Implement Substation: BLE-WS bridge with music-reactive e-stim

- Renamed from CoyoteBridge to Substation (namespace, classes, UI)
- BLE connection via WinRT with graceful disconnect handling
- WebSocket server (127.0.0.1:8765) with single-client, push events
  for BLE connect/disconnect transitions
- Operator strength limiter (clamp/scale modes, default 30)
- Tray icon with 3 states: neutral/active/hot (runtime-drawn voltage glyph)
- A/B strength gauges, hide-on-minimise to tray
- Music mode: MP3 decode + FFT analysis (NAudio + FftSharp), rhythm→ch A,
  melody→ch B, pre-analyzed with synced audio playback
- Test/Stop All work without BLE connected (dev mode)
- WS device-driving commands error when BLE not connected; ping/status/
  connect always work
- TreatWarningsAsErrors, LangVersion=latest
- .gitignore, README with full API docs + attribution
This commit is contained in:
2026-08-08 10:46:25 +00:00
parent 2bbdcfd9fe
commit 44421a51e3
11 changed files with 2004 additions and 1 deletions
+33
View File
@@ -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
+135
View File
@@ -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<int, int>? StrengthChanged;
public event Action<bool>? 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();
}
}
+571
View File
@@ -0,0 +1,571 @@
using System.ComponentModel;
using System.Drawing.Drawing2D;
using System.Diagnostics;
using NAudio.Wave;
namespace Substation;
public class MainForm : Form
{
readonly CoyoteDevice _device;
readonly State _state;
readonly Server _server;
readonly NotifyIcon _tray;
readonly Label _lblBle;
readonly Label _lblWs;
readonly Label _lblStrength;
readonly Label _lblDeviceName;
readonly TextBox _txtDeviceName;
readonly Button _btnConnect;
readonly Button _btnTest;
readonly Button _btnStop;
readonly System.Windows.Forms.Timer _statusTimer;
readonly CancellationTokenSource _loopCts;
bool _closingFromTray;
readonly ProgressBar _gaugeA;
readonly ProgressBar _gaugeB;
readonly Label _lblGaugeA;
readonly Label _lblGaugeB;
readonly TrackBar _limitBar;
readonly Label _lblLimit;
readonly CheckBox _chkScale;
readonly Icon _iconNeutral;
readonly Icon _iconActive;
readonly Icon _iconHot;
string _trayState = "";
readonly Button _btnMusic;
readonly Label _lblTrack;
bool _isMusicRunning;
WaveOutEvent? _audioOut;
Stopwatch? _musicStopwatch;
public MainForm(CoyoteDevice device, State state, Server server)
{
_device = device;
_state = state;
_server = server;
_loopCts = new CancellationTokenSource();
Text = "Substation";
ClientSize = new Size(360, 340);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
ShowInTaskbar = true;
_lblBle = new Label
{
Text = "BLE: searching...",
Location = new Point(16, 16),
Size = new Size(328, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_lblWs = new Label
{
Text = "WS: idle",
Location = new Point(16, 40),
Size = new Size(328, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_lblStrength = new Label
{
Text = "Strength: A=0 B=0",
Location = new Point(16, 64),
Size = new Size(328, 20)
};
_lblDeviceName = new Label
{
Text = "Device:",
Location = new Point(16, 90),
Size = new Size(48, 20)
};
_txtDeviceName = new TextBox
{
Text = "47L121000",
Location = new Point(68, 88),
Size = new Size(160, 20)
};
_btnConnect = new Button
{
Text = "Connect",
Location = new Point(236, 88),
Size = new Size(108, 24)
};
_btnConnect.Click += OnConnect;
_btnTest = new Button
{
Text = "Test",
Location = new Point(16, 120),
Size = new Size(100, 32)
};
_btnTest.Click += OnTest;
_btnMusic = new Button
{
Text = "Music",
Location = new Point(130, 120),
Size = new Size(100, 32)
};
_btnMusic.Click += OnMusic;
_btnStop = new Button
{
Text = "Stop All",
Location = new Point(244, 120),
Size = new Size(100, 32)
};
_btnStop.Click += OnStop;
_lblTrack = new Label
{
Text = "",
Location = new Point(16, 158),
Size = new Size(328, 16),
ForeColor = Color.DimGray
};
_lblGaugeA = new Label
{
Text = "A",
Location = new Point(16, 180),
Size = new Size(16, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_gaugeA = new ProgressBar
{
Location = new Point(40, 180),
Size = new Size(304, 20),
Minimum = 0,
Maximum = 200,
Style = ProgressBarStyle.Continuous
};
_lblGaugeB = new Label
{
Text = "B",
Location = new Point(16, 208),
Size = new Size(16, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_gaugeB = new ProgressBar
{
Location = new Point(40, 208),
Size = new Size(304, 20),
Minimum = 0,
Maximum = 200,
Style = ProgressBarStyle.Continuous
};
_lblLimit = new Label
{
Text = "Limit: 30",
Location = new Point(16, 248),
Size = new Size(64, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_limitBar = new TrackBar
{
Location = new Point(80, 244),
Size = new Size(180, 45),
Minimum = 0,
Maximum = 200,
TickFrequency = 50,
Value = 30
};
_limitBar.ValueChanged += OnLimitChanged;
OnLimitChanged(null, EventArgs.Empty);
_chkScale = new CheckBox
{
Text = "Scale",
Location = new Point(268, 248),
Size = new Size(76, 24),
Checked = false
};
_chkScale.CheckedChanged += OnLimitChanged;
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblStrength, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblGaugeA, _gaugeA, _lblGaugeB, _gaugeB, _lblLimit, _limitBar, _chkScale });
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
_iconNeutral = CreateVoltageIcon(Color.Gray);
_iconActive = CreateVoltageIcon(Color.Gold);
_iconHot = CreateVoltageIcon(Color.OrangeRed);
Icon = _iconActive;
_tray = new NotifyIcon
{
Icon = _iconActive,
Visible = true,
Text = "Substation"
};
_tray.ContextMenuStrip = new ContextMenuStrip();
_tray.ContextMenuStrip.Items.Add("Show", null, (_, _) => ShowFromTray());
_tray.ContextMenuStrip.Items.Add("-");
_tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) => ExitFromTray());
_tray.DoubleClick += (_, _) => ShowFromTray();
_statusTimer = new System.Windows.Forms.Timer { Interval = 250 };
_statusTimer.Tick += OnStatusTick;
_statusTimer.Start();
Load += OnLoad;
FormClosing += OnFormClosing;
Resize += OnResize;
_server.ClientChanged += OnWsClientChanged;
}
async void OnLoad(object? sender, EventArgs e)
{
_ = _server.RunAsync(CancellationToken.None);
_ = Task.Run(() => TickLoop(_loopCts.Token));
try
{
await _device.ConnectAsync(_txtDeviceName.Text);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BLE] auto-connect failed: {ex.Message}");
}
}
async Task TickLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
if (_device.IsConnected)
{
var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick();
var frame = B0.Build(0, modeA, modeB, valA, valB, freqA, intA, freqB, intB);
await _device.SendB0(frame);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[tick] error: {ex.Message}");
}
try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; }
}
}
void ShowFromTray()
{
Show();
WindowState = FormWindowState.Normal;
Activate();
}
void ExitFromTray()
{
_closingFromTray = true;
_tray.Visible = false;
Application.Exit();
}
void OnResize(object? sender, EventArgs e)
{
if (WindowState == FormWindowState.Minimized)
Hide();
}
void OnFormClosing(object? sender, FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.UserClosing && !_closingFromTray)
{
e.Cancel = true;
Hide();
return;
}
_tray.Visible = false;
_statusTimer.Stop();
_loopCts.Cancel();
_server.Stop();
}
void OnConnect(object? sender, EventArgs e)
{
if (_device.IsConnected) return;
_btnConnect.Enabled = false;
_lblBle.Text = "BLE: connecting...";
Task.Run(async () =>
{
try
{
await _device.ConnectAsync(_txtDeviceName.Text);
}
catch (Exception ex)
{
BeginInvoke(() => _lblBle.Text = $"BLE: error - {ex.Message}");
}
finally
{
BeginInvoke(() => _btnConnect.Enabled = true);
}
});
}
void OnWsClientChanged(bool connected)
{
if (IsDisposed) return;
BeginInvoke(() =>
{
_lblWs.Text = connected ? "WS: client connected" : "WS: idle";
_btnTest.Enabled = !connected && !IsTestRunning;
_btnMusic.Enabled = !connected && !_isMusicRunning;
UpdateTrayIcon();
});
}
void OnStatusTick(object? sender, EventArgs e)
{
if (IsDisposed) return;
_lblBle.Text = _device.IsConnected
? $"BLE: connected ({_device.DeviceName})"
: "BLE: disconnected";
_lblStrength.Text = $"Strength: A={_device.StrengthA} B={_device.StrengthB}";
_gaugeA.Value = Math.Clamp(_device.StrengthA, 0, 200);
_gaugeB.Value = Math.Clamp(_device.StrengthB, 0, 200);
_btnTest.Enabled = !_server.HasClient && !IsTestRunning;
_btnMusic.Enabled = !_server.HasClient && !_isMusicRunning;
_btnStop.Enabled = true;
if (_isMusicRunning && _musicStopwatch != null)
{
var elapsed = _musicStopwatch.Elapsed;
_lblTrack.Text = $"Playing {elapsed:mm\\:ss} / {_musicTotalTime:mm\\:ss} {Path.GetFileName(_musicFilePath)}";
}
UpdateTrayIcon();
}
bool IsTestRunning;
void OnTest(object? sender, EventArgs e)
{
if (IsTestRunning || _server.HasClient) return;
IsTestRunning = true;
_btnTest.Enabled = false;
Task.Run(() => RunTestAsync());
}
async Task RunTestAsync()
{
// Short feel-good pattern over both channels (~3s), gentle swell, A leads B.
const int testStrength = 25;
_state.SetStrength('A', testStrength);
_state.SetStrength('B', testStrength);
// 30 frames x 100ms = 3 seconds. Deep-ish 7Hz pulse with breathing envelope.
var framesA = new List<WaveFrame>();
var framesB = new List<WaveFrame>();
for (int i = 0; i < 30; i++)
{
double t = (double)i / 30;
// Swell 0 -> 70 -> 0 over the run
double env = Math.Sin(Math.PI * t) * 70;
int intA = (int)Math.Round(env);
int intB = (int)Math.Round(env * 0.7); // B slightly softer
int freqMs = 150; // ~7Hz deep
framesA.Add(new WaveFrame(
Freq.Compress4(new[] { freqMs, freqMs, freqMs, freqMs }),
Intensity.Clamp4(new[] { intA, intA, intA, intA })));
framesB.Add(new WaveFrame(
Freq.Compress4(new[] { freqMs, freqMs, freqMs, freqMs }),
Intensity.Clamp4(new[] { intB, intB, intB, intB })));
}
_state.Stop('A'); _state.Stop('B');
_state.EnqueueStream('A', framesA);
_state.EnqueueStream('B', framesB);
// Wait for the stream to be consumed (~3s) plus margin, then silence.
await Task.Delay(3300);
_state.SetStrength('A', 0);
_state.SetStrength('B', 0);
_state.Stop('A');
_state.Stop('B');
BeginInvoke(() =>
{
IsTestRunning = false;
_btnTest.Enabled = !_server.HasClient;
});
}
void OnStop(object? sender, EventArgs e)
{
StopMusic();
_state.SetStrength('A', 0);
_state.SetStrength('B', 0);
_state.Stop('A');
_state.Stop('B');
}
void OnLimitChanged(object? sender, EventArgs e)
{
var max = _limitBar.Value;
var mode = _chkScale.Checked ? LimitMode.Scale : LimitMode.Clamp;
_state.SetLimit(max, mode);
_lblLimit.Text = $"Limit: {max}";
}
string _musicFilePath = "";
TimeSpan _musicTotalTime;
void OnMusic(object? sender, EventArgs e)
{
if (_isMusicRunning || _server.HasClient) return;
using var dlg = new OpenFileDialog
{
Filter = "MP3 files (*.mp3)|*.mp3|All files (*.*)|*.*",
Title = "Select music to drive e-stim"
};
if (dlg.ShowDialog() != DialogResult.OK) return;
_musicFilePath = dlg.FileName;
_musicTotalTime = TimeSpan.Zero;
_isMusicRunning = true;
_btnMusic.Enabled = false;
_btnTest.Enabled = false;
_lblTrack.Text = $"Analyzing... {Path.GetFileName(_musicFilePath)}";
var filePath = _musicFilePath;
Task.Run(() => RunMusicAsync(filePath));
}
async Task RunMusicAsync(string filePath)
{
MusicPattern? pattern = null;
try
{
var progress = new Progress<int>(pct =>
{
if (!IsDisposed)
_lblTrack.Text = $"Analyzing... {pct}% {Path.GetFileName(filePath)}";
});
pattern = await Task.Run(() => MusicAnalyzer.Analyze(filePath, (IProgress<int>)progress));
}
catch (Exception ex)
{
BeginInvoke(() =>
{
_lblTrack.Text = $"Analysis failed: {ex.Message}";
_isMusicRunning = false;
_btnMusic.Enabled = !_server.HasClient;
_btnTest.Enabled = !_server.HasClient && !IsTestRunning;
});
return;
}
_musicTotalTime = pattern.Duration;
// Set a moderate strength ceiling for music
_state.SetStrength('A', 60);
_state.SetStrength('B', 60);
// Clear any existing patterns and enqueue all music frames
_state.Stop('A');
_state.Stop('B');
_state.EnqueueStream('A', pattern.ChannelA);
_state.EnqueueStream('B', pattern.ChannelB);
// Start audio playback + stopwatch simultaneously
try
{
var reader = new AudioFileReader(filePath);
_audioOut = new WaveOutEvent();
_audioOut.Init(reader);
_audioOut.PlaybackStopped += (_, _) => BeginInvoke(StopMusic);
_musicStopwatch = Stopwatch.StartNew();
_audioOut.Play();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[music] playback failed: {ex.Message}");
_musicStopwatch = Stopwatch.StartNew();
}
BeginInvoke(() => _lblTrack.Text = $"Playing 00:00 / {_musicTotalTime:mm\\:ss} {Path.GetFileName(filePath)}");
}
void StopMusic()
{
if (_audioOut != null)
{
try { _audioOut.Stop(); } catch { }
try { _audioOut.Dispose(); } catch { }
_audioOut = null;
}
_musicStopwatch = null;
if (_isMusicRunning)
{
_isMusicRunning = false;
if (!IsDisposed)
{
_lblTrack.Text = "";
_btnMusic.Enabled = !_server.HasClient;
}
}
}
void UpdateTrayIcon()
{
var newState = _state.IsSignaling ? "hot"
: _server.HasClient ? "active"
: "neutral";
if (newState == _trayState) return;
_trayState = newState;
_tray.Icon = newState switch
{
"hot" => _iconHot,
"active" => _iconActive,
_ => _iconNeutral
};
}
static Icon CreateVoltageIcon(Color boltColor)
{
const int sz = 32;
using var bmp = new Bitmap(sz, sz);
using var g = Graphics.FromImage(bmp);
g.SmoothingMode = SmoothingMode.AntiAlias;
// Dark panel background with rounded corners.
using var bg = new SolidBrush(Color.FromArgb(45, 45, 48));
using var path = new GraphicsPath();
int r = 6, pad = 1;
var rect = new Rectangle(pad, pad, sz - 2 * pad, sz - 2 * pad);
path.AddArc(rect.X, rect.Y, r, r, 180, 90);
path.AddArc(rect.Right - r, rect.Y, r, r, 270, 90);
path.AddArc(rect.Right - r, rect.Bottom - r, r, r, 0, 90);
path.AddArc(rect.X, rect.Bottom - r, r, r, 90, 90);
path.CloseFigure();
g.FillPath(bg, path);
// Generic voltage symbol: a bold lightning bolt in the given colour.
var pts = new Point[]
{
new(21, 4), new(10, 18), new(16, 18),
new(13, 28), new(24, 13), new(17, 13)
};
using var bolt = new SolidBrush(boltColor);
g.FillPolygon(bolt, pts);
return Icon.FromHandle(bmp.GetHicon());
}
}
+220
View File
@@ -0,0 +1,220 @@
using System.Numerics;
using FftSharp;
using NAudio.Wave;
namespace Substation;
public record MusicPattern(List<WaveFrame> ChannelA, List<WaveFrame> 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<int>? 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<WaveFrame>(tickCount);
var channelB = new List<WaveFrame>(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<TickFeature>(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<float>();
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<double> MelodyFreqSamples = new();
}
}
+131
View File
@@ -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: 0200 per channel (amplitude ceiling)
- Waveform frequency byte: 10240 (compressed from 101000ms)
- Waveform intensity byte: 0100 (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 (0100, relative). Wider pulse = stronger feel. The intensity envelope is what makes a pattern pleasurable vs. sharp — slow swells/fades, not square edges.
**Strength** (0200) is the hard amplitude ceiling per channel, set separately. Do expression in the 0100 intensity envelope; keep strength modest.
### Frequency compression (input ms → device byte 10240)
```
10100 → identity
101600 → (input - 100) / 5 + 100
6011000 → (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 (0200) |
| 3 | Channel B strength (0200) |
| 47 | Ch A frequency ×4 (10240) |
| 811 | Ch A intensity ×4 (0100) |
| 1215 | Ch B frequency ×4 |
| 1619 | 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 |
| 12 | Ch A/B strength soft cap (0200, persisted) |
| 34 | Ch A/B frequency balance (0255, 128=neutral; higher = stronger low-freq impact) |
| 56 | Ch A/B intensity balance (0255, 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 1030 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`
+24
View File
@@ -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));
}
}
+182
View File
@@ -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; }
}
+229 -1
View File
@@ -1,3 +1,231 @@
# Substation # Substation
DGLab Coyote 3.0 WS adapter desktop app 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 <https://dotnet.microsoft.com/download/dotnet/8.0>)
- 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 (0200) |
| 3 | Channel B strength value (0200) |
| 47 | Channel A waveform frequency ×4 (10240) |
| 811 | Channel A waveform intensity ×4 (0100) |
| 1215 | Channel B waveform frequency ×4 |
| 1619 | Channel B waveform intensity ×4 |
Frequency input is in **milliseconds** (101000, where 10ms = 100Hz buzzy, 1000ms = 1Hz deep thump). The app compresses this to the device's 10240 byte range:
| Input range | Compression formula |
|--------------|------------------------------|
| 10100 | identity |
| 101600 | `(input - 100) / 5 + 100` |
| 6011000 | `(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, 0200). 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 (101000)
- `intensity`: 4 values 0100 (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 (0255, 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** (1030). 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 (0100) 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).
+334
View File
@@ -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<bool>? 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<Command>(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);
}
+124
View File
@@ -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<WaveFrame> StreamA = new();
public readonly ConcurrentQueue<WaveFrame> 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<WaveFrame> 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<WaveFrame> 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);
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>Substation</AssemblyName>
<RootNamespace>Substation</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NAudio" Version="2.2.1" />
<PackageReference Include="FftSharp" Version="2.1.0" />
</ItemGroup>
</Project>