Implement Substation: BLE-WS bridge with music-reactive e-stim
- Renamed from CoyoteBridge to Substation (namespace, classes, UI) - BLE connection via WinRT with graceful disconnect handling - WebSocket server (127.0.0.1:8765) with single-client, push events for BLE connect/disconnect transitions - Operator strength limiter (clamp/scale modes, default 30) - Tray icon with 3 states: neutral/active/hot (runtime-drawn voltage glyph) - A/B strength gauges, hide-on-minimise to tray - Music mode: MP3 decode + FFT analysis (NAudio + FftSharp), rhythm→ch A, melody→ch B, pre-analyzed with synced audio playback - Test/Stop All work without BLE connected (dev mode) - WS device-driving commands error when BLE not connected; ping/status/ connect always work - TreatWarningsAsErrors, LangVersion=latest - .gitignore, README with full API docs + attribution
This commit is contained in:
+571
@@ -0,0 +1,571 @@
|
||||
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;
|
||||
OnLimitChanged(null, EventArgs.Empty);
|
||||
_chkScale = new CheckBox
|
||||
{
|
||||
Text = "Scale",
|
||||
Location = new Point(268, 248),
|
||||
Size = new Size(76, 24),
|
||||
Checked = false
|
||||
};
|
||||
_chkScale.CheckedChanged += OnLimitChanged;
|
||||
|
||||
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<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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user