From 44421a51e3cee78356eb13b370fa5e4f7c5e9e76 Mon Sep 17 00:00:00 2001 From: Mute Date: Sat, 8 Aug 2026 10:46:25 +0000 Subject: [PATCH] Implement Substation: BLE-WS bridge with music-reactive e-stim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 33 +++ CoyoteDevice.cs | 135 +++++++++++ MainForm.cs | 571 ++++++++++++++++++++++++++++++++++++++++++++++ MusicAnalyzer.cs | 220 ++++++++++++++++++ NOTES.md | 131 +++++++++++ Program.cs | 24 ++ Protocol.cs | 182 +++++++++++++++ README.md | 230 ++++++++++++++++++- Server.cs | 334 +++++++++++++++++++++++++++ State.cs | 124 ++++++++++ Substation.csproj | 21 ++ 11 files changed, 2004 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 CoyoteDevice.cs create mode 100644 MainForm.cs create mode 100644 MusicAnalyzer.cs create mode 100644 NOTES.md create mode 100644 Program.cs create mode 100644 Protocol.cs create mode 100644 Server.cs create mode 100644 State.cs create mode 100644 Substation.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02bfee4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +## .NET build output +bin/ +obj/ + +## User-specific files +*.user +*.suo +*.userprefs +*.vs/ +*.DotSettings.user + +## NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/* +!*.build.props +!*.build.targets + +## Visual Studio / Rider / VS Code +.idea/ +.vscode/ +*.sln.docstates + +## OS files +Thumbs.db +ehthumbs.db +Desktop.ini +.DS_Store + +## Logs / temp +*.log +tmp/ +*.tmp diff --git a/CoyoteDevice.cs b/CoyoteDevice.cs new file mode 100644 index 0000000..de01d8e --- /dev/null +++ b/CoyoteDevice.cs @@ -0,0 +1,135 @@ +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; +using Windows.Devices.Enumeration; +using Windows.Storage.Streams; + +namespace Substation; + +public class CoyoteDevice : IDisposable +{ + static readonly Guid ServiceUuid = Guid.Parse("0000180c-0000-1000-8000-00805f9b34fb"); + static readonly Guid WriteCharUuid = Guid.Parse("0000150a-0000-1000-8000-00805f9b34fb"); + static readonly Guid NotifyCharUuid= Guid.Parse("0000150b-0000-1000-8000-00805f9b34fb"); + + BluetoothLEDevice? _device; + GattCharacteristic? _write; + GattCharacteristic? _notify; + + public bool IsConnected => _write != null; + public int StrengthA { get; private set; } + public int StrengthB { get; private set; } + public string DeviceName { get; private set; } = ""; + + public event Action? StrengthChanged; + public event Action? ConnectionChanged; + + public async Task ConnectAsync(string nameFilter = "47L121000") + { + var selector = BluetoothLEDevice.GetDeviceSelectorFromDeviceName(nameFilter); + var devices = await DeviceInformation.FindAllAsync(selector); + + if (devices.Count == 0) + { + selector = BluetoothLEDevice.GetDeviceSelectorFromPairingState(false); + devices = await DeviceInformation.FindAllAsync(selector); + var match = devices.FirstOrDefault(d => + d.Name.Contains("47L121000", StringComparison.OrdinalIgnoreCase) || + d.Name.Contains("DG-LAB", StringComparison.OrdinalIgnoreCase) || + d.Name.Contains("Coyote", StringComparison.OrdinalIgnoreCase)); + if (match == null) + throw new InvalidOperationException( + $"Coyote 3.0 not found (filter='{nameFilter}'). Ensure it's powered on and BLE is enabled."); + // fallback path + var fallback = await BluetoothLEDevice.FromIdAsync(match.Id); + await ConnectDeviceAsync(fallback, match.Name); + return; + } + + var dev = await BluetoothLEDevice.FromIdAsync(devices[0].Id); + await ConnectDeviceAsync(dev, devices[0].Name); + } + + async Task ConnectDeviceAsync(BluetoothLEDevice dev, string name) + { + _device = dev; + DeviceName = name; + _device.ConnectionStatusChanged += OnConnectionStatusChanged; + + var svcResult = await dev.GetGattServicesForUuidAsync(ServiceUuid, BluetoothCacheMode.Uncached); + if (svcResult.Status != GattCommunicationStatus.Success || svcResult.Services.Count == 0) + throw new InvalidOperationException($"GATT service {ServiceUuid} not found on {name}."); + + var svc = svcResult.Services[0]; + + var charResult = await svc.GetCharacteristicsAsync(); + if (charResult.Status != GattCommunicationStatus.Success) + throw new InvalidOperationException($"Failed to enumerate characteristics on {name}."); + + _write = charResult.Characteristics.FirstOrDefault(c => c.Uuid == WriteCharUuid) + ?? throw new InvalidOperationException("Write characteristic 0x150A not found."); + _notify = charResult.Characteristics.FirstOrDefault(c => c.Uuid == NotifyCharUuid) + ?? throw new InvalidOperationException("Notify characteristic 0x150B not found."); + + _notify.ValueChanged += OnNotify; + var subStatus = await _notify.WriteClientCharacteristicConfigurationDescriptorAsync( + GattClientCharacteristicConfigurationDescriptorValue.Notify); + if (subStatus != GattCommunicationStatus.Success) + throw new InvalidOperationException("Failed to subscribe to 0x150B notifications."); + + await SendBF(200, 200, 128, 128, 128, 128); + ConnectionChanged?.Invoke(true); + } + + void OnConnectionStatusChanged(BluetoothLEDevice sender, object args) + { + if (sender.ConnectionStatus == Windows.Devices.Bluetooth.BluetoothConnectionStatus.Disconnected) + { + _write = null; + _notify = null; + ConnectionChanged?.Invoke(false); + } + } + + void OnNotify(GattCharacteristic sender, GattValueChangedEventArgs args) + { + var reader = DataReader.FromBuffer(args.CharacteristicValue); + if (reader.UnconsumedBufferLength < 1) return; + + var head = reader.ReadByte(); + if (head == 0xB1 && reader.UnconsumedBufferLength >= 3) + { + reader.ReadByte(); + StrengthA = reader.ReadByte(); + StrengthB = reader.ReadByte(); + StrengthChanged?.Invoke(StrengthA, StrengthB); + } + } + + public async Task SendB0(byte[] frame) + { + if (_write == null) return; + var writer = new DataWriter(); + writer.WriteBytes(frame); + var status = await _write.WriteValueAsync(writer.DetachBuffer(), GattWriteOption.WriteWithoutResponse); + if (status != GattCommunicationStatus.Success) + Console.Error.WriteLine($"[BLE] B0 write failed: {status}"); + } + + public async Task SendBF(byte capA, byte capB, byte freqBalA, byte freqBalB, byte intBalA, byte intBalB) + { + if (_write == null) return; + var frame = BF.Build(capA, capB, freqBalA, freqBalB, intBalA, intBalB); + var writer = new DataWriter(); + writer.WriteBytes(frame); + var status = await _write.WriteValueAsync(writer.DetachBuffer(), GattWriteOption.WriteWithoutResponse); + if (status != GattCommunicationStatus.Success) + Console.Error.WriteLine($"[BLE] BF write failed: {status}"); + } + + public void Dispose() + { + if (_notify != null) + _notify.ValueChanged -= OnNotify; + _device?.Dispose(); + } +} diff --git a/MainForm.cs b/MainForm.cs new file mode 100644 index 0000000..24123de --- /dev/null +++ b/MainForm.cs @@ -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(); + 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()); + } +} diff --git a/MusicAnalyzer.cs b/MusicAnalyzer.cs new file mode 100644 index 0000000..be855f9 --- /dev/null +++ b/MusicAnalyzer.cs @@ -0,0 +1,220 @@ +using System.Numerics; +using FftSharp; +using NAudio.Wave; + +namespace Substation; + +public record MusicPattern(List ChannelA, List ChannelB, TimeSpan Duration); + +public static class MusicAnalyzer +{ + const int SampleRate = 44100; + const int WindowSize = 2048; + const int HopSize = 1024; + const double TickDuration = 0.1; // 100ms per e-stim frame + + // Frequency band boundaries (Hz) + const double RhythmLow = 20, RhythmHigh = 250; + const double MelodyLow = 300, MelodyHigh = 4000; + + public static MusicPattern Analyze(string mp3Path, IProgress? progress = null) + { + var samples = DecodeToMono(mp3Path); + var duration = TimeSpan.FromSeconds((double)samples.Length / SampleRate); + + var tickCount = (int)Math.Ceiling((double)samples.Length / SampleRate / TickDuration); + var channelA = new List(tickCount); + var channelB = new List(tickCount); + + var window = new FftSharp.Windows.Hanning(); + var buffer = new double[WindowSize]; + int fftsPerTick = (int)Math.Round(TickDuration * SampleRate / HopSize); + + double[]? prevRhythmMag = null; + double maxFlux = 0; + double maxMelodyEnergy = 0; + + // First pass: collect per-tick features + var tickFeatures = new List(tickCount); + int pos = 0; + int fftIndex = 0; + + while (pos + WindowSize <= samples.Length) + { + for (int i = 0; i < WindowSize; i++) + buffer[i] = samples[pos + i]; + + window.ApplyInPlace(buffer); + var spectrum = FFT.Forward(buffer); + var mag = FFT.Magnitude(spectrum); + + var (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq) = + ExtractFeatures(mag, prevRhythmMag); + + if (rhythmFlux > maxFlux) maxFlux = rhythmFlux; + if (melodyEnergy > maxMelodyEnergy) maxMelodyEnergy = melodyEnergy; + + prevRhythmMag = mag; + + int tickIdx = fftIndex / Math.Max(1, fftsPerTick); + while (tickFeatures.Count <= tickIdx) + tickFeatures.Add(new TickFeature()); + + var tf = tickFeatures[tickIdx]; + tf.RhythmFlux = Math.Max(tf.RhythmFlux, rhythmFlux); + tf.MelodyEnergy += melodyEnergy; + tf.MelodyFreqSamples.Add(melodyFreq); + tf.MelodyCount++; + + pos += HopSize; + fftIndex++; + + if (progress != null && fftIndex % 50 == 0) + { + var pct = (int)((double)pos / samples.Length * 100); + progress.Report(pct); + } + } + + progress?.Report(100); + + // Second pass: normalize and build WaveFrames + const int rhythmFreqMs = 150; // ~7Hz deep pulse + + foreach (var tf in tickFeatures) + { + // Channel A: rhythm onset + double normalizedFlux = maxFlux > 0 ? tf.RhythmFlux / maxFlux : 0; + int onsetIntensity = (int)Math.Round(normalizedFlux * 100); + // Sub-tick attack/decay shape + var intA = new[] + { + (byte)Math.Clamp(onsetIntensity, 0, 100), + (byte)Math.Clamp(onsetIntensity * 6 / 10, 0, 100), + (byte)Math.Clamp(onsetIntensity * 3 / 10, 0, 100), + (byte)0 + }; + var freqA = Freq.Compress4(new[] { rhythmFreqMs, rhythmFreqMs, rhythmFreqMs, rhythmFreqMs }); + channelA.Add(new WaveFrame(freqA, intA)); + + // Channel B: melody + double avgEnergy = tf.MelodyCount > 0 ? tf.MelodyEnergy / tf.MelodyCount : 0; + double normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0; + int melodyIntensity = (int)Math.Round(normalizedEnergy * 80); // cap at 80 for comfort + melodyIntensity = Math.Clamp(melodyIntensity, 0, 100); + + // Weighted average dominant frequency + double weightedFreq = 0; + double totalWeight = 0; + foreach (var f in tf.MelodyFreqSamples) + { + weightedFreq += f * f; // weight by energy (freq already squared mag) + totalWeight += f; + } + double avgMelodyHz = totalWeight > 0 ? weightedFreq / totalWeight : 500; + int estimsMs = MapPitchToPeriod(avgMelodyHz); + + var intB = new[] + { + (byte)melodyIntensity, (byte)melodyIntensity, + (byte)melodyIntensity, (byte)melodyIntensity + }; + var freqB = Freq.Compress4(new[] { estimsMs, estimsMs, estimsMs, estimsMs }); + channelB.Add(new WaveFrame(freqB, intB)); + } + + return new MusicPattern(channelA, channelB, duration); + } + + static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq) ExtractFeatures( + double[] magnitude, double[]? prevRhythmMag) + { + double binWidth = (double)SampleRate / WindowSize; + int rhythmLoBin = (int)(RhythmLow / binWidth); + int rhythmHiBin = (int)(RhythmHigh / binWidth); + int melodyLoBin = (int)(MelodyLow / binWidth); + int melodyHiBin = (int)(MelodyHigh / binWidth); + + // Rhythm band energy + double rhythmEnergy = 0; + for (int i = rhythmLoBin; i <= rhythmHiBin && i < magnitude.Length; i++) + rhythmEnergy += magnitude[i] * magnitude[i]; + rhythmEnergy = Math.Sqrt(rhythmEnergy / (rhythmHiBin - rhythmLoBin + 1)); + + // Spectral flux (positive change in rhythm band) + double rhythmFlux = 0; + if (prevRhythmMag != null) + { + for (int i = rhythmLoBin; i <= rhythmHiBin && i < magnitude.Length; i++) + { + double diff = magnitude[i] - prevRhythmMag[i]; + if (diff > 0) rhythmFlux += diff; + } + } + + // Melody band: energy + dominant frequency (spectral peak) + double melodyEnergy = 0; + double peakMag = 0; + int peakBin = melodyLoBin; + for (int i = melodyLoBin; i <= melodyHiBin && i < magnitude.Length; i++) + { + double m = magnitude[i]; + melodyEnergy += m * m; + if (m > peakMag) + { + peakMag = m; + peakBin = i; + } + } + melodyEnergy = Math.Sqrt(melodyEnergy / (melodyHiBin - melodyLoBin + 1)); + double melodyFreq = peakBin * binWidth; + + return (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq); + } + + static int MapPitchToPeriod(double hz) + { + // Map melody frequency (300-4000Hz) to e-stim period (10-1000ms) + // Logarithmic mapping: low notes → deep, high notes → buzzy + double logFreq = Math.Log(Math.Clamp(hz, MelodyLow, MelodyHigh)); + double logMin = Math.Log(MelodyLow); + double logMax = Math.Log(MelodyHigh); + double t = (logFreq - logMin) / (logMax - logMin); // 0..1 + // Invert: high freq → short period (buzzy), low freq → long period (deep) + int ms = (int)Math.Round(1000 - t * 990); // 1000ms..10ms + return Math.Clamp(ms, 10, 1000); + } + + static double[] DecodeToMono(string mp3Path) + { + using var reader = new Mp3FileReader(mp3Path); + var format = new WaveFormat(SampleRate, 16, 1); + using var resampler = new MediaFoundationResampler(reader, format); + resampler.ResamplerQuality = 60; + + var sampleList = new List(); + var buffer = new byte[SampleRate * 2]; // 1s worth of 16-bit mono + int read; + while ((read = resampler.Read(buffer, 0, buffer.Length)) > 0) + { + for (int i = 0; i < read; i += 2) + { + short sample = (short)(buffer[i] | (buffer[i + 1] << 8)); + sampleList.Add(sample / 32768f); + } + } + + var result = new double[sampleList.Count]; + for (int i = 0; i < sampleList.Count; i++) + result[i] = sampleList[i]; + return result; + } + + class TickFeature + { + public double RhythmFlux; + public double MelodyEnergy; + public double MelodyCount; + public readonly List MelodyFreqSamples = new(); + } +} diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..2081717 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,131 @@ +# Coyote 3.0 — Research Notes + +## Device + +**DG-Lab Coyote 3.0** — biphasic pulse generator, 2 independent channels (A/B). +- BLE name: `47L121000` +- Service UUID: `0x180C` +- Write characteristic: `0x150A` (commands in) +- Notify characteristic: `0x150B` (responses out) +- Base UUID: `0000XXXX-0000-1000-8000-00805f9b34fb` +- Strength range: 0–200 per channel (amplitude ceiling) +- Waveform frequency byte: 10–240 (compressed from 10–1000ms) +- Waveform intensity byte: 0–100 (pulse width, relative) +- Output window: 25ms; commands carry 4 ticks = 100ms of output + +## How waveforms work + +The box outputs **biphasic pulses**. A waveform is a time-series of two parameters: + +1. **Frequency** = pulse repetition period (ms). Low ms = high Hz = buzzy/tingly. High ms = low Hz = deep/thumpy. + - 10ms ≈ 100Hz, 50ms ≈ 20Hz, 100ms ≈ 10Hz, 1000ms ≈ 1Hz +2. **Intensity** = pulse width (0–100, relative). Wider pulse = stronger feel. The intensity envelope is what makes a pattern pleasurable vs. sharp — slow swells/fades, not square edges. + +**Strength** (0–200) is the hard amplitude ceiling per channel, set separately. Do expression in the 0–100 intensity envelope; keep strength modest. + +### Frequency compression (input ms → device byte 10–240) + +``` +10–100 → identity +101–600 → (input - 100) / 5 + 100 +601–1000 → (input - 600) / 10 + 200 +``` + +### B0 frame (20 bytes, sent every 100ms) + +| Byte(s) | Field | +|---------|-------| +| 0 | `0xB0` head | +| 1 | seq (high 4) + strength mode (low 4): 00=none, 01=add, 10=sub, 11=abs | +| 2 | Channel A strength (0–200) | +| 3 | Channel B strength (0–200) | +| 4–7 | Ch A frequency ×4 (10–240) | +| 8–11 | Ch A intensity ×4 (0–100) | +| 12–15 | Ch B frequency ×4 | +| 16–19 | Ch B intensity ×4 | + +Invalid value in any channel's 4-tuple → device drops all 4 for that channel. To disable a channel, send intensity `101` in one slot. + +### BF frame (7 bytes, soft caps + balance) + +| Byte(s) | Field | +|---------|-------| +| 0 | `0xBF` head | +| 1–2 | Ch A/B strength soft cap (0–200, persisted) | +| 3–4 | Ch A/B frequency balance (0–255, 128=neutral; higher = stronger low-freq impact) | +| 5–6 | Ch A/B intensity balance (0–255, 128=neutral; higher = stronger low-freq stimulation) | + +⚠️ BF takes effect immediately with no response. Must re-send after every reconnect. + +### B1 notification (from 0x150B) + +| Byte(s) | Field | +|---------|-------| +| 0 | `0xB1` head | +| 1 | sequence number (matches the B0 that caused the change, 0 if wheel) | +| 2 | Ch A actual strength | +| 3 | Ch B actual strength | + +## Connectivity topologies + +The last mile is **always BLE**. Everything upstream is an adapter. + +1. **Direct BLE** — any BLE host writes B0/BF directly. No app, no internet. + - Web Bluetooth (Chrome/Edge) → xToys.app, OpenDGLab-Connect + - Python `bleak`, C# WinRT, Node `noble` +2. **Phone-app bridge (DG-Lab Socket mode)** — phone app pairs to box via BLE AND runs a WebSocket endpoint. Third-party terminal connects to that WS (LAN or internet) and sends commands the app forwards over BLE. + - `PyDGLab-WS` (Python, 81★) implements both client and server sides + - QR code in app encodes WS URL + clientId for binding +3. **OpenDGLab OpenProtocol** — protobuf protocol for OPClient ↔ device-host. Richer; meant for game/VR integrations (HL2, R.E.P.O. mods). + +## Browser Bluetooth + +- **Web Bluetooth API** (`navigator.bluetooth.requestDevice`) — Chrome, Edge, Opera, Brave, Android Chrome. xToys and OpenDGLab-Connect use this. +- **Firefox**: not supported, Mozilla refuses over fingerprinting concerns. No flag. Use app-bridge + WS instead. +- **Safari/iOS**: not supported. + +## Key repos + +| Repo | What | +|------|------| +| `dungeonlab-open/dglab-bluetooth-protocol` (638★) | **Official** BLE protocol docs + example waveform data (V2/V3) | +| `OpenDGLab/OpenDGLab-WaveGen` (8★) | Web GUI waveform editor, exports pattern strings. Live: opendglab.github.io/OpenDGLab-WaveGen | +| `OpenDGLab/OpenDGLab-OpenProtocol` (16★) | OpenProtocol spec (protobuf) | +| `OpenDGLab/OpenDGLab-Core` (45★, Kotlin) | Reference implementation of BLE protocol | +| `OpenDGLab/OpenDGLab-Connect` (19★, JS) | Web client using Web Bluetooth | +| `OpenDGLab/OpenDGLab-Desktop` (58★, C++) | Desktop client | +| `Ljzd-PRO/PyDGLab-WS` (81★, Python) | App-socket bridge library, async, well-maintained. Docs: pydglab-ws.readthedocs.io | +| `Kruziikloksu/simple-custom-dg-lab-server` (Python) | Custom relay server, supports app-exported waveforms | +| `huzpsb/DGLAB4J` (Java) | Coyote v3 socket protocol, Java impl | +| `EcstasyEngineer/coyote-mcp` (JS) | MCP server for Coyote via app socket (LAN) | +| `AngelcoMilk/DGLabPunish` (C#) | R.E.P.O. game mod with continuous waveforms | + +## Where to find feel-good patterns + +1. **Official example data**: `dungeonlab-open/dglab-bluetooth-protocol` — added V2/V3 波形示例数据 on 2024/10/28, under `coyote/v2` and `coyote/v3` directories +2. **Built-in named patterns**: app presets (`Flick`, `Click`, …). Enumerate via `GETWAVELIST` (OpenProtocol) or select by name (PyDGLab-WS) +3. **OpenDGLab-WaveGen**: visual editor that exports pattern strings importable in code +4. **Community**: DG-Lab Discord/subreddit, nonebot plugin repos, game mod repos share pattern strings + +## Pattern generation shapes (sensation over shock) + +- **Slow breathing**: intensity = `50 + 40*sin(2π t / 8s)`, freq fixed ~150ms (~7Hz deep). 8s period. +- **Teasing ramp**: intensity 0→80 over 5s, hold 1s, drop to 5, repeat. Freq alternating 100ms/250ms. +- **Flutter**: freq 20ms (50Hz), intensity 10–30 quick pulses. Buzzy/tingly, low strength. + +## Substation app (built this session) + +Location: this directory +- .NET 8 console app, targets `net8.0-windows10.0.19041.0` +- Direct BLE via WinRT (`BluetoothLEDevice`, `GattCharacteristic`) +- WebSocket server on `127.0.0.1:8765` via `HttpListener` +- 100ms tick loop builds B0 from shared state and writes to BLE +- JSON commands: `connect`, `status`, `strength`, `wave`, `stream`, `stop`, `config`, `disconnect`, `ping` +- Designed to be driven by a browser userscript talking to `ws://127.0.0.1:8765` + +``` +web game / userscript ──WS──> Substation ──BLE──> Coyote 3.0 + 127.0.0.1:8765 B0/BF frames +``` + +Build: `dotnet run -c Release` diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..1d1995b --- /dev/null +++ b/Program.cs @@ -0,0 +1,24 @@ +namespace Substation; + +static class Program +{ + public const int Port = 8765; + + [STAThread] + static void Main() + { + ApplicationConfiguration.Initialize(); + + using var device = new CoyoteDevice(); + var state = new State(); + var server = new Server(device, state, Port); + + device.StrengthChanged += (a, b) => + { + state.ActualA = a; + state.ActualB = b; + }; + + Application.Run(new MainForm(device, state, server)); + } +} diff --git a/Protocol.cs b/Protocol.cs new file mode 100644 index 0000000..da2b94f --- /dev/null +++ b/Protocol.cs @@ -0,0 +1,182 @@ +using System.Text.Json.Serialization; + +namespace Substation; + +public static class Freq +{ + public static byte Compress(int ms) + { + if (ms is >= 10 and <= 100) return (byte)ms; + if (ms is > 100 and <= 600) return (byte)((ms - 100) / 5 + 100); + if (ms is > 600 and <= 1000) return (byte)((ms - 600) / 10 + 200); + return 10; + } + + public static byte[] Compress4(int[] ms) + { + if (ms.Length != 4) throw new ArgumentException("freq must have exactly 4 values", nameof(ms)); + return new byte[] + { + Compress(ms[0]), Compress(ms[1]), Compress(ms[2]), Compress(ms[3]) + }; + } +} + +public static class Intensity +{ + public static byte Clamp(int v) => (byte)Math.Clamp(v, 0, 100); + + public static byte[] Clamp4(int[] v) + { + if (v.Length != 4) throw new ArgumentException("intensity must have exactly 4 values", nameof(v)); + return new byte[] { Clamp(v[0]), Clamp(v[1]), Clamp(v[2]), Clamp(v[3]) }; + } +} + +public enum StrengthMode : byte +{ + None = 0b00, + Add = 0b01, + Sub = 0b10, + Abs = 0b11 +} + +public enum LimitMode +{ + Clamp = 0, + Scale = 1 +} + +public static class B0 +{ + public static byte PackModes(StrengthMode a, StrengthMode b) => + (byte)(((int)a << 2) | (int)b); + + public static byte[] Build( + byte seq, + StrengthMode modeA, StrengthMode modeB, + byte strengthA, byte strengthB, + byte[] freqA, byte[] intA, + byte[] freqB, byte[] intB) + { + if (freqA.Length != 4 || intA.Length != 4 || freqB.Length != 4 || intB.Length != 4) + throw new ArgumentException("wave arrays must be 4 bytes each"); + + var f = new byte[20]; + f[0] = 0xB0; + f[1] = (byte)((seq << 4) | (PackModes(modeA, modeB) & 0x0F)); + f[2] = strengthA; + f[3] = strengthB; + Buffer.BlockCopy(freqA, 0, f, 4, 4); + Buffer.BlockCopy(intA, 0, f, 8, 4); + Buffer.BlockCopy(freqB, 0, f, 12, 4); + Buffer.BlockCopy(intB, 0, f, 16, 4); + return f; + } + + public static readonly byte[] ChannelOff = + { 0, 0, 0, 0 }; + + public static readonly byte[] IntensityOff = + { 0, 0, 0, 101 }; +} + +public static class BF +{ + public static byte[] Build( + byte capA, byte capB, + byte freqBalA, byte freqBalB, + byte intBalA, byte intBalB) + { + return new byte[] + { + 0xBF, capA, capB, freqBalA, freqBalB, intBalA, intBalB + }; + } +} + +public record WaveFrame(byte[] Freq, byte[] Intensity); + +public class Command +{ + [JsonPropertyName("op")] public string Op { get; set; } = ""; + + [JsonPropertyName("channel")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Channel { get; set; } + + [JsonPropertyName("mode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Mode { get; set; } + + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Value { get; set; } + + [JsonPropertyName("freq")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int[]? Freq { get; set; } + + [JsonPropertyName("intensity")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int[]? Intensity { get; set; } + + [JsonPropertyName("frames")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FrameDto[]? Frames { get; set; } + + [JsonPropertyName("softcapA")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? SoftCapA { get; set; } + + [JsonPropertyName("softcapB")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? SoftCapB { get; set; } + + [JsonPropertyName("freqBalA")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? FreqBalA { get; set; } + + [JsonPropertyName("freqBalB")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? FreqBalB { get; set; } + + [JsonPropertyName("intBalA")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? IntBalA { get; set; } + + [JsonPropertyName("intBalB")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? IntBalB { get; set; } +} + +public class FrameDto +{ + [JsonPropertyName("freq")] public int[]? Freq { get; set; } + [JsonPropertyName("intensity")] public int[]? Intensity { get; set; } +} + +public class OkResponse +{ + [JsonPropertyName("ok")] public bool Ok => true; + [JsonPropertyName("msg")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Msg { get; set; } +} + +public class ErrResponse +{ + [JsonPropertyName("ok")] public bool Ok => false; + [JsonPropertyName("error")] public string Error { get; set; } = ""; +} + +public class StatusResponse +{ + [JsonPropertyName("ok")] public bool Ok => true; + [JsonPropertyName("event")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Event { get; set; } + [JsonPropertyName("connected")] public bool Connected { get; set; } + [JsonPropertyName("strengthA")] public int StrengthA { get; set; } + [JsonPropertyName("strengthB")] public int StrengthB { get; set; } +} diff --git a/README.md b/README.md index 400fc1c..03c6730 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,231 @@ # Substation -DGLab Coyote 3.0 WS adapter desktop app \ No newline at end of file +A thin .NET 8 **WinForms** app that bridges a **DG-Lab Coyote 3.0** e-stim box over Bluetooth Low Energy to a **WebSocket API on 127.0.0.1**. Designed to be driven by a browser userscript, game mod, or any process that can speak JSON over WS. Runs in the system tray. + +``` +web game / userscript ──WS──> Substation ──BLE──> Coyote 3.0 + 127.0.0.1:8765 B0/BF frames +``` + +## Requirements + +- Windows 10 1809+ (Bluetooth LE support) +- .NET 8 SDK — `winget install Microsoft.DotNet.SDK.8` (or ) +- Bluetooth radio enabled +- Coyote 3.0 powered on (advertises as `47L121000`) + +## Build + +```bat +cd Substation +dotnet build -c Release +dotnet run -c Release +``` + +Or publish a self-contained exe: + +```bat +dotnet publish -c Release -r win-x64 --self-contained +``` + +## UI + +A small window with: +- **BLE status** — connection state + device name +- **WS status** — idle / client connected (only one client allowed at a time; additional attempts are rejected) +- **Strength readout** — current A/B strength from the box +- **Connect BLE** — retry connection if auto-connect failed +- **Test** — runs a ~3s gentle swell pattern on both channels (strength 25, 7Hz deep pulse, sinusoidal envelope). Enabled only when BLE is connected AND no WS client is attached. +- **Stop All** — zeros strength and clears waveforms on both channels (panic stop) + +Closing the window minimizes to the tray icon (double-click to restore; right-click for Show/Exit menu). + +## How it works + +The app runs two concurrent loops: + +1. **Tick loop** — every 100ms, builds a `B0` frame (20 bytes) from the current shared state and writes it to BLE characteristic `0x150A`. This is the Coyote's output window: 4 frequency + 4 intensity values per channel, each value representing 25ms of output. +2. **WebSocket server** — listens on `127.0.0.1:PORT`, accepts JSON commands that mutate the shared state (strength, waveform patterns, stream queues). Only one WS client is served at a time; a second client receives `{"ok":false,"error":"another client is already connected"}` and is closed. + +On BLE connect, the app sends a `BF` frame to set soft caps and balance parameters with safe defaults (caps=200, balances=128). + +### Protocol cheat sheet + +| Byte(s) | B0 field | +|---------|---------------------------------------| +| 0 | `0xB0` (command head) | +| 1 | seq (high 4 bits) + strength mode (low 4 bits) | +| 2 | Channel A strength value (0–200) | +| 3 | Channel B strength value (0–200) | +| 4–7 | Channel A waveform frequency ×4 (10–240) | +| 8–11 | Channel A waveform intensity ×4 (0–100) | +| 12–15 | Channel B waveform frequency ×4 | +| 16–19 | Channel B waveform intensity ×4 | + +Frequency input is in **milliseconds** (10–1000, where 10ms = 100Hz buzzy, 1000ms = 1Hz deep thump). The app compresses this to the device's 10–240 byte range: + +| Input range | Compression formula | +|--------------|------------------------------| +| 10–100 | identity | +| 101–600 | `(input - 100) / 5 + 100` | +| 601–1000 | `(input - 600) / 10 + 200` | + +## WebSocket API + +All commands are JSON objects with an `op` field. Send one per message. Responses are JSON: `{"ok":true,"msg":"..."}` or `{"ok":false,"error":"..."}`. + +### `connect` +```json +{"op":"connect"} +``` +Scans for the Coyote and connects. Auto-attempted on startup; retry with this if it fails. + +### `status` +```json +{"op":"status"} +``` +Returns: +```json +{"ok":true,"connected":true,"strengthA":15,"strengthB":0} +``` + +### `strength` +```json +{"op":"strength","channel":"A","value":50} +``` +Sets channel strength (absolute, 0–200). This is the **hard ceiling** — the panic stop. Set to 0 to silence a channel immediately. + +### `wave` +```json +{"op":"wave","channel":"A","freq":[100,100,100,100],"intensity":[0,20,50,80]} +``` +Sets a **looping 4-tick pattern** for the channel. Repeats every 100ms until replaced or stopped. +- `freq`: 4 values in ms (10–1000) +- `intensity`: 4 values 0–100 (pulse width, relative) + +### `stream` +```json +{"op":"stream","channel":"A","frames":[ + {"freq":[100,100,100,100],"intensity":[0,30,60,90]}, + {"freq":[50,50,50,50],"intensity":[90,70,40,10]} +]} +``` +Queues **one-shot frames** that play before resuming the loop pattern. Each frame = 100ms. Use for transitions, ramps, timed effects. + +### `stop` +```json +{"op":"stop","channel":"A"} +``` +Clears the loop pattern and stream queue for the channel. Channel goes silent. + +### `config` +```json +{"op":"config","softcapA":150,"softcapB":150,"freqBalA":128,"freqBalB":128,"intBalA":128,"intBalB":128} +``` +Sends a `BF` frame. Soft caps limit the maximum strength (persisted on device). Balance parameters adjust low/high frequency feel (0–255, 128 = neutral). All fields optional; omitted fields use defaults. + +### `disconnect` +```json +{"op":"disconnect"} +``` +Stops all waveforms on both channels. BLE connection stays alive. + +### `ping` +```json +{"op":"ping"} +``` +Returns `{"ok":true,"msg":"pong"}`. Use for keepalive. + +## Example userscript + +```javascript +// ==UserScript== +// @name Coyote Bridge Client +// @match https://your-game.example.com/* +// @grant none +// ==/UserScript== + +const ws = new WebSocket("ws://127.0.0.1:8765"); + +ws.onopen = () => { + console.log("[coyote] connected to bridge"); + ws.send(JSON.stringify({ op: "status" })); +}; + +ws.onmessage = (e) => { + console.log("[coyote]", JSON.parse(e.data)); +}; + +ws.onerror = (e) => console.error("[coyote] WS error", e); + +function setStrength(ch, val) { + ws.send(JSON.stringify({ op: "strength", channel: ch, value: val })); +} + +function setWave(ch, freq, intensity) { + ws.send(JSON.stringify({ op: "wave", channel: ch, freq, intensity })); +} + +function stop(ch) { + ws.send(JSON.stringify({ op: "stop", channel: ch })); +} + +// Example: breathing pattern on channel A +// 7Hz deep pulse (150ms), intensity swells 0->80->0 over ~8 ticks +const breathe = [ + { freq: [150,150,150,150], intensity: [0, 10, 20, 30] }, + { freq: [150,150,150,150], intensity: [40, 55, 70, 80] }, + { freq: [150,150,150,150], intensity: [80, 70, 55, 40] }, + { freq: [150,150,150,150], intensity: [30, 20, 10, 0 ] }, +]; + +let breatheIdx = 0; +setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ + op: "stream", + channel: "A", + frames: [breathe[breatheIdx]] + })); + breatheIdx = (breatheIdx + 1) % breathe.length; +}, 100); + +// Set a safe strength ceiling first +setStrength("A", 30); + +// Panic stop +// stop("A"); setStrength("A", 0); +``` + +## Safety notes + +- **Start with low strength** (10–30). The box goes to 200; that's a lot. +- Set soft caps via `config` to limit the ceiling before experimenting. +- The `stop` command + `strength 0` is your emergency brake. +- Strength is the **amplitude ceiling**; waveform intensity (0–100) is the **pulse width** that creates texture within that ceiling. Keep strength modest and do expression in the intensity envelope. +- The app uses `seq=0` (no strength ack) for simplicity. Strength changes are fire-and-forget. If you need guaranteed delivery, enhance `CoyoteDevice` to use `seq>0` and wait for `B1` responses. + +## Project structure + +``` +Substation/ +├── Substation.csproj — net8.0-windows, WinForms, WinRT BLE +├── Protocol.cs — freq compression, B0/BF builders, JSON DTOs +├── CoyoteDevice.cs — BLE connect, GATT read/write, notify handling +├── State.cs — thread-safe shared state (strength, wave queues) +├── Server.cs — HttpListener WebSocket server (single-client) + command dispatch +├── MainForm.cs — WinForms window, tray icon, voltage glyph, test pattern, tick loop +├── Program.cs — entry point, wires components, runs form +└── README.md +``` + +## References + +- [DG-LAB official BLE protocol](https://github.com/dungeonlab-open/dglab-bluetooth-protocol) +- [Waveform explanation](https://github.com/dungeonlab-open/dglab-bluetooth-protocol/blob/main/coyote/README.md) +- [PyDGLab-WS](https://github.com/Ljzd-PRO/PyDGLab-WS) — Python equivalent (app-socket path) +- [OpenDGLab WaveGen](https://opendglab.github.io/OpenDGLab-WaveGen/) — visual waveform editor + +--- + +Entirely vibecoded with [GLM 5.2](https://chatglm.ai). diff --git a/Server.cs b/Server.cs new file mode 100644 index 0000000..ffdfda9 --- /dev/null +++ b/Server.cs @@ -0,0 +1,334 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; + +namespace Substation; + +public class Server +{ + readonly CoyoteDevice _device; + readonly State _state; + readonly HttpListener _listener; + readonly string _url; + + int _hasClient; // 0 = no, 1 = yes (Interlocked) + + WebSocket? _pushWs; + readonly SemaphoreSlim _sendLock = new(1, 1); + + public bool HasClient => Interlocked.CompareExchange(ref _hasClient, 0, 0) == 1; + + public event Action? ClientChanged; + + static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + public Server(CoyoteDevice device, State state, int port) + { + _device = device; + _state = state; + _url = $"http://127.0.0.1:{port}/"; + _listener = new HttpListener(); + _listener.Prefixes.Add(_url); + _device.ConnectionChanged += OnDeviceConnectionChanged; + } + + public void Stop() + { + try { _listener.Stop(); } catch { } + } + + public async Task RunAsync(CancellationToken ct) + { + _listener.Start(); + Console.WriteLine($"[WS] listening on {_url}"); + + using var reg = ct.Register(() => + { + try { _listener.Stop(); } catch { } + }); + + while (!ct.IsCancellationRequested) + { + HttpListenerContext ctx; + try + { + ctx = await _listener.GetContextAsync(); + } + catch (HttpListenerException) { break; } + catch (ObjectDisposedException) { break; } + + if (!ctx.Request.IsWebSocketRequest) + { + ctx.Response.StatusCode = 400; + ctx.Response.Close(); + continue; + } + + if (HasClient) + { + // Reject: only one client at a time. + try + { + var rej = await ctx.AcceptWebSocketAsync(null); + var msg = Encoding.UTF8.GetBytes("{\"ok\":false,\"error\":\"another client is already connected\"}"); + await rej.WebSocket.SendAsync(msg, WebSocketMessageType.Text, true, CancellationToken.None); + await rej.WebSocket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "busy", CancellationToken.None); + } + catch { } + continue; + } + + _ = HandleClientAsync(ctx, ct); + } + } + + async Task HandleClientAsync(HttpListenerContext ctx, CancellationToken ct) + { + WebSocket ws; + try + { + var wsCtx = await ctx.AcceptWebSocketAsync(null); + ws = wsCtx.WebSocket; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[WS] accept failed: {ex.Message}"); + return; + } + + Interlocked.Exchange(ref _hasClient, 1); + _pushWs = ws; + ClientChanged?.Invoke(true); + Console.WriteLine($"[WS] client connected ({ctx.Request.RemoteEndPoint})"); + + // Push current status immediately so the client knows the BLE state + await SendAsync(ws, BuildStatusPush(_device.IsConnected ? "hello" : null)); + + var buf = new byte[8192]; + try + { + while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested) + { + WebSocketReceiveResult result; + var sb = new StringBuilder(); + + do + { + result = await ws.ReceiveAsync(buf, ct); + if (result.MessageType == WebSocketMessageType.Close) + goto done; + + sb.Append(Encoding.UTF8.GetString(buf, 0, result.Count)); + } + while (!result.EndOfMessage); + + var response = HandleCommand(sb.ToString()); + await SendAsync(ws, response); + } + done:; + } + catch (OperationCanceledException) { } + catch (WebSocketException ex) + { + Console.Error.WriteLine($"[WS] socket error: {ex.Message}"); + } + finally + { + _pushWs = null; + if (ws.State == WebSocketState.Open) + try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); } catch { } + ws.Dispose(); + Interlocked.Exchange(ref _hasClient, 0); + ClientChanged?.Invoke(false); + Console.WriteLine("[WS] client disconnected"); + } + } + + async Task SendAsync(WebSocket ws, string text) + { + await _sendLock.WaitAsync(); + try + { + if (ws.State == WebSocketState.Open) + { + var bytes = Encoding.UTF8.GetBytes(text); + await ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None); + } + } + finally { _sendLock.Release(); } + } + + void OnDeviceConnectionChanged(bool connected) + { + var ws = _pushWs; + if (ws == null) return; + var msg = BuildStatusPush(connected ? "connected" : "disconnected"); + _ = SendAsync(ws, msg); + } + + string BuildStatusPush(string? eventName) + { + return JsonSerializer.Serialize(new StatusResponse + { + Event = eventName, + Connected = _device.IsConnected, + StrengthA = _device.StrengthA, + StrengthB = _device.StrengthB + }, JsonOpts); + } + + string HandleCommand(string json) + { + Command? cmd; + try + { + cmd = JsonSerializer.Deserialize(json, JsonOpts); + } + catch (Exception ex) + { + return Err($"invalid JSON: {ex.Message}"); + } + + if (cmd == null || string.IsNullOrWhiteSpace(cmd.Op)) + return Err("missing 'op' field"); + + try + { + return cmd.Op switch + { + "connect" => DoConnect(), + "status" => DoStatus(), + "ping" => Ok("pong"), + _ when !_device.IsConnected => Err("device not connected"), + "strength" => DoStrength(cmd), + "wave" => DoWave(cmd), + "stream" => DoStream(cmd), + "stop" => DoStop(cmd), + "config" => DoConfig(cmd), + "disconnect" => DoDisconnect(), + _ => Err($"unknown op: '{cmd.Op}'") + }; + } + catch (Exception ex) + { + return Err(ex.Message); + } + } + + string DoConnect() + { + if (_device.IsConnected) + return Ok("already connected"); + + Task.Run(async () => + { + try + { + await _device.ConnectAsync(); + Console.WriteLine($"[BLE] connected to {_device.DeviceName}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[BLE] connect failed: {ex.Message}"); + } + }); + + return Ok("connecting..."); + } + + string DoStatus() + { + return JsonSerializer.Serialize(new StatusResponse + { + Connected = _device.IsConnected, + StrengthA = _device.StrengthA, + StrengthB = _device.StrengthB + }, JsonOpts); + } + + string DoStrength(Command cmd) + { + if (cmd.Channel is not ("A" or "B")) + return Err("channel must be 'A' or 'B'"); + if (!cmd.Value.HasValue) + return Err("value required (0-200)"); + + var ch = cmd.Channel[0]; + _state.SetStrength(ch, cmd.Value.Value); + return Ok($"strength {ch} -> {cmd.Value.Value}"); + } + + string DoWave(Command cmd) + { + if (cmd.Channel is not ("A" or "B")) + return Err("channel must be 'A' or 'B'"); + if (cmd.Freq == null || cmd.Intensity == null) + return Err("freq[] and intensity[] required (4 values each)"); + + var freq = Freq.Compress4(cmd.Freq); + var intensity = Intensity.Clamp4(cmd.Intensity); + var ch = cmd.Channel[0]; + + _state.SetLoop(ch, freq, intensity); + return Ok($"wave {ch} looped: freq=[{string.Join(',', freq)}] int=[{string.Join(',', intensity)}]"); + } + + string DoStream(Command cmd) + { + if (cmd.Channel is not ("A" or "B")) + return Err("channel must be 'A' or 'B'"); + if (cmd.Frames == null || cmd.Frames.Length == 0) + return Err("frames[] required"); + + var ch = cmd.Channel[0]; + var frames = cmd.Frames.Select(f => + { + if (f.Freq == null || f.Intensity == null || f.Freq.Length != 4 || f.Intensity.Length != 4) + throw new ArgumentException("each frame needs freq[4] and intensity[4]"); + return new WaveFrame(Freq.Compress4(f.Freq), Intensity.Clamp4(f.Intensity)); + }).ToList(); + + _state.EnqueueStream(ch, frames); + return Ok($"streamed {frames.Count} frames to {ch}"); + } + + string DoStop(Command cmd) + { + if (cmd.Channel is not ("A" or "B")) + return Err("channel must be 'A' or 'B'"); + + var ch = cmd.Channel[0]; + _state.Stop(ch); + return Ok($"stopped {ch}"); + } + + string DoConfig(Command cmd) + { + var capA = (byte)Math.Clamp(cmd.SoftCapA ?? 200, 0, 200); + var capB = (byte)Math.Clamp(cmd.SoftCapB ?? 200, 0, 200); + var freqBalA = (byte)Math.Clamp(cmd.FreqBalA ?? 128, 0, 255); + var freqBalB = (byte)Math.Clamp(cmd.FreqBalB ?? 128, 0, 255); + var intBalA = (byte)Math.Clamp(cmd.IntBalA ?? 128, 0, 255); + var intBalB = (byte)Math.Clamp(cmd.IntBalB ?? 128, 0, 255); + + Task.Run(() => _device.SendBF(capA, capB, freqBalA, freqBalB, intBalA, intBalB)); + return Ok($"config sent: caps={capA}/{capB} freqBal={freqBalA}/{freqBalB} intBal={intBalA}/{intBalB}"); + } + + string DoDisconnect() + { + _state.Stop('A'); + _state.Stop('B'); + return Ok("waveforms stopped (BLE stays connected; restart app to fully disconnect)"); + } + + static string Ok(string? msg = null) => + JsonSerializer.Serialize(new OkResponse { Msg = msg }, JsonOpts); + + static string Err(string error) => + JsonSerializer.Serialize(new ErrResponse { Error = error }, JsonOpts); +} diff --git a/State.cs b/State.cs new file mode 100644 index 0000000..98a1a13 --- /dev/null +++ b/State.cs @@ -0,0 +1,124 @@ +using System.Collections.Concurrent; + +namespace Substation; + +public class State +{ + public readonly object Lock = new(); + + public bool DirtyA, DirtyB; + public int DesiredA, DesiredB; + + public byte[]? LoopFreqA, LoopIntA; + public byte[]? LoopFreqB, LoopIntB; + + public readonly ConcurrentQueue StreamA = new(); + public readonly ConcurrentQueue StreamB = new(); + + public int ActualA, ActualB; + + public int MaxStrength = 200; + public LimitMode LimitMode = LimitMode.Clamp; + + public void SetLimit(int max, LimitMode mode) + { + lock (Lock) + { + MaxStrength = Math.Clamp(max, 0, 200); + LimitMode = mode; + DirtyA = true; + DirtyB = true; + } + } + + public bool IsSignaling + { + get + { + lock (Lock) + { + bool hasPattern = LoopFreqA != null || LoopFreqB != null + || !StreamA.IsEmpty || !StreamB.IsEmpty; + bool hasStrength = DesiredA > 0 || DesiredB > 0; + return hasPattern && hasStrength; + } + } + } + + public void SetStrength(char ch, int value) + { + lock (Lock) + { + var clamped = Math.Clamp(value, 0, 200); + if (ch == 'A') { DesiredA = clamped; DirtyA = true; } + else { DesiredB = clamped; DirtyB = true; } + } + } + + public void SetLoop(char ch, byte[] freq, byte[] intensity) + { + lock (Lock) + { + if (ch == 'A') { LoopFreqA = freq; LoopIntA = intensity; } + else { LoopFreqB = freq; LoopIntB = intensity; } + } + } + + public void EnqueueStream(char ch, IEnumerable frames) + { + var queue = ch == 'A' ? StreamA : StreamB; + foreach (var f in frames) + queue.Enqueue(f); + } + + public void Stop(char ch) + { + lock (Lock) + { + if (ch == 'A') { LoopFreqA = null; LoopIntA = null; } + else { LoopFreqB = null; LoopIntB = null; } + } + var queue = ch == 'A' ? StreamA : StreamB; + while (queue.TryDequeue(out _)) { } + } + + public (StrengthMode modeA, byte valA, StrengthMode modeB, byte valB, + byte[] freqA, byte[] intA, byte[] freqB, byte[] intB) + ConsumeTick() + { + lock (Lock) + { + var modeA = DirtyA ? StrengthMode.Abs : StrengthMode.None; + var modeB = DirtyB ? StrengthMode.Abs : StrengthMode.None; + + var limit = MaxStrength; + var limMode = LimitMode; + byte ApplyLimit(int desired) => + limMode == LimitMode.Scale + ? (byte)(desired * limit / 200) + : (byte)Math.Clamp(desired, 0, limit); + var valA = ApplyLimit(DesiredA); + var valB = ApplyLimit(DesiredB); + DirtyA = false; + DirtyB = false; + + var (freqA, intA) = PopWave(StreamA, LoopFreqA, LoopIntA); + var (freqB, intB) = PopWave(StreamB, LoopFreqB, LoopIntB); + + return (modeA, valA, modeB, valB, freqA, intA, freqB, intB); + } + } + + static (byte[] freq, byte[] intensity) PopWave( + ConcurrentQueue stream, + byte[]? loopFreq, byte[]? loopInt) + { + if (stream.TryDequeue(out var frame)) + return (frame.Freq, frame.Intensity); + + if (loopFreq != null && loopInt != null) + return (loopFreq, loopInt); + + return (B0.ChannelOff, B0.IntensityOff); + } +} diff --git a/Substation.csproj b/Substation.csproj new file mode 100644 index 0000000..fa422a9 --- /dev/null +++ b/Substation.csproj @@ -0,0 +1,21 @@ + + + + WinExe + net8.0-windows10.0.19041.0 + 10.0.17763.0 + enable + enable + true + latest + true + Substation + Substation + + + + + + + +