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
- **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).
| 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.
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.
// 7Hz deep pulse (150ms), intensity swells 0->80->0 over ~8 ticks
constbreathe=[
{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]},
];
letbreatheIdx=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