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:
2026-08-08 10:46:25 +00:00
parent 2bbdcfd9fe
commit 44421a51e3
11 changed files with 2004 additions and 1 deletions
+571
View File
@@ -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());
}
}