using System.ComponentModel; using System.Drawing.Drawing2D; using System.Diagnostics; 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 _lblStrength; 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 ProgressBar _gaugeA; readonly ProgressBar _gaugeB; readonly Label _lblGaugeA; readonly Label _lblGaugeB; readonly TrackBar _limitBar; readonly Label _lblLimit; readonly CheckBox _chkScale; readonly Icon _iconNeutral; readonly Icon _iconActive; readonly Icon _iconHot; string _trayState = ""; readonly Button _btnMusic; readonly Label _lblTrack; bool _isMusicRunning; WaveOutEvent? _audioOut; Stopwatch? _musicStopwatch; public MainForm(CoyoteDevice device, State state, Server server) { _device = device; _state = state; _server = server; _loopCts = new CancellationTokenSource(); Text = "Substation"; ClientSize = new Size(360, 340); FormBorderStyle = FormBorderStyle.FixedSingle; MaximizeBox = false; StartPosition = FormStartPosition.CenterScreen; ShowInTaskbar = true; _lblBle = new Label { Text = "BLE: searching...", Location = new Point(16, 16), Size = new Size(328, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _lblWs = new Label { Text = "WS: idle", Location = new Point(16, 40), Size = new Size(328, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _lblStrength = new Label { Text = "Strength: A=0 B=0", Location = new Point(16, 64), Size = new Size(328, 20) }; _lblDeviceName = new Label { Text = "Device:", Location = new Point(16, 90), Size = new Size(48, 20) }; _txtDeviceName = new TextBox { Text = "47L121000", Location = new Point(68, 88), Size = new Size(160, 20) }; _btnConnect = new Button { Text = "Connect", Location = new Point(236, 88), Size = new Size(108, 24) }; _btnConnect.Click += OnConnect; _btnTest = new Button { Text = "Test", Location = new Point(16, 120), Size = new Size(100, 32) }; _btnTest.Click += OnTest; _btnMusic = new Button { Text = "Music", Location = new Point(130, 120), Size = new Size(100, 32) }; _btnMusic.Click += OnMusic; _btnStop = new Button { Text = "Stop All", Location = new Point(244, 120), Size = new Size(100, 32) }; _btnStop.Click += OnStop; _lblTrack = new Label { Text = "", Location = new Point(16, 158), Size = new Size(328, 16), ForeColor = Color.DimGray }; _lblGaugeA = new Label { Text = "A", Location = new Point(16, 180), Size = new Size(16, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _gaugeA = new ProgressBar { Location = new Point(40, 180), Size = new Size(304, 20), Minimum = 0, Maximum = 200, Style = ProgressBarStyle.Continuous }; _lblGaugeB = new Label { Text = "B", Location = new Point(16, 208), Size = new Size(16, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _gaugeB = new ProgressBar { Location = new Point(40, 208), Size = new Size(304, 20), Minimum = 0, Maximum = 200, Style = ProgressBarStyle.Continuous }; _lblLimit = new Label { Text = "Limit: 30", Location = new Point(16, 248), Size = new Size(64, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _limitBar = new TrackBar { Location = new Point(80, 244), Size = new Size(180, 45), Minimum = 0, Maximum = 200, TickFrequency = 50, Value = 30 }; _limitBar.ValueChanged += OnLimitChanged; _chkScale = new CheckBox { Text = "Scale", Location = new Point(268, 248), Size = new Size(76, 24), Checked = false }; _chkScale.CheckedChanged += OnLimitChanged; OnLimitChanged(null, EventArgs.Empty); Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblStrength, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblGaugeA, _gaugeA, _lblGaugeB, _gaugeB, _lblLimit, _limitBar, _chkScale }); // 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 = 250 }; _statusTimer.Tick += OnStatusTick; _statusTimer.Start(); Load += OnLoad; FormClosing += OnFormClosing; Resize += OnResize; _server.ClientChanged += OnWsClientChanged; } 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 { if (_device.IsConnected) { var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick(); var frame = B0.Build(0, modeA, modeB, valA, valB, freqA, intA, freqB, intB); await _device.SendB0(frame); } } 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"; _btnTest.Enabled = !connected && !IsTestRunning; _btnMusic.Enabled = !connected && !_isMusicRunning; UpdateTrayIcon(); }); } void OnStatusTick(object? sender, EventArgs e) { if (IsDisposed) return; _lblBle.Text = _device.IsConnected ? $"BLE: connected ({_device.DeviceName})" : "BLE: disconnected"; _lblStrength.Text = $"Strength: A={_device.StrengthA} B={_device.StrengthB}"; _gaugeA.Value = Math.Clamp(_device.StrengthA, 0, 200); _gaugeB.Value = Math.Clamp(_device.StrengthB, 0, 200); _btnTest.Enabled = !_server.HasClient && !IsTestRunning; _btnMusic.Enabled = !_server.HasClient && !_isMusicRunning; _btnStop.Enabled = true; if (_isMusicRunning && _musicStopwatch != null) { var elapsed = _musicStopwatch.Elapsed; _lblTrack.Text = $"Playing {elapsed:mm\\:ss} / {_musicTotalTime:mm\\:ss} {Path.GetFileName(_musicFilePath)}"; } 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; _btnTest.Enabled = !_server.HasClient; }); } void OnStop(object? sender, EventArgs e) { StopMusic(); _state.SetStrength('A', 0); _state.SetStrength('B', 0); _state.Stop('A'); _state.Stop('B'); } void OnLimitChanged(object? sender, EventArgs e) { var max = _limitBar.Value; var mode = _chkScale.Checked ? LimitMode.Scale : LimitMode.Clamp; _state.SetLimit(max, mode); _lblLimit.Text = $"Limit: {max}"; } 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; _btnMusic.Enabled = !_server.HasClient; _btnTest.Enabled = !_server.HasClient && !IsTestRunning; }); 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 = ""; _btnMusic.Enabled = !_server.HasClient; } } } 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 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()); } }