# StreamHub WebSocket API StreamHub exposes a single WebSocket server (default port **8090**, any URL path). Text frames carry JSON commands/events; binary frames carry data pushes and trigger captures. The Go reference hub (`Client/udpstreamer`) implements the identical protocol; both the browser SPA and the ImGui client work against either. For the upstream UDPS wire format (source → hub) see [Protocol.md](Protocol.md). All numbers in binary frames are **little-endian**. All timestamps are **Unix wall-clock seconds** (float64) — see *Time base* in [StreamHub-Developer.md](StreamHub-Developer.md). --- ## 1. Commands (client → hub, JSON text frames) Every command is a JSON object with a `type` field. ### `ping` ```json {"type":"ping"} ``` Reply (unicast): `{"type":"pong"}`. ### `addSource` ```json {"type":"addSource","label":"PSU","addr":"192.168.0.10:44500", "multicastGroup":"239.0.0.1","dataPort":44503} ``` - `addr` — `host:port` of the UDPStreamer control port. - `multicastGroup` / `dataPort` — optional; if present the hub joins the multicast group for DATA and keeps a TCP control connection for CONNECT/CONFIG. - The hub assigns ids `s1`, `s2`, … and broadcasts an updated `sources` event. ### `removeSource` ```json {"type":"removeSource","id":"s1"} ``` ### `saveSources` ```json {"type":"saveSources"} ``` Writes the hub's `SourcesFile`: the current dynamically-added source list **and** the calibration table, as one flat JSON array (see [§4](#4-config-file-format)). The hub replies with [`configSaved`](#configsaved). Despite the name, this command persists the whole config, not just the sources. ### `setCalibration` ```json {"type":"setCalibration","source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"} ``` Records an affine calibration `value = raw × scale + offset` for one signal, keyed by the source's **label** (not its runtime id) and the **base** signal name — one entry covers every element of an array signal. | Field | Type | Default | Validation | |---|---|---|---| | `source` | string | — | non-empty after trimming | | `signal` | string | — | non-empty after trimming; any trailing `[i]` is stripped | | `scale` | number | `1` | finite and non-zero | | `offset` | number | `0` | finite | | `unit` | string | `""` | trimmed, truncated to 16 UTF-8 bytes (a multi-byte character such as `°C` consumes more than one byte; truncation never splits a character); empty = use the streamer's own unit | Calibration is **metadata only**: the hub stores and redistributes it but never applies it. Ring buffers, recorded history, the `zoom` reply, both binary frames and the trigger comparator all stay in raw units — a client that ignores calibration behaves exactly as before. An entry that reduces to the identity (`scale = 1`, `offset = 0`, `unit = ""`) is **deleted** rather than stored, so a reset leaves no residue in the config file. On acceptance the hub broadcasts [`calibration`](#calibration) to every client. A rejected entry produces **no** broadcast, so the offending client reverts to the last value it was told. ### `reloadConfig` ```json {"type":"reloadConfig"} ``` Re-reads `SourcesFile` and then: - **replaces** the calibration table wholesale with the file's contents; - **adds** any source in the file that is not already active; - **never** removes, restarts or reconnects a live source. The asymmetry is deliberate: calibration is cheap to reapply, whereas a source is a live UDP session that must not be interrupted. An unsaved source the user added keeps streaming. The hub replies with [`configReloaded`](#configreloaded), followed on success by a `calibration` broadcast. Whether a `sources` broadcast follows depends on the hub implementation: the Go hub emits `sources` only when the file adds at least one new source (each `sm.Add()` call triggers it individually), while the C++ hub always emits `sources` unconditionally after a successful reload. Clients must therefore tolerate an unsolicited `sources` frame after any reload. ### `getSources` / `getConfig` / `getStats` ```json {"type":"getSources"} {"type":"getConfig","sourceId":"s1"} {"type":"getStats"} ``` Force a broadcast of the corresponding event. ### `setTrigger` ```json {"type":"setTrigger","signal":"s1:Sine","edge":"rising","threshold":0.0, "windowSec":0.1,"prePercent":20,"mode":"normal"} ``` - `signal` — full key `src:sig`, or `src:sig[i]` to trigger on element *i* of a multi-element PACKET signal. - `edge` — `"rising"`, `"falling"` or `"both"`. - `windowSec` — total capture window (clamped to 1e-4 … 10 s). `preSec = windowSec * prePercent / 100`, `postSec = windowSec − preSec`. - `mode` — `"normal"` (auto-rearm ~200 ms after capture) or `"single"` (stays TRIGGERED until `rearm`). ### `arm` / `disarm` / `rearm` / `trigStop` ```json {"type":"arm"} {"type":"disarm"} {"type":"rearm"} {"type":"trigStop","stopped":true} ``` - `arm` — IDLE → ARMED. - `disarm` — any state → IDLE. - `rearm` — TRIGGERED → ARMED (single mode). - `trigStop` — sets the *stopped* flag suppressing auto-rearm in normal mode; omit `stopped` to toggle. Every transition is broadcast as a `triggerState` event. ### `zoom` ```json {"type":"zoom","reqId":17,"t0":1765370000.123,"t1":1765370000.223, "n":2400,"signals":"s1:Sine,s2:Wave[0]"} ``` - `reqId` — echoed in the reply; lets the client match request/response. - `t0`/`t1` — Unix seconds window. - `n` — target points per signal. Absent or `<10` → 2400; `n ≤ 0` → **no decimation** (raw ring contents). - `signals` — comma-separated full keys; absent → all signals of all sources. - The reply is **unicast** to the requesting client only. ### `historyZoom` ```json {"type":"historyZoom","reqId":42,"t0":1765360000.0,"t1":1765370000.0, "n":2400,"signals":"s1:Sine,s2:Wave"} ``` - Reads from **disk-backed history** instead of the in-memory ring buffer. Identical semantics to `zoom` but queries the `.shist` files written by `HistoryWriter`. - `reqId`, `t0`/`t1`, `n`, `signals` — same meaning as `zoom`. - If history is not enabled, the reply contains `"error":"history not enabled"`. - Reply is **unicast** (same shape as `zoom` reply, but `"type":"historyZoom"`). ### `historyInfo` ```json {"type":"historyInfo"} ``` Request the hub to send a `historyInfo` event (unicast). Also sent automatically on client connect. ### `setMaxPoints` ```json {"type":"setMaxPoints","maxPoints":50000} ``` Resizes all ring buffers (applied safely inside the push loop; push cursors are reset). Broadcasts `maxPointsUpdated`. --- ## 2. Events (hub → client, JSON text frames) ### `sources` ```json {"type":"sources","sources":[ {"id":"s1","label":"PSU","addr":"192.168.0.10:44500","state":"streaming"}]} ``` Sent on client connect, after add/remove/getSources, and when a source first delivers its CONFIG. ### `config` ```json {"type":"config","sourceId":"s1","publishMode":0,"signals":[ {"name":"Sine","typeCode":10,"quantType":0,"numDimensions":0, "numRows":1,"numCols":1,"rangeMin":-1.0,"rangeMax":1.0, "timeMode":0,"samplingRate":5000000.0,"timeSignalIdx":-1,"unit":"V"}]} ``` Field semantics follow the UDPS signal descriptor ([Protocol.md](Protocol.md)). `numElements = numRows × numCols`. ### `stats` Sent at `StatsRate` Hz (default 1 Hz): ```json {"type":"stats","sources":{"s1":{ "state":"streaming","totalReceived":1234,"totalLost":0, "rateHz":100.1,"rateStdHz":0.3, "fragsPerCycle":3.0,"bytesPerCycle":4200.0, "cycleAvgMs":10.0,"cycleStdMs":0.1,"cycleMinMs":9.8,"cycleMaxMs":10.4, "cycleHistMin":9.8,"cycleHistMax":10.4,"cycleHist":[0,1,5,"…(20 bins)"]}}} ``` ### `triggerState` ```json {"type":"triggerState","state":"triggered","mode":"normal", "stopped":false,"trigTime":1765370000.1234567} ``` `state` ∈ `idle | armed | collecting | triggered`; `trigTime` present once a trigger has fired. ### `zoom` (reply) ```json {"type":"zoom","reqId":17,"signals":{ "s1:Sine":{"t":[1765370000.1230000,"…"],"v":[0.123456789,"…"]}}} ``` `t` is serialised with `%.17g` (full float64 precision — required for µs windows at Unix-epoch magnitudes), `v` with `%.9g`. ### `historyInfo` Sent on client connect (if history is enabled) and on `historyInfo` command: ```json {"type":"historyInfo","enabled":true,"durationHours":1.0,"decimation":10, "signals":{ "scalar:Sine1":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000}, "scalar:Sine2":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000}}} ``` - `enabled` — `true` if the `+History` config block is present and valid. - `durationHours` — configured history duration. - `decimation` — samples-to-disk decimation factor (1 = every sample). - `signals` — per-signal metadata keyed by `"sourceId:signalName"`: - `t0`/`t1` — oldest/newest timestamp stored on disk (Unix seconds). - `count` — number of valid entries currently in the circular file. - `capacity` — total capacity of the circular file. ### `historyZoom` (reply) Same shape as `zoom` reply, but `"type":"historyZoom"`: ```json {"type":"historyZoom","reqId":42,"signals":{ "s1:Sine":{"t":[1765360000.1230000,"…"],"v":[0.123456789,"…"]}}} ``` If history is not enabled: `{"type":"historyZoom","error":"history not enabled"}`. ### `maxPointsUpdated` ```json {"type":"maxPointsUpdated","maxPoints":50000} ``` ### `calibration` ```json {"type":"calibration","cal":[ {"source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"} ]} ``` The complete calibration table. Broadcast when a client connects (as an empty array when nothing is calibrated), after every accepted `setCalibration`, and after a successful `reloadConfig`. It is a separate frame rather than a field on `sources` because `sources` is serialised into a fixed 4 KiB buffer. ### `configSaved` ```json {"type":"configSaved","ok":true,"path":"/etc/streamhub/sources.json"} {"type":"configSaved","ok":false,"path":"","error":"no sources file configured"} ``` Broadcast in reply to `saveSources`. `path` is always present (empty when the hub has no config file configured); `error` only when `ok` is false. The exact error text is not part of the protocol contract and differs between hubs (the Go hub uses `"no sources-file configured"`, the C++ hub `"no sources file configured"`). ### `configReloaded` ```json {"type":"configReloaded","ok":true,"path":"/etc/streamhub/sources.json"} {"type":"configReloaded","ok":false,"path":"/etc/streamhub/sources.json","error":"cannot read sources file"} ``` Broadcast in reply to `reloadConfig`; same shape as `configSaved`. On success it is followed by a `calibration` broadcast. Whether a `sources` broadcast also follows is hub-specific: the Go hub sends it only if the reload added at least one new source; the C++ hub sends it unconditionally. Clients must tolerate an unsolicited `sources` frame after any reload. --- ## 3. Binary frames (hub → client) The first byte of every binary WS frame is a **version** discriminator. ### Version 1 — data push Sent at `PushRate` Hz per source. Contains **only the samples that are new since the previous push** (per-signal cursors hub-side), LTTB-decimated to at most `MaxPushPoints` (default 50) per signal. ``` [1] version = 1 [1] sourceIdLen (L) [L] sourceId (UTF-8, no NUL) [4] numSignals (uint32) per signal: [2] keyLen (uint16) (K) [K] key = signal name; "name[i]" per element for multi-element PACKET signals [4] pairCount (uint32) (N) [N×8] t (float64, Unix seconds) [N×8] v (float64, physical units) ``` Clients must append samples verbatim — there is no overlap between pushes. ### Version 2 — trigger capture Broadcast once per capture (FSM COLLECTING → TRIGGERED). Contains *all* signals over `[trigTime − preSec, trigTime + postSec]`, each LTTB-decimated to ≤ 20 000 points. ``` [1] version = 2 [8] trigTime (float64, Unix seconds) [8] preSec (float64) [8] postSec (float64) [4] numSignals (uint32) per signal: [2] keyLen (uint16) (K) [K] fullKey = "src:sig" (UTF-8) [4] pairCount (uint32) (N) [N×8] t (float64, Unix seconds) [N×8] v (float64) ``` --- ## 4. Config file format `SourcesFile` (C++ `SourcesFile` config key, Go `-sources-file` flag) is a flat JSON array of flat objects. A block containing `addr` is a source; a block containing `signal` is a calibration entry; anything else is skipped with a warning. ```json [ {"label": "wave", "addr": "127.0.0.1:44500"}, {"label": "mc", "addr": "127.0.0.1:44501", "multicastGroup": "239.0.0.1", "dataPort": 44502}, {"source": "wave", "signal": "Adc", "scale": 0.00030518, "offset": -1.25, "unit": "V"} ] ``` **Every object must stay flat.** The C++ `StreamHub::LoadSourcesFile` parser takes each `{` up to the next `}` as one object, so a nested object anywhere in the file would truncate the parse at the inner brace. A nested `"calibration": {…}` inside a source entry is therefore not an option, and this is why calibration entries are siblings of sources rather than children. Files written by hub versions predating calibration load unchanged, and a file written by either hub loads in the other. --- ## 5. Limits | Limit | Value | |-------|-------| | Concurrent WS clients | 16 | | UDPS source sessions | 32 | | Max received WS payload | 64 KiB | | Max sent WS payload | 4 MiB | | Calibration entries | 256 (C++ hub, `kMaxCalibration`); unbounded (Go hub) | | Calibration unit override | 16 UTF-8 bytes |