mute 933c7ede38 Move Swap to connect row, show limiter values, fix trackbar keys
- Swap checkbox moved up to the Connect/device line
- Limiter values shown as separate labels (plain, 32px wide for 3 digits)
- Trackbar keyboard: Up/PageUp/Home = increase, Down/PageDown/End = decrease
- Fix Lim B trackbar clipping (moved down to avoid overlap with Lim A)
2026-08-08 11:58:37 +00:00
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

Build

cd Substation
dotnet build -c Release
dotnet run -c Release

Or publish a self-contained exe:

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

{"op":"connect"}

Scans for the Coyote and connects. Auto-attempted on startup; retry with this if it fails.

status

{"op":"status"}

Returns:

{"ok":true,"connected":true,"strengthA":15,"strengthB":0}

strength

{"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

{"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

{"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

{"op":"stop","channel":"A"}

Clears the loop pattern and stream queue for the channel. Channel goes silent.

config

{"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

{"op":"disconnect"}

Stops all waveforms on both channels. BLE connection stays alive.

ping

{"op":"ping"}

Returns {"ok":true,"msg":"pong"}. Use for keepalive.

Example userscript

// ==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


Entirely vibecoded with GLM 5.2.

S
Description
DGLab Coyote 3.0 WS adapter desktop app
Readme CC0-1.0 202 KiB
Languages
C# 100%