diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index febbaba..0ae5661 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -380,7 +380,9 @@ binary frames carry data push payloads. | `ping` | — | Hub replies `{"type":"pong"}` | | `addSource` | `label`, `addr` (`"host:port"`), `multicastGroup?`, `dataPort?` | Connect to a new UDPS source; hub assigns id `s1, s2, …` | | `removeSource` | `id` | Disconnect and remove a source | -| `saveSources` | — | Persist the current dynamic source list to `SourcesFile` (JSON) | +| `saveSources` | — | Persist the dynamic source list **and** the calibration table to `SourcesFile`; replies `configSaved` | +| `setCalibration` | `source` (label), `signal` (base name), `scale`, `offset`, `unit` | Record `value = raw × scale + offset` for one signal; metadata only, the hub never applies it. Identity entries are deleted. Replies with a `calibration` broadcast | +| `reloadConfig` | — | Re-read `SourcesFile`: calibration replaced wholesale, missing sources added, live sources never touched; replies `configReloaded` | | `getSources` | — | Trigger `sources` broadcast | | `getConfig` | `sourceId` | Trigger `config` broadcast for one source | | `getStats` | — | Trigger `stats` broadcast | @@ -402,8 +404,30 @@ binary frames carry data push payloads. | `triggerState` | `state` (`"idle"`\|`"armed"`\|`"collecting"`\|`"triggered"`), `mode`, `stopped`, `trigTime?` | On any trigger FSM transition | | `zoom` | `reqId`, `signals:{"src:sig":{t:[…], v:[…]}}` (`t` printed `%.17g`, `v` `%.9g`) | Unicast reply to `zoom` | | `maxPointsUpdated` | `maxPoints` | After ring buffer resize | +| `calibration` | `cal:[{source, signal, scale, offset, unit}]` | On connect; after an accepted `setCalibration`; after a successful `reloadConfig` | +| `configSaved` | `ok`, `path`, `error?` | In reply to `saveSources` | +| `configReloaded` | `ok`, `path`, `error?` | In reply to `reloadConfig` | | `pong` | — | In reply to `ping` | +### Config File Format + +`SourcesFile` is a flat JSON array of flat objects; `addr` marks a source, +`signal` marks a calibration entry. + +```json +[ + {"label": "wave", "addr": "127.0.0.1:44500"}, + {"source": "wave", "signal": "Adc", "scale": 0.00030518, "offset": -1.25, "unit": "V"} +] +``` + +Flatness is a hard constraint: `StreamHub::LoadSourcesFile` scans from each `{` +to the next `}`, so a nested object would truncate the parse. Both hubs read and +write this format identically, and pre-calibration files load unchanged. + +Calibration is applied **client-side only**. Rings, history, `zoom` replies, both +binary frames and the trigger comparator are all in raw units. + ### Binary Push Frame (version 1, hub → client, binary WS frame) Little-endian throughout. Sent at `PushRate` Hz per source; contains **only diff --git a/Docs/StreamHub-API.md b/Docs/StreamHub-API.md index 6039a5c..228c334 100644 --- a/Docs/StreamHub-API.md +++ b/Docs/StreamHub-API.md @@ -46,8 +46,57 @@ Reply (unicast): `{"type":"pong"}`. ```json {"type":"saveSources"} ``` -Persists the current dynamically-added source list to the hub's `SourcesFile` -(JSON array of `{label,addr,multicastGroup,dataPort}`); it is reloaded at startup. +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 chars; 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 and a `sources` broadcast. ### `getSources` / `getConfig` / `getStats` @@ -219,6 +268,37 @@ If history is not enabled: `{"type":"historyZoom","error":"history not enabled"} {"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 SourcesFile 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. + +### `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 and, if the file added any source, a +`sources` broadcast. + --- ## 3. Binary frames (hub → client) @@ -270,7 +350,33 @@ per signal: --- -## 4. Limits +## 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 | |-------|-------| diff --git a/Docs/WebUI.md b/Docs/WebUI.md index be3266a..b896049 100644 --- a/Docs/WebUI.md +++ b/Docs/WebUI.md @@ -80,6 +80,21 @@ Signals received in the CONFIG packet are listed in the sidebar: - **Spatial arrays** — `TimeMode = PacketTime` arrays are shown as an expandable group; individual elements (`Ch1[0]`, `Ch1[1]`, …) can be dragged independently. +The unit badge next to each signal shows the calibration's unit override when one +is set, and the streamer's own unit otherwise. + +At the bottom of the sidebar, the collapsible **Sources & Config** section holds: + +- the `host:port`, label, multicast group and data port inputs plus **Connect**, + which adds a source at runtime; +- **Save** — writes the source list and the whole calibration table to the hub's + config file; +- **Reload** — re-reads that file. Calibration is replaced wholesale (so unsaved + edits are discarded), sources present in the file but not running are added, + and no running source is stopped or reconnected; +- a status line showing the written path on success or the hub's error text on + failure. + Click the sidebar toggle button (☰) to collapse/expand the signal list. ### Adding Plots @@ -143,11 +158,31 @@ plot header showing per-signal vertical scale controls: | **V/div** | Volts (or units) per division | | **Pos (div)** | Screen position in divisions (draggable offset marker on Y axis) | | **Type** (Mixed mode only) | Toggle between **Analog** and **Digital** for this signal | +| **Cal · Scale** | Data calibration gain. `value = raw × Scale + Offset` | +| **Cal · Offset** | Data calibration bias, in calibrated units | +| **Cal · Unit** | Overrides the unit reported by the streamer (max 16 chars) | +| **Reset** | Clears this signal's calibration (`Scale = 1`, `Offset = 0`, no unit override) | | **✕** | Close the toolbar and deselect the signal | Offset markers (small triangles on the Y axis) show each signal's position and can be dragged to reposition signals without opening the toolbar. +**Calibration vs. V/div and Offset.** They are different things. V/div and Offset +are a *display* transform: they move and stretch the trace on screen. Calibration +changes *the value itself* — the plot, the Y-axis tick labels, the cursor and +hover readouts, the CSV export and the trigger threshold all report +`raw × Scale + Offset` in the calibrated unit. V/div is then read as "calibrated +units per division" and Offset as "the calibrated value at screen centre". + +The calibration header names the **base** signal and its element count, because +one entry covers every element of an array — opening the toolbar on `Adc[3]` and +editing the calibration moves all of `Adc`. + +Calibration is keyed by the source's **label**, is shared with every other +browser connected to the same hub, and is not persisted until you press **Save** +in the Sources & Config section. It is mirrored to `localStorage` so it survives +a page reload even against a hub with no config file. + ### Plot Controls | Control | Action | diff --git a/docs/superpowers/specs/2026-08-16-udpstreamer-signal-calibration-and-config-design.md b/docs/superpowers/specs/2026-08-16-udpstreamer-signal-calibration-and-config-design.md index 6f29526..42b56ac 100644 --- a/docs/superpowers/specs/2026-08-16-udpstreamer-signal-calibration-and-config-design.md +++ b/docs/superpowers/specs/2026-08-16-udpstreamer-signal-calibration-and-config-design.md @@ -193,7 +193,8 @@ change never sends `calibration`, so the mirror simply remains authoritative; the same holds for a hub started without a config file. **Validation.** The same rules as the hub are enforced in the input handlers: a -non-finite or zero `scale` reverts the field to its last accepted value. +non-finite or zero `scale` keeps the rejected text in the field and marks it +with a red `cal-invalid` border, so the user can see what was wrong. ## Testing