612385af7f
- Four gauges: Send A/B (commanded) and Recv A/B (device-reported) - State.LastSentA/B tracks what would be sent after limiting - ConsumeTick always runs; only SendB0 gated on IsConnected - Stream queues drain correctly in dev mode (no box) - Tray icon hot state works without BLE connected - Removed strength text label, widened form to 420x360
599 lines
18 KiB
C#
599 lines
18 KiB
C#
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 _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 _gaugeSendA;
|
|
readonly ProgressBar _gaugeSendB;
|
|
readonly ProgressBar _gaugeRecvA;
|
|
readonly ProgressBar _gaugeRecvB;
|
|
readonly Label _lblSendA;
|
|
readonly Label _lblSendB;
|
|
readonly Label _lblRecvA;
|
|
readonly Label _lblRecvB;
|
|
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(420, 360);
|
|
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(296, 64),
|
|
Size = new Size(108, 24)
|
|
};
|
|
_btnConnect.Click += OnConnect;
|
|
|
|
_btnTest = new Button
|
|
{
|
|
Text = "Test",
|
|
Location = new Point(16, 96),
|
|
Size = new Size(100, 32)
|
|
};
|
|
_btnTest.Click += OnTest;
|
|
|
|
_btnMusic = new Button
|
|
{
|
|
Text = "Music",
|
|
Location = new Point(130, 96),
|
|
Size = new Size(100, 32)
|
|
};
|
|
_btnMusic.Click += OnMusic;
|
|
|
|
_btnStop = new Button
|
|
{
|
|
Text = "Stop All",
|
|
Location = new Point(244, 96),
|
|
Size = new Size(100, 32)
|
|
};
|
|
_btnStop.Click += OnStop;
|
|
|
|
_lblTrack = new Label
|
|
{
|
|
Text = "",
|
|
Location = new Point(16, 134),
|
|
Size = new Size(388, 16),
|
|
ForeColor = Color.DimGray
|
|
};
|
|
|
|
_lblSendA = new Label
|
|
{
|
|
Text = "Send A",
|
|
Location = new Point(16, 160),
|
|
Size = new Size(48, 20),
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
};
|
|
_gaugeSendA = new ProgressBar
|
|
{
|
|
Location = new Point(72, 160),
|
|
Size = new Size(332, 20),
|
|
Minimum = 0,
|
|
Maximum = 200,
|
|
Style = ProgressBarStyle.Continuous
|
|
};
|
|
_lblSendB = new Label
|
|
{
|
|
Text = "Send B",
|
|
Location = new Point(16, 184),
|
|
Size = new Size(48, 20),
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
};
|
|
_gaugeSendB = new ProgressBar
|
|
{
|
|
Location = new Point(72, 184),
|
|
Size = new Size(332, 20),
|
|
Minimum = 0,
|
|
Maximum = 200,
|
|
Style = ProgressBarStyle.Continuous
|
|
};
|
|
_lblRecvA = new Label
|
|
{
|
|
Text = "Recv A",
|
|
Location = new Point(16, 212),
|
|
Size = new Size(48, 20),
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
};
|
|
_gaugeRecvA = new ProgressBar
|
|
{
|
|
Location = new Point(72, 212),
|
|
Size = new Size(332, 20),
|
|
Minimum = 0,
|
|
Maximum = 200,
|
|
Style = ProgressBarStyle.Continuous
|
|
};
|
|
_lblRecvB = new Label
|
|
{
|
|
Text = "Recv B",
|
|
Location = new Point(16, 236),
|
|
Size = new Size(48, 20),
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
};
|
|
_gaugeRecvB = new ProgressBar
|
|
{
|
|
Location = new Point(72, 236),
|
|
Size = new Size(332, 20),
|
|
Minimum = 0,
|
|
Maximum = 200,
|
|
Style = ProgressBarStyle.Continuous
|
|
};
|
|
|
|
_lblLimit = new Label
|
|
{
|
|
Text = "Limit: 30",
|
|
Location = new Point(16, 272),
|
|
Size = new Size(64, 20),
|
|
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
|
};
|
|
_limitBar = new TrackBar
|
|
{
|
|
Location = new Point(80, 268),
|
|
Size = new Size(240, 45),
|
|
Minimum = 0,
|
|
Maximum = 200,
|
|
TickFrequency = 50,
|
|
Value = 30
|
|
};
|
|
_limitBar.ValueChanged += OnLimitChanged;
|
|
_chkScale = new CheckBox
|
|
{
|
|
Text = "Scale",
|
|
Location = new Point(328, 272),
|
|
Size = new Size(76, 24),
|
|
Checked = false
|
|
};
|
|
_chkScale.CheckedChanged += OnLimitChanged;
|
|
OnLimitChanged(null, EventArgs.Empty);
|
|
|
|
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblSendA, _gaugeSendA, _lblSendB, _gaugeSendB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _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
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
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";
|
|
_gaugeSendA.Value = Math.Clamp(_state.LastSentA, 0, 200);
|
|
_gaugeSendB.Value = Math.Clamp(_state.LastSentB, 0, 200);
|
|
_gaugeRecvA.Value = Math.Clamp(_device.StrengthA, 0, 200);
|
|
_gaugeRecvB.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<WaveFrame>();
|
|
var framesB = new List<WaveFrame>();
|
|
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<int>(pct =>
|
|
{
|
|
if (!IsDisposed)
|
|
_lblTrack.Text = $"Analyzing... {pct}% {Path.GetFileName(filePath)}";
|
|
});
|
|
pattern = await Task.Run(() => MusicAnalyzer.Analyze(filePath, (IProgress<int>)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());
|
|
}
|
|
}
|