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
This commit is contained in:
@@ -1,3 +1,231 @@
|
||||
# Substation
|
||||
|
||||
DGLab Coyote 3.0 WS adapter desktop app
|
||||
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 (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).
|
||||
|
||||
Reference in New Issue
Block a user