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 + + + + + + + +