Files

232 lines
8.3 KiB
Markdown
Raw Permalink Normal View History

2026-08-08 10:44:35 +00:00
# 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
```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).