using System.ComponentModel; using System.Drawing.Drawing2D; using NAudio.CoreAudioApi; 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 _btnStop; readonly System.Windows.Forms.Timer _statusTimer; readonly CancellationTokenSource _loopCts; 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 TrackBar _ampBarA; readonly TrackBar _ampBarB; readonly Label _lblLimitA; readonly Label _lblLimitB; readonly Label _lblLimitValA; readonly Label _lblLimitValB; readonly Label _lblAmpA; readonly Label _lblAmpB; readonly Label _lblAmpValA; readonly Label _lblAmpValB; readonly CheckBox _chkScaleA; readonly CheckBox _chkScaleB; readonly CheckBox _chkSwap; readonly Icon _iconNeutral; readonly Icon _iconActive; readonly Icon _iconHot; string _trayState = ""; readonly Button _btnLive; readonly Button _btnPattern; readonly Button _btnRandom; readonly Label _lblTrack; readonly Label _lblAudioDev; readonly ComboBox _cboAudioDev; bool _isLiveRunning; bool _isPatternRunning; bool _isRandomRunning; 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, 520); 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; _btnLive = new Button { Text = "Live", Location = new Point(16, 96), Size = new Size(80, 32) }; _btnLive.Click += OnLive; _btnPattern = new Button { Text = "Pattern", Location = new Point(104, 96), Size = new Size(80, 32) }; _btnPattern.Click += OnPattern; _btnRandom = new Button { Text = "Random", Location = new Point(192, 96), Size = new Size(80, 32) }; _btnRandom.Click += OnRandom; _btnStop = new Button { Text = "Stop All", Location = new Point(324, 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); _lblAmpA = new Label { Text = "Amp A", Location = new Point(16, 394), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _ampBarA = new TrackBar { Location = new Point(68, 390), Size = new Size(212, 45), Minimum = 0, Maximum = 100, TickFrequency = 25, Value = 0 }; _ampBarA.ValueChanged += OnAmpChanged; FixTrackbarKeys(_ampBarA); _lblAmpValA = new Label { Text = "0", Location = new Point(286, 394), Size = new Size(32, 20), TextAlign = ContentAlignment.MiddleRight }; _lblAmpB = new Label { Text = "Amp B", Location = new Point(16, 442), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _ampBarB = new TrackBar { Location = new Point(68, 438), Size = new Size(212, 45), Minimum = 0, Maximum = 100, TickFrequency = 25, Value = 0 }; _ampBarB.ValueChanged += OnAmpChanged; FixTrackbarKeys(_ampBarB); _lblAmpValB = new Label { Text = "0", Location = new Point(286, 442), Size = new Size(32, 20), TextAlign = ContentAlignment.MiddleRight }; OnAmpChanged(null, EventArgs.Empty); Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnLive, _btnPattern, _btnRandom, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB, _lblAmpA, _ampBarA, _lblAmpValA, _lblAmpB, _ampBarB, _lblAmpValB }); // 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(() => { double aScale = _state.LimitScaleA; double bScale = _state.LimitScaleB; var scaledIntA = new byte[4]; var scaledIntB = new byte[4]; for (int i = 0; i < 4; i++) { scaledIntA[i] = (byte)Math.Round(intA[i] * aScale); scaledIntB[i] = (byte)Math.Round(intB[i] * bScale); } _heatA.AddTick(freqA, scaledIntA, _state.LastActiveA); _heatB.AddTick(freqB, scaledIntB, _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() { _tray.Visible = false; Application.Exit(); } void OnResize(object? sender, EventArgs e) { if (WindowState == FormWindowState.Minimized) Hide(); } void OnFormClosing(object? sender, FormClosingEventArgs e) { _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() { bool busy = _isLiveRunning || _isPatternRunning || _isRandomRunning; _btnLive.Enabled = !_server.HasClient && !busy; _btnPattern.Enabled = !_server.HasClient && !busy; _btnRandom.Enabled = !_server.HasClient && !busy; _btnStop.Enabled = true; } void OnStatusTick(object? sender, EventArgs e) { if (IsDisposed) return; if (_isLiveRunning) { _lblTrack.Text = "Live capture"; } else if (_isPatternRunning) { _lblTrack.Text = $"Pattern: {_patternName}"; } else if (_isRandomRunning) { _lblTrack.Text = "Random noise"; } UpdateTrayIcon(); } void OnStop(object? sender, EventArgs e) { StopLive(); StopPattern(); StopRandom(); _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 OnAmpChanged(object? sender, EventArgs e) { var ampA = 1.0 + _ampBarA.Value / 100.0 * 9.0; var ampB = 1.0 + _ampBarB.Value / 100.0 * 9.0; _state.SetAmpA(ampA); _state.SetAmpB(ampB); _lblAmpValA.Text = $"{_ampBarA.Value}"; _lblAmpValB.Text = $"{_ampBarB.Value}"; } void OnSwapChanged(object? sender, EventArgs e) { _state.SwapChannels = _chkSwap.Checked; } 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(); } } } string _patternName = ""; CancellationTokenSource? _patternCts; void OnPattern(object? sender, EventArgs e) { if (_isPatternRunning || _server.HasClient) return; var url = ShowInputDialog("Paste xToys pattern URL:", "Pattern Import"); if (string.IsNullOrWhiteSpace(url)) return; _isPatternRunning = true; _btnPattern.Enabled = false; _lblTrack.Text = "Loading pattern..."; _patternCts = new CancellationTokenSource(); Task.Run(() => RunPatternAsync(url, _patternCts.Token)); } async Task RunPatternAsync(string url, CancellationToken ct) { XToysPattern? pattern = null; try { pattern = await XToysPatternImporter.ImportAsync(url); } catch (Exception ex) { BeginInvoke(() => { _lblTrack.Text = $"Pattern failed: {ex.Message?.Split('\n')[0]}"; _isPatternRunning = false; RefreshButtonStates(); }); return; } _patternName = pattern.Name; _state.SetStrength('A', 60); _state.SetStrength('B', 60); _state.Stop('A'); _state.Stop('B'); BeginInvoke(() => _lblTrack.Text = $"Pattern: {_patternName}"); // Continuously enqueue pattern frames at 100ms per frame int idx = 0; while (!ct.IsCancellationRequested && _isPatternRunning) { var frameA = pattern.ChannelA[idx]; var frameB = pattern.ChannelB[idx]; _state.EnqueueStream('A', new[] { frameA }); _state.EnqueueStream('B', new[] { frameB }); idx = (idx + 1) % pattern.ChannelA.Count; try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; } } } void StopPattern() { _patternCts?.Cancel(); _patternCts = null; if (_isPatternRunning) { _isPatternRunning = false; if (!IsDisposed) { _lblTrack.Text = ""; RefreshButtonStates(); } } } CancellationTokenSource? _randomCts; void OnRandom(object? sender, EventArgs e) { if (_isRandomRunning || _server.HasClient) return; _isRandomRunning = true; _btnRandom.Enabled = false; _lblTrack.Text = "Random noise"; _state.SetStrength('A', 60); _state.SetStrength('B', 60); _state.Stop('A'); _state.Stop('B'); _randomCts = new CancellationTokenSource(); Task.Run(() => RunRandomAsync(_randomCts.Token)); } async Task RunRandomAsync(CancellationToken ct) { // Pink noise via Voss-McCartney algorithm (1/f spectrum) // Separate state per channel, B inverted for counterphase var rng = new Random(); int octaves = 8; var rowsA = new double[octaves]; var rowsB = new double[octaves]; double pinkA = 0, pinkB = 0; while (!ct.IsCancellationRequested && _isRandomRunning) { // Channel A int updateA = rng.Next(octaves); rowsA[updateA] = rng.NextDouble() * 2 - 1; pinkA = 0; for (int i = 0; i < octaves; i++) pinkA += rowsA[i]; pinkA /= octaves; // Channel B (independent state, inverted for counterphase) int updateB = rng.Next(octaves); rowsB[updateB] = rng.NextDouble() * 2 - 1; pinkB = 0; for (int i = 0; i < octaves; i++) pinkB += rowsB[i]; pinkB /= octaves; pinkB = -pinkB; // counterphase // Map pink noise (-1..1) to intensity (0..80, capped for comfort) int intA = (int)Math.Clamp((pinkA + 1) * 40, 0, 80); int intB = (int)Math.Clamp((pinkB + 1) * 40, 0, 80); // Random frequency: map pink noise to period (10-1000ms) int freqMsA = (int)Math.Clamp((pinkA + 1) * 500, 10, 1000); int freqMsB = (int)Math.Clamp((pinkB + 1) * 500, 10, 1000); var frameA = new WaveFrame( Freq.Compress4(new[] { freqMsA, freqMsA, freqMsA, freqMsA }), Intensity.Clamp4(new[] { intA, intA, intA, intA })); var frameB = new WaveFrame( Freq.Compress4(new[] { freqMsB, freqMsB, freqMsB, freqMsB }), Intensity.Clamp4(new[] { intB, intB, intB, intB })); _state.EnqueueStream('A', new[] { frameA }); _state.EnqueueStream('B', new[] { frameB }); try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; } } } void StopRandom() { _randomCts?.Cancel(); _randomCts = null; if (_isRandomRunning) { _isRandomRunning = false; if (!IsDisposed) { _lblTrack.Text = ""; RefreshButtonStates(); } } } string ShowInputDialog(string prompt, string title) { using var dlg = new Form { Text = title, FormBorderStyle = FormBorderStyle.FixedDialog, ClientSize = new Size(380, 100), StartPosition = FormStartPosition.CenterParent, MaximizeBox = false, MinimizeBox = false }; var lbl = new Label { Text = prompt, Location = new Point(12, 12), Size = new Size(356, 20) }; var txt = new TextBox { Location = new Point(12, 36), Size = new Size(356, 22) }; var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, Location = new Point(200, 66), Size = new Size(80, 24) }; var cancel = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Location = new Point(288, 66), Size = new Size(80, 24) }; dlg.Controls.AddRange(new Control[] { lbl, txt, ok, cancel }); dlg.AcceptButton = ok; dlg.CancelButton = cancel; return dlg.ShowDialog(this) == DialogResult.OK ? txt.Text.Trim() : ""; } 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()); } }