# 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 ) - 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).