The manual OnLimitChanged(null, EventArgs.Empty) call in the constructor was placed before _chkScale was created, causing a NullReferenceException because the handler reads _chkScale.Checked. Moved the call to after both _limitBar and _chkScale are created and wired.
Substation
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
cd Substation
dotnet build -c Release
dotnet run -c Release
Or publish a self-contained exe:
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:
- Tick loop — every 100ms, builds a
B0frame (20 bytes) from the current shared state and writes it to BLE characteristic0x150A. This is the Coyote's output window: 4 frequency + 4 intensity values per channel, each value representing 25ms of output. - 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
{"op":"connect"}
Scans for the Coyote and connects. Auto-attempted on startup; retry with this if it fails.
status
{"op":"status"}
Returns:
{"ok":true,"connected":true,"strengthA":15,"strengthB":0}
strength
{"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
{"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
{"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
{"op":"stop","channel":"A"}
Clears the loop pattern and stream queue for the channel. Channel goes silent.
config
{"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
{"op":"disconnect"}
Stops all waveforms on both channels. BLE connection stays alive.
ping
{"op":"ping"}
Returns {"ok":true,"msg":"pong"}. Use for keepalive.
Example userscript
// ==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
configto limit the ceiling before experimenting. - The
stopcommand +strength 0is 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, enhanceCoyoteDeviceto useseq>0and wait forB1responses.
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
- Waveform explanation
- PyDGLab-WS — Python equivalent (app-socket path)
- OpenDGLab WaveGen — visual waveform editor
Entirely vibecoded with GLM 5.2.