Connections
Live data WebSocket
The dashboard's live data stream: CBOR frames, subscribe and resume.
GET /ws/v2/telemetry
The live telemetry stream for your own client: a display, a logger, a bot. Connect, say which roaster and the last sample you hold, and the server sends what you missed (up to the last two hours) and then every new reading. A reconnect loses nothing. The dashboard uses this same stream.
Samples come as binary CBOR frames with short keys; everything else is JSON text. When your
server needs a login for reads (the default), send an API token as
Authorization: Bearer <token> or X-API-Key: <token> on the upgrade request; the
dashboard's own pages use their sign-in cookie.
Frames
Text frames are JSON and carry control messages and low-rate events. Binary frames are CBOR and carry samples.
Server → client
| Frame | Encoding | Shape |
|---|---|---|
| hello | text | {"type":"hello","protocol":2,"server_time_ms":…,"rings":[{device_id, oldest_seq, latest_seq, count, latest_ts_ms}]} |
| subscribed | text | {"type":"subscribed","device_id":…,"from_seq":N,"latest_seq":M,"gap":bool} |
| samples | binary CBOR | {"type":"samples","device_id":…,"samples":[Sample…],"catchup":bool,"gap":bool} |
| replay | text | {"type":"replay","device_id":…,"replay":{…}}, one per replay tick (same body as protocol 1; see Profile drive) |
| alarm | text | {"type":"alarm","device_id":…,"alarm":{…}} |
| pong | text | {"type":"pong","server_time_ms":…} |
| error | text | {"type":"error","message":…} |
Client → server
| Frame | Shape |
|---|---|
| subscribe | {"type":"subscribe","device_id":"my_roaster","since_seq":N} or "since_ms":T, optional "limit":L |
| ping | {"type":"ping"} |
since_seq is the last sequence number the client already holds (0 or omitted
for "send everything you have"). A subscribe without device_id receives live
samples from every device and no catch-up. A new subscribe replaces the
previous one.
Sample
Keys are short because a two-hour catch-up is 7200 of them. All temperatures are °C, rates °C/min.
| Key | Meaning |
|---|---|
q | sequence number, per device, 1-based, monotonic |
t | server receive time, Unix ms |
bt, et | bean / environment temperature |
rb, re | server rate of rise of BT / ET |
rd | the device's own RoR, if it sent one |
h | heater command, % 0–100 |
f | fan, % 0–100 |
sp | setpoint |
m | 0 manual, 1 auto (PID) |
he | heater enable |
x | map of any other numeric fields the device reported (rssi, freeHeap, ambientTemp, Kp…) |
Absent keys mean "not reported".
Flow
- Connect. The server sends
hellolisting every device it holds samples for. - Send
subscribewith the device and your lastq(0 on first load). - The server answers
subscribed, then one or moresamplesframes withcatchup: true(up to 500 samples each, oldest first).gap: trueon the first one means yoursince_seqwas older than the ring: there is a hole you cannot fill from the socket (use the session's stored telemetry). - From then on each live reading arrives as a
samplesframe with one sample andcatchup: false. Samples already sent during catch-up are never repeated, even if they were queued in the broadcast while catch-up ran. - On reconnect, repeat from step 2 with the highest
qyou received.
HTTP equivalents
GET /api/roaster/{device_id}/samples?since_seq=N|since_ms=T&limit=Lreturns the same samples as JSON, withlatest_seqandgap.GET /api/telemetry/ringsreturns the per-device ring summary fromhello.
Example (Node 22, no dependencies beyond cbor-x)
import { decode } from 'cbor-x';
const ws = new WebSocket('ws://localhost:8080/ws/v2/telemetry');
ws.binaryType = 'arraybuffer';
let last = 0;
ws.onopen = () => ws.send(JSON.stringify({ type: 'subscribe', device_id: 'my_roaster', since_seq: last }));
ws.onmessage = (e) => {
if (typeof e.data === 'string') return console.log(JSON.parse(e.data));
const frame = decode(new Uint8Array(e.data));
for (const s of frame.samples) { last = s.q; console.log(s.q, s.bt, s.rb); }
};