Files
mute 44421a51e3 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
2026-08-08 10:46:25 +00:00

232 lines
8.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).