using System.ComponentModel; using System.Drawing.Drawing2D; using System.Diagnostics; using NAudio.CoreAudioApi; using NAudio.Wave; namespace Substation; public class MainForm : Form { readonly CoyoteDevice _device; readonly State _state; readonly Server _server; readonly NotifyIcon _tray; readonly Label _lblBle; readonly Label _lblWs; readonly Label _lblDeviceName; readonly TextBox _txtDeviceName; readonly Button _btnConnect; readonly Button _btnTest; readonly Button _btnStop; readonly System.Windows.Forms.Timer _statusTimer; readonly CancellationTokenSource _loopCts; bool _closingFromTray; readonly HeatMap _heatA; readonly HeatMap _heatB; readonly ProgressBar _gaugeRecvA; readonly ProgressBar _gaugeRecvB; readonly Label _lblSendA; readonly Label _lblSendB; readonly Label _lblRecvA; readonly Label _lblRecvB; readonly TrackBar _limitBarA; readonly TrackBar _limitBarB; readonly Label _lblLimitA; readonly Label _lblLimitB; readonly Label _lblLimitValA; readonly Label _lblLimitValB; readonly CheckBox _chkScaleA; readonly CheckBox _chkScaleB; readonly CheckBox _chkSwap; readonly Icon _iconNeutral; readonly Icon _iconActive; readonly Icon _iconHot; string _trayState = ""; readonly Button _btnMusic; readonly Button _btnLive; readonly Label _lblTrack; readonly Label _lblAudioDev; readonly ComboBox _cboAudioDev; bool _isMusicRunning; bool _isLiveRunning; WaveOutEvent? _audioOut; Stopwatch? _musicStopwatch; LiveCapture? _liveCapture; public MainForm(CoyoteDevice device, State state, Server server) { _device = device; _state = state; _server = server; _loopCts = new CancellationTokenSource(); Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}"; ClientSize = new Size(420, 450); FormBorderStyle = FormBorderStyle.FixedSingle; MaximizeBox = false; StartPosition = FormStartPosition.CenterScreen; ShowInTaskbar = true; _lblBle = new Label { Text = "BLE: searching...", Location = new Point(16, 16), Size = new Size(388, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _lblWs = new Label { Text = "WS: idle", Location = new Point(16, 40), Size = new Size(388, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _lblDeviceName = new Label { Text = "Device:", Location = new Point(16, 66), Size = new Size(48, 20) }; _txtDeviceName = new TextBox { Text = "47L121000", Location = new Point(68, 64), Size = new Size(160, 20) }; _btnConnect = new Button { Text = "Connect", Location = new Point(236, 64), Size = new Size(80, 24) }; _btnConnect.Click += OnConnect; _chkSwap = new CheckBox { Text = "Swap", Location = new Point(328, 66), Size = new Size(56, 20), Checked = false }; _chkSwap.CheckedChanged += OnSwapChanged; _btnTest = new Button { Text = "Test", Location = new Point(16, 96), Size = new Size(80, 32) }; _btnTest.Click += OnTest; _btnMusic = new Button { Text = "Music", Location = new Point(104, 96), Size = new Size(80, 32) }; _btnMusic.Click += OnMusic; _btnLive = new Button { Text = "Live", Location = new Point(192, 96), Size = new Size(80, 32) }; _btnLive.Click += OnLive; _btnStop = new Button { Text = "Stop All", Location = new Point(280, 96), Size = new Size(80, 32) }; _btnStop.Click += OnStop; _lblAudioDev = new Label { Text = "Audio:", Location = new Point(16, 134), Size = new Size(40, 20) }; _cboAudioDev = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Location = new Point(60, 132), Size = new Size(344, 22) }; PopulateAudioDevices(); _lblTrack = new Label { Text = "", Location = new Point(16, 158), Size = new Size(388, 16), ForeColor = Color.DimGray }; _lblSendA = new Label { Text = "Send A", Location = new Point(16, 184), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _heatA = new HeatMap(60) { Location = new Point(72, 182), Size = new Size(332, 22) }; _lblSendB = new Label { Text = "Send B", Location = new Point(16, 212), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _heatB = new HeatMap(60) { Location = new Point(72, 210), Size = new Size(332, 22) }; _lblRecvA = new Label { Text = "Recv A", Location = new Point(16, 240), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _gaugeRecvA = new ProgressBar { Location = new Point(72, 240), Size = new Size(332, 20), Minimum = 0, Maximum = 200, Style = ProgressBarStyle.Continuous }; _lblRecvB = new Label { Text = "Recv B", Location = new Point(16, 266), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _gaugeRecvB = new ProgressBar { Location = new Point(72, 266), Size = new Size(332, 20), Minimum = 0, Maximum = 200, Style = ProgressBarStyle.Continuous }; _lblLimitA = new Label { Text = "Lim A", Location = new Point(16, 298), Size = new Size(40, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _limitBarA = new TrackBar { Location = new Point(60, 294), Size = new Size(220, 45), Minimum = 0, Maximum = 200, TickFrequency = 50, Value = 30 }; _limitBarA.ValueChanged += OnLimitChanged; FixTrackbarKeys(_limitBarA); _lblLimitValA = new Label { Text = "30", Location = new Point(286, 298), Size = new Size(32, 20), TextAlign = ContentAlignment.MiddleRight }; _chkScaleA = new CheckBox { Text = "Scale", Location = new Point(320, 298), Size = new Size(76, 24), Checked = false }; _chkScaleA.CheckedChanged += OnLimitChanged; _lblLimitB = new Label { Text = "Lim B", Location = new Point(16, 346), Size = new Size(40, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _limitBarB = new TrackBar { Location = new Point(60, 342), Size = new Size(220, 45), Minimum = 0, Maximum = 200, TickFrequency = 50, Value = 30 }; _limitBarB.ValueChanged += OnLimitChanged; FixTrackbarKeys(_limitBarB); _lblLimitValB = new Label { Text = "30", Location = new Point(286, 346), Size = new Size(32, 20), TextAlign = ContentAlignment.MiddleRight }; _chkScaleB = new CheckBox { Text = "Scale", Location = new Point(320, 346), Size = new Size(76, 24), Checked = false }; _chkScaleB.CheckedChanged += OnLimitChanged; OnLimitChanged(null, EventArgs.Empty); Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnTest, _btnMusic, _btnLive, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB }); // Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange _iconNeutral = CreateVoltageIcon(Color.Gray); _iconActive = CreateVoltageIcon(Color.Gold); _iconHot = CreateVoltageIcon(Color.OrangeRed); Icon = _iconActive; _tray = new NotifyIcon { Icon = _iconActive, Visible = true, Text = "Substation" }; _tray.ContextMenuStrip = new ContextMenuStrip(); _tray.ContextMenuStrip.Items.Add("Show", null, (_, _) => ShowFromTray()); _tray.ContextMenuStrip.Items.Add("-"); _tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) => ExitFromTray()); _tray.DoubleClick += (_, _) => ShowFromTray(); _statusTimer = new System.Windows.Forms.Timer { Interval = 1000 }; _statusTimer.Tick += OnStatusTick; _statusTimer.Start(); Load += OnLoad; FormClosing += OnFormClosing; Resize += OnResize; _server.ClientChanged += OnWsClientChanged; _device.ConnectionChanged += OnDeviceConnectionChanged; _device.StrengthChanged += OnDeviceStrengthChanged; } async void OnLoad(object? sender, EventArgs e) { _ = _server.RunAsync(CancellationToken.None); _ = Task.Run(() => TickLoop(_loopCts.Token)); try { await _device.ConnectAsync(_txtDeviceName.Text); } catch (Exception ex) { Console.Error.WriteLine($"[BLE] auto-connect failed: {ex.Message}"); } } async Task TickLoop(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick(); if (_device.IsConnected) { var frame = B0.Build(0, modeA, modeB, valA, valB, freqA, intA, freqB, intB); await _device.SendB0(frame); } if (!IsDisposed) { BeginInvoke(() => { _heatA.AddTick(freqA, intA, _state.LastActiveA); _heatB.AddTick(freqB, intB, _state.LastActiveB); }); } } catch (Exception ex) { Console.Error.WriteLine($"[tick] error: {ex.Message}"); } try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; } } } void ShowFromTray() { Show(); WindowState = FormWindowState.Normal; Activate(); } void ExitFromTray() { _closingFromTray = true; _tray.Visible = false; Application.Exit(); } void OnResize(object? sender, EventArgs e) { if (WindowState == FormWindowState.Minimized) Hide(); } void OnFormClosing(object? sender, FormClosingEventArgs e) { if (e.CloseReason == CloseReason.UserClosing && !_closingFromTray) { e.Cancel = true; Hide(); return; } _tray.Visible = false; _statusTimer.Stop(); _loopCts.Cancel(); _server.Stop(); } void OnConnect(object? sender, EventArgs e) { if (_device.IsConnected) return; _btnConnect.Enabled = false; _lblBle.Text = "BLE: connecting..."; Task.Run(async () => { try { await _device.ConnectAsync(_txtDeviceName.Text); } catch (Exception ex) { BeginInvoke(() => _lblBle.Text = $"BLE: error - {ex.Message}"); } finally { BeginInvoke(() => _btnConnect.Enabled = true); } }); } void OnWsClientChanged(bool connected) { if (IsDisposed) return; BeginInvoke(() => { _lblWs.Text = connected ? "WS: client connected" : "WS: idle"; RefreshButtonStates(); UpdateTrayIcon(); }); } void OnDeviceConnectionChanged(bool connected) { if (IsDisposed) return; BeginInvoke(() => { _lblBle.Text = connected ? $"BLE: connected ({_device.DeviceName})" : "BLE: disconnected"; _btnConnect.Enabled = !connected; RefreshButtonStates(); }); } void OnDeviceStrengthChanged(int a, int b) { if (IsDisposed) return; BeginInvoke(() => { if (_state.SwapChannels) (a, b) = (b, a); _gaugeRecvA.Value = Math.Clamp(a, 0, 200); _gaugeRecvB.Value = Math.Clamp(b, 0, 200); }); } void RefreshButtonStates() { _btnTest.Enabled = !_server.HasClient && !IsTestRunning && !_isMusicRunning && !_isLiveRunning; _btnMusic.Enabled = !_server.HasClient && !_isMusicRunning && !IsTestRunning && !_isLiveRunning; _btnLive.Enabled = !_server.HasClient && !_isLiveRunning && !IsTestRunning && !_isMusicRunning; _btnStop.Enabled = true; } void OnStatusTick(object? sender, EventArgs e) { if (IsDisposed) return; if (_isMusicRunning && _musicStopwatch != null) { var elapsed = _musicStopwatch.Elapsed; _lblTrack.Text = $"Playing {elapsed:mm\\:ss} / {_musicTotalTime:mm\\:ss} {Path.GetFileName(_musicFilePath)}"; } else if (_isLiveRunning) { _lblTrack.Text = "Live capture"; } UpdateTrayIcon(); } bool IsTestRunning; void OnTest(object? sender, EventArgs e) { if (IsTestRunning || _server.HasClient) return; IsTestRunning = true; _btnTest.Enabled = false; Task.Run(() => RunTestAsync()); } async Task RunTestAsync() { // Short feel-good pattern over both channels (~3s), gentle swell, A leads B. const int testStrength = 25; _state.SetStrength('A', testStrength); _state.SetStrength('B', testStrength); // 30 frames x 100ms = 3 seconds. Deep-ish 7Hz pulse with breathing envelope. var framesA = new List(); var framesB = new List(); for (int i = 0; i < 30; i++) { double t = (double)i / 30; // Swell 0 -> 70 -> 0 over the run double env = Math.Sin(Math.PI * t) * 70; int intA = (int)Math.Round(env); int intB = (int)Math.Round(env * 0.7); // B slightly softer int freqMs = 150; // ~7Hz deep framesA.Add(new WaveFrame( Freq.Compress4(new[] { freqMs, freqMs, freqMs, freqMs }), Intensity.Clamp4(new[] { intA, intA, intA, intA }))); framesB.Add(new WaveFrame( Freq.Compress4(new[] { freqMs, freqMs, freqMs, freqMs }), Intensity.Clamp4(new[] { intB, intB, intB, intB }))); } _state.Stop('A'); _state.Stop('B'); _state.EnqueueStream('A', framesA); _state.EnqueueStream('B', framesB); // Wait for the stream to be consumed (~3s) plus margin, then silence. await Task.Delay(3300); _state.SetStrength('A', 0); _state.SetStrength('B', 0); _state.Stop('A'); _state.Stop('B'); BeginInvoke(() => { IsTestRunning = false; RefreshButtonStates(); }); } void OnStop(object? sender, EventArgs e) { StopMusic(); StopLive(); _state.SetStrength('A', 0); _state.SetStrength('B', 0); _state.Stop('A'); _state.Stop('B'); } void OnLimitChanged(object? sender, EventArgs e) { var maxA = _limitBarA.Value; var maxB = _limitBarB.Value; var modeA = _chkScaleA.Checked ? LimitMode.Scale : LimitMode.Clamp; var modeB = _chkScaleB.Checked ? LimitMode.Scale : LimitMode.Clamp; _state.SetLimitA(maxA, modeA); _state.SetLimitB(maxB, modeB); _lblLimitValA.Text = $"{maxA}"; _lblLimitValB.Text = $"{maxB}"; } void OnSwapChanged(object? sender, EventArgs e) { _state.SwapChannels = _chkSwap.Checked; } string _musicFilePath = ""; TimeSpan _musicTotalTime; void OnMusic(object? sender, EventArgs e) { if (_isMusicRunning || _server.HasClient) return; using var dlg = new OpenFileDialog { Filter = "MP3 files (*.mp3)|*.mp3|All files (*.*)|*.*", Title = "Select music to drive e-stim" }; if (dlg.ShowDialog() != DialogResult.OK) return; _musicFilePath = dlg.FileName; _musicTotalTime = TimeSpan.Zero; _isMusicRunning = true; _btnMusic.Enabled = false; _btnTest.Enabled = false; _lblTrack.Text = $"Analyzing... {Path.GetFileName(_musicFilePath)}"; var filePath = _musicFilePath; Task.Run(() => RunMusicAsync(filePath)); } async Task RunMusicAsync(string filePath) { MusicPattern? pattern = null; try { var progress = new Progress(pct => { if (!IsDisposed) _lblTrack.Text = $"Analyzing... {pct}% {Path.GetFileName(filePath)}"; }); pattern = await Task.Run(() => MusicAnalyzer.Analyze(filePath, (IProgress)progress)); } catch (Exception ex) { BeginInvoke(() => { _lblTrack.Text = $"Analysis failed: {ex.Message}"; _isMusicRunning = false; RefreshButtonStates(); }); return; } _musicTotalTime = pattern.Duration; // Set a moderate strength ceiling for music _state.SetStrength('A', 60); _state.SetStrength('B', 60); // Clear any existing patterns and enqueue all music frames _state.Stop('A'); _state.Stop('B'); _state.EnqueueStream('A', pattern.ChannelA); _state.EnqueueStream('B', pattern.ChannelB); // Start audio playback + stopwatch simultaneously try { var reader = new AudioFileReader(filePath); _audioOut = new WaveOutEvent(); _audioOut.Init(reader); _audioOut.PlaybackStopped += (_, _) => BeginInvoke(StopMusic); _musicStopwatch = Stopwatch.StartNew(); _audioOut.Play(); } catch (Exception ex) { Console.Error.WriteLine($"[music] playback failed: {ex.Message}"); _musicStopwatch = Stopwatch.StartNew(); } BeginInvoke(() => _lblTrack.Text = $"Playing 00:00 / {_musicTotalTime:mm\\:ss} {Path.GetFileName(filePath)}"); } void StopMusic() { if (_audioOut != null) { try { _audioOut.Stop(); } catch { } try { _audioOut.Dispose(); } catch { } _audioOut = null; } _musicStopwatch = null; if (_isMusicRunning) { _isMusicRunning = false; if (!IsDisposed) { _lblTrack.Text = ""; RefreshButtonStates(); } } } void PopulateAudioDevices() { using var enumerator = new MMDeviceEnumerator(); var defaultDev = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Console); var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active); _cboAudioDev.Items.Clear(); int defaultIdx = 0; for (int i = 0; i < devices.Count; i++) { _cboAudioDev.Items.Add(devices[i]); if (devices[i].ID == defaultDev.ID) defaultIdx = i; } _cboAudioDev.SelectedIndex = defaultIdx; } void OnLive(object? sender, EventArgs e) { if (_isLiveRunning || _server.HasClient) return; if (_cboAudioDev.SelectedItem is not MMDevice dev) return; _isLiveRunning = true; _btnLive.Enabled = false; _lblTrack.Text = "Starting live capture..."; _state.SetStrength('A', 60); _state.SetStrength('B', 60); _state.Stop('A'); _state.Stop('B'); _liveCapture = new LiveCapture(_state, dev); _liveCapture.Stopped += () => BeginInvoke(StopLive); _liveCapture.Start(); _lblTrack.Text = "Live capture"; RefreshButtonStates(); } void StopLive() { if (_liveCapture != null) { try { _liveCapture.Stop(); } catch { } try { _liveCapture.Dispose(); } catch { } _liveCapture = null; } if (_isLiveRunning) { _isLiveRunning = false; if (!IsDisposed) { _lblTrack.Text = ""; RefreshButtonStates(); } } } void UpdateTrayIcon() { var newState = _state.IsSignaling ? "hot" : _server.HasClient ? "active" : "neutral"; if (newState == _trayState) return; _trayState = newState; _tray.Icon = newState switch { "hot" => _iconHot, "active" => _iconActive, _ => _iconNeutral }; } static void FixTrackbarKeys(TrackBar tb) { tb.KeyDown += (s, e) => { if (s is not TrackBar t) return; int step = e.KeyCode == Keys.Up || e.KeyCode == Keys.Down ? 1 : e.KeyCode == Keys.PageUp || e.KeyCode == Keys.PageDown ? 10 : 0; if (step == 0) return; if (e.KeyCode is Keys.Up or Keys.PageUp or Keys.Home) t.Value = Math.Min(t.Maximum, t.Value + (e.KeyCode == Keys.Home ? t.Maximum : step)); else t.Value = Math.Max(t.Minimum, t.Value - (e.KeyCode == Keys.End ? t.Maximum : step)); e.Handled = true; }; } static Icon CreateVoltageIcon(Color boltColor) { const int sz = 32; using var bmp = new Bitmap(sz, sz); using var g = Graphics.FromImage(bmp); g.SmoothingMode = SmoothingMode.AntiAlias; // Dark panel background with rounded corners. using var bg = new SolidBrush(Color.FromArgb(45, 45, 48)); using var path = new GraphicsPath(); int r = 6, pad = 1; var rect = new Rectangle(pad, pad, sz - 2 * pad, sz - 2 * pad); path.AddArc(rect.X, rect.Y, r, r, 180, 90); path.AddArc(rect.Right - r, rect.Y, r, r, 270, 90); path.AddArc(rect.Right - r, rect.Bottom - r, r, r, 0, 90); path.AddArc(rect.X, rect.Bottom - r, r, r, 90, 90); path.CloseFigure(); g.FillPath(bg, path); // Generic voltage symbol: a bold lightning bolt in the given colour. var pts = new Point[] { new(21, 4), new(10, 18), new(16, 18), new(13, 28), new(24, 13), new(17, 13) }; using var bolt = new SolidBrush(boltColor); g.FillPolygon(bolt, pts); return Icon.FromHandle(bmp.GetHicon()); } }