Bluetooth LE and USB protocol

The Bluetooth service, packet framing, the USB line protocol, every operation, session JSON and the trial commitment.

11 min readSource on GitHub

The web app (webapp/, served from GitHub Pages) talks to the helmet with Web Bluetooth, or over a USB cable with Web Serial. Both carry the same JSON requests and responses (below). Any client that follows this page can control the helmet.

PlatformBluetoothUSB
macOS, Windows, Linux, ChromeOSChrome or EdgeChrome or Edge
AndroidChrome or Edge–
iPhone / iPadBluefy (Safari has no Web Bluetooth)–

GATT

UUIDNamePropertiesDirection
a3c87500-8ed3-4bdf-8a39-a01bebede295service––
a3c87501-…requestwrite, write without responseapp → helmet
a3c87502-…responsenotifyhelmet → app
a3c87503-…statusnotifyhelmet → app: once right after the app subscribes, then every 0.5 s while running, every 2 s idle
  • The helmet advertises the service UUID; the name is GodHelmet-XXXX, changeable in Helmet → Settings.
  • Up to two apps can be connected at once, e.g. the receiver and a telepathy sender.
  • An app gets notifications only for the characteristics it subscribed to. A response goes only to the app that sent the request (firmware before 0.4.0 notified every connected app).
  • After a disconnect the helmet advertises again at once, and checks every 2 s that it still does.

Framing

A notification or write carries at most MTU − 3 bytes, so every JSON message is split into packets:

[seq: u8][flags: u8][payload bytes …]     flags: bit0 FIRST, bit1 LAST
  • All packets of one message share seq, and a FIRST packet discards any unfinished message.
  • The app writes packets of at most 180 bytes. That fits the 185-byte MTU of iOS/macOS; Android negotiates up to 517.
  • The helmet sizes its notifications to each app's own MTU, and never sends more than 512 bytes in one (the limit for an attribute value).
  • A request the helmet cannot take is answered with an error instead of silence (below).

Implementations:

  • firmware/lib/bleframe (C++, host-tested);
  • webapp/src/ble/frame.js (JS, tested against the same cases).

USB

One JSON message per line at 115200 baud (the chips' own USB ignores the baud rate). Implementations: firmware/src/usblink.cpp, webapp/src/ble/serial.js (tested against a fake board).

→ {"c":1234567,"id":7,"op":"hello"}\n
← @{"c":1234567,"id":7,"ok":true,"data":{...}}\n
  • Replies start with @. Every other line from the helmet is log text (the boot log, errors), so a terminal program shows both.
  • Requests are lines that start with {, up to 96 KB, like over Bluetooth. Other lines are ignored. A longer line, or one the board has no memory for, is answered with an error.
  • No status pushes: the app asks with status, every 0.5 s while running and every 2 s idle. Each valid request counts as the app being present (status LED, keepaliveS); other text on the line does not.
  • Opening the port: the app turns RTS off, then DTR. Auto-reset circuits, and the chips' own USB, restart the board while RTS is on and DTR is off; this order never produces that. On boards with a USB bridge chip it does pull the BOOT pin low for a few milliseconds. BOOT is a STOP button, so the firmware takes it as STOP only when it is held for 40 ms: closing and reopening the port leaves a running session alone (firmware before 0.4.0 stopped it).
  • Restarts: the first line after a restart is God helmet controller <version> (<board>, <chip>), the second Helmet <helmetId>, start <bootId> (<resetReason>). Seeing them means the board restarted, and any session on it has ended (When the connection drops).
  • Two USB sockets: on the S3, C3 and C6 both work, the chip's own USB and the bridge chip's UART.

Requests and responses

→ {"c": 1234567, "id": 7, "op": "session.start", "session": {...}, "fieldUT": 1.0, "blind": true}
← {"c": 1234567, "id": 7, "ok": true, "data": {"logId": 12}}
← {"c": 1234567, "id": 8, "ok": false, "error": "a session is already running"}
  • c is a random tag per app. The helmet answers only the app that asked; firmware before 0.4.0 notified every connected app, so an app still keeps only responses with its own tag.
  • id matches a response to its request. The protocol has a single id member: for the operations that take an id parameter (log.rate, trial.lock and the other trial.* ones), that entry's id is the request's id, and the response carries it. The app numbers its other requests from 1 000 000 000, so the two never meet, and matches such a response to its oldest waiting request with that id.
  • The helmet handles requests one at a time, in the order they arrive.
  • Write c and id as the first two members. The helmet reads them from the start of a request, so it can still say whose request it was when it has to drop one.
  • Requests the helmet drops get an error response at once:
    errorWhen
    busy: request droppedSix requests were already waiting. Sending it again is safe
    request too large (96 KB at most)
    out of memory (program too large for this board)The board has no room for a request this long, or for running it
    request damaged: start missingA packet arrived without its FIRST packet. c and id are then 0: the app fails its oldest waiting request. Sending it again is safe

Operations

opParametersReturns
hello–firmware, protocol, board, chip (e.g. ESP32-C3), helmetId, bootId, uptimeS, resetReason, recoveredLogId (these five: below), transports (["ble","usb"], or ["usb"] with Bluetooth off), calibrated, safetyAccepted, maxReachableUT, maxFieldUT, defaultFieldUT, lastSessionEpoch, minIntervalS, freeHeap, fsUsed, fsTotal (measured while no session runs), links (ble: connected apps, bleStatus: those subscribed to status, usb: ports with a request in the last 10 s), env
status–the live status (the same JSON as the Bluetooth status notification)
time.setepochMsenv (the app sends its clock on every connect)
env.setkp (0–9), ap, sourceenv
settings.get–settings (below)
settings.setany subset of the settingssettings; refused while a session runs
session.startsession (preset JSON), presetId, fieldUT, blind, token (optional, below)logId; needs safetyAccepted. Refused when the log entry cannot be written
session.stop–fades out over 2 s
session.skip–next phase (after the last phase: stop)
session.fieldfieldUTlive intensity change, clamped to maxFieldUT
testmode dc/rotate/led (one output flashing +, pause, −, pause, for two LEDs in place of a coil)/leds (the same on AO1 to AO8 in turn, 2 s each), channel, mA (≤ 4.6), seconds (≤ 120), layout, dir, dwellMs, blinkHz (led/leds: flashes per second of each LED, 0.1–20, default 1; 0 = 0.5, 1, 2, 5, 10 Hz in turn, seconds/5 each, at least one round of AO1 to AO8 for leds), token (optional)logId: 0 (not logged)
log.listoffset, limit (≤ 10)total, items (newest first; unrated blind sessions show condition: "hidden"). An entry: id, start, presetId, presetName, blind, condition, test, fieldUT, plannedMs, elapsedMs, finished, completed, reason, kpValid, kp, ap, kpSource, lst, rating, notes, trialId, token, and once it has ended finalFieldUT, fieldChanges, skips
log.rateid, rating 0–10, notesthe entry with its condition revealed; refused while that session is still running, and for a trial's session until the trial is revealed or aborted
trial.newmode rv/telepathy, manifestHash, sets [{id, size}], optional session + presetId + blind + fieldUT, token (optional)public trial. The trial and its log entry are on file before the session starts
trial.lockid, responseHashpublic trial (records time, Kp/ap, LST)
trial.judgeidpublic trial with set, setSize, shuffled order
trial.ranksid, ranks (rank per display position, a permutation of 1..N)public trial
trial.revealidfull trial: condition, target, nonce, preimage, targetRank
trial.abortid– (the trial stays listed as aborted)
trial.senderid{set, target, manifestHash}, only for open telepathy trials
trial.get / trial.listid / offset, limitpublic trial(s)
rng.bytesn (1–256){hex, source}: hardware random bytes (esp_fill_random), for micro-PK tasks. A true RNG with Bluetooth on; with Bluetooth off the chip's noise source is switched on for the draw. Trials draw their target and condition the same way

Settings:

KeyNotes
uTperMA[8]Per-coil calibration
calibrated
seriesOhm470–100000
coilOhm
vFullScale
dacGain2x
dacBufferedRef
maxFieldUT≤ 10
defaultFieldUT
longitudeDegUsed for local sidereal time
keepaliveSStop the session when no app has been present for this many seconds. 0 = off (the default: a session runs on without the app), otherwise 5–3600; other values are brought into that range. Present means, over Bluetooth, an app subscribed to status or sending a request; over USB, a valid request
bleNameApplies after a restart
lastSessionEpochRead-only
safetyAccepted
maxCurrentMARead-only

Status notification: state (idle/running/stopping), armed, blind, test, logId, sessionId, sessionName, phaseName, phase, phaseCount, elapsedMs, programMs, totalMs, phaseMs, phaseDurationMs, envelope, fieldUT, testMA, overruns, lastStopReason, bootId, token, presetId, startEpoch, trialId, levels[8].

  • The levels field is omitted in blind sessions, since it would reveal a sham.
  • token, presetId, startEpoch, trialId and logId describe the session that runs, and stay after it has ended until the next start.
  • overruns counts output periods (0.5 ms each) that came late or were lost, measured against the clock: a stall of 5 ms adds 10. The session's own time does not fall behind.
  • lastStopReason: completed, stopped from app, skipped past last phase, UI keepalive lost, STOP button, STOP button (BOOT), STOP on screen.

When the connection drops

The helmet runs a session by itself. A dropped Bluetooth link, a pulled USB cable or a closed browser tab changes nothing on the helmet: the session runs to its end (unless keepaliveS is set). What an app needs to find it again:

FieldWhereMeaning
helmetIdhello12 hex digits, fixed for the board (its last four are the XXXX of the default name)
bootIdhello, statusA number that goes up by one at every start of the helmet
uptimeShelloSeconds since that start
resetReasonhelloWhy it started: power-on (also the reset button, which the chip cannot tell apart), software restart, panic, task watchdog, interrupt watchdog, watchdog, brownout, external reset, wake from sleep, usb, jtag, sdio, unknown
recoveredLogIdhelloThe log entry this start had to close because the helmet went down during that session; 0 = none
tokenstatus, log entryThe token of the start request (below)

After reconnecting, an app asks hello and status and compares:

  • Same helmetId, same bootId, status.token is its own token: it is its session. state says whether it still runs; if not, lastStopReason and the log entry say how it ended.
  • Same helmetId, another bootId: the helmet restarted, and the session ended with it. The log entry recoveredLogId has the details.
  • Another helmetId: a different helmet.

A restart in mid-session. While a session runs, the helmet notes four times a second how far it has got, in memory that a crash, a watchdog or a software restart does not erase (no flash is written for it). At the next start it closes the session's log entry with the time it really ran and the reason restart: <resetReason>, for example restart: task watchdog. A power cut erases that memory, and so does the reset button on most boards; the entry is then closed as power lost with elapsedMs 0. Either way the entry is finished, not completed, and can be rated as usual.

Starting twice is starting once

session.start, test and trial.new take an optional token: 1 to 24 characters from A-Z a-z 0-9 _ -, a new random one for every start. When the reply to a start gets lost, the app does not know whether the session runs. It sends the same request again with the same token:

→ {"c": 1234567, "id": 1000000009, "op": "session.start", "session": {...}, "token": "k3Vx81QmZt0aPw2L"}
← {"c": 1234567, "id": 1000000009, "ok": true, "data": {"logId": 12, "already": true}}

already: true means nothing was started now: this is the session the first request made, whether it still runs or has ended. trial.new answers with the trial it made the first time, plus already: true. The helmet remembers the tokens of its last four starts, until it restarts. A start with another token, or with none, is refused as before while something runs.

Session JSON

Same format as the files in presets/. Parsed by firmware/lib/engine/src/preset_json.cpp and webapp/src/engine/session.js:

{
  "id": "my-preset", "name": "My preset", "layout": "temporal", "uT": 1.0,
  "fadeInS": 10, "fadeOutS": 10,
  "phases": [
    {"name": "Phase 1", "minutes": 20, "level": 1.0, "crossfade": false,
     "pattern": {"type": "burst", "f0": 6, "f1": 30, "sweepMs": 1500, "gapMs": 4000, "pointMs": 3},
     "spatial": {"mode": "rotate", "dir": "ccw", "dwell": [10, 2, 20], "weights": [1,1,1,1,1,1,1,1]}}
  ],
  "tableData": [128, 200, 255]
}
FieldValues
Pattern typeoff, dc, sine, chirp, burst, thomas, pulse, semirandom, table (needs tableData, values 0–255, at most 8192)
Spatial modebilateral, left, right, rotate, cycle (one side), alternate (alternateMs), single (channel)
Dwell"dwellMs": 20 (fixed) or "dwell": [start, step, end] (ramp, repeats)
Durationminutes or seconds per phase; the total must be ≤ 60 min

Trial commitment

preimage   = "godhelmet-trial|id=<id>|manifest=<manifestHash>|set=<set>|target=<k>|condition=<c>|nonce=<32 hex>"
commitment = SHA-256(preimage)                          published by trial.new
manifest   = {"format":"godhelmet-pool-manifest","version":1,"sets":[{"id":…,"images":[<sha256 of each picture>…]}…]}
responseHash = SHA-256(JSON.stringify({trialId, text, sketch}))   sent by trial.lock

After the reveal, the app checks three things:

  • the commitment matches the preimage;
  • the local pool still hashes to manifestHash;
  • the stored response still hashes to responseHash.