# Per-Signal Calibration and Persistent Hub Config Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give every streamed signal an affine calibration `y = raw * scale + offset` with an optional unit override, stored in the hub's config file alongside the source list, editable from the web oscilloscope and shared between browsers.
**Architecture:** The calibration is metadata only — raw samples stay raw in the ring buffers, in recorded history and in the trigger comparator. Both hubs (the Go `wshub` and the C++ `StreamHub`) own a calibration table keyed by `(source label, base signal name)`, persist it into the existing `-sources-file` / `SourcesFile` as extra elements of the same flat JSON array, and exchange it over five new WebSocket frames. The browser SPA applies the calibration at the single point where raw values enter the display transform, so the hover readout, cursors, rulers and Y-axis ticks all follow for free.
**Tech Stack:** Go 1.x (`marte2/common` module, gorilla/websocket), C++ (MARTe2 style — `StreamString`, `FastPollingMutexSem`, fixed arrays, no STL), vanilla ES2020 browser JS + uPlot, `node --test` for JS unit tests, `go test` for Go.
## Global Constraints
Every task's requirements implicitly include this section.
- **Calibration data model.** Key is `(source, signal)` where `source` is the source **label** (never the runtime id `s1`/`s2`) and `signal` is the **base** signal name with any `[i]` array-element suffix stripped. Fields and validation:
- `source`: string, must be non-empty after trimming.
- `signal`: string, must be non-empty after trimming, no `[i]` suffix.
- `scale`: float64, default `1`, must be finite and non-zero.
- `offset`: float64, default `0`, must be finite.
- `unit`: string, default `""`, trimmed, truncated to **16** characters. Empty means "use the streamer's unit".
- **Identity entries are deleted, not stored.** An entry with `scale == 1 && offset == 0 && unit == ""` carries no information; storing it removes any existing entry for that key so it is never written to the config file.
- **The config file is a flat JSON array of flat objects.** No nested objects, ever. `StreamHub::LoadSourcesFile` is a hand-rolled scanner that takes each `{` up to the next `}` as one object; a nested object would truncate the parse. A block containing `addr` is a source; a block containing `signal` is a calibration; anything else is skipped with a warning.
- **Five new WebSocket frames, implemented identically in both hubs:**
| Direction | Frame |
|---|---|
| hub → client | `{"type":"calibration","cal":[{"source","signal","scale","offset","unit"}, …]}` |
| client → hub | `{"type":"setCalibration","source","signal","scale","offset","unit"}` |
| hub → client | `{"type":"configSaved","ok":bool,"path":string,"error":string}` |
| client → hub | `{"type":"reloadConfig"}` |
| hub → client | `{"type":"configReloaded","ok":bool,"path":string,"error":string}` |
`calibration` is broadcast when a client connects and after every accepted `setCalibration` and every successful `reloadConfig`. An invalid `setCalibration` is rejected and emits **no** broadcast, so the offending client reverts to the last broadcast value.
- **Reload semantics.** `reloadConfig` re-reads the config file, then: **replaces** the calibration table wholesale, **adds** any source in the file that is not already running, and **never** removes, restarts or reconnects a live source.
- **No STL in `Source/Components/`.** `Source/Applications/StreamHub/` follows MARTe2 style: `MARTe::StreamString`, `FastPollingMutexSem`, fixed-size arrays, `new[]`/`delete[]`. `kMaxCalibration = 256`.
- **Build environment.** `source env.sh` before any C++ build or run. It sets `MARTe2_DIR`, `MARTe2_Components_DIR`, `TARGET=x86-linux` and `LD_LIBRARY_PATH`.
- **Existing config files must keep loading unchanged in both hubs.** A file containing only source blocks parses exactly as before.
---
## File Structure
**Go hub — `Common/Client/go/wshub/`**
- `calibration.go` *(new)* — the `CalConfig` type, its validation, the concurrency-safe `calTable` store, and the flat config-file codec (`parseConfigFile` / `encodeConfigFile`). Self-contained and free of hub/network dependencies so it is trivially testable.
- `calibration_test.go` *(new)* — table tests for validation, the heterogeneous-array parse, and the encode→parse round-trip.
- `sources.go` *(modify)* — `SourceManager.Save` / `Load` switch to the new codec; new `Reload` and `Path` methods.
- `sources_test.go` *(new)* — save→parse round-trip asserting sources and calibration both survive.
- `hub.go` *(modify)* — `cal *calTable` on `Hub`, `cal CalConfig` on `hubCmd`, the two new `readPump` cases, the three new/changed `commandCh` cases, the two message builders, and the calibration send in the `register` case.
**C++ hub — `Source/Applications/StreamHub/`**
- `StreamHub.h` *(modify)* — `kMaxCalibration`, the `CalibrationEntry` struct, five new method declarations, three new members.
- `StreamHub.cpp` *(modify)* — the whitespace-tolerant JSON helpers (a pre-existing bug: `HandleSaveSources` writes `"label": "x"` with a space, which the old `JsonGetString` could not read back), the calibration store, the two new command handlers, the two new broadcasters, and the load/save discriminator branches.
**Parity check — `Test/E2E/suite/client/`**
- `configcheck/main.go` *(new)* — a standalone WebSocket client, in the existing `client` module, that drives the five new frames against **either** hub and exits non-zero on mismatch. Deliberately independent of the scenario framework: it needs no live UDP source.
**Browser SPA — `Client/udpstreamer/static/` and `Client/udpstreamer/test/`**
- `calibration.js` *(new)* — pure calibration helpers with a `module.exports` guard so the same file is a browser `
```
with:
```html
```
`calibration.js` must load first: `app.js` reads the global `Calib` at top level
in Task 7.
- [ ] **Step 6: Verify the page still loads**
```bash
cd Client/udpstreamer && node --check static/calibration.js && node --check static/app.js
go run . -port 8099 &
sleep 1
curl -sf http://127.0.0.1:8099/calibration.js | head -3
kill %1
```
Expected: both `node --check` calls are silent; the `curl` prints the module's
first three lines, proving `//go:embed` picked the new file up.
- [ ] **Step 7: Commit**
```bash
git add Client/udpstreamer/static/calibration.js Client/udpstreamer/test/calibration.test.js Client/udpstreamer/static/index.html
git commit -m "webui: add pure calibration module with unit tests"
```
---
### Task 7: SPA display path — calibrate every plotted value
This is the task that makes calibration visible. Because both calibration and
the vertical scale are affine, applying calibration **once**, at the top of
`applyVScaleNorm`, is sufficient for the whole display path:
```
y_cal = raw * scale + offset <- added here
y_norm = (y_cal - vsOffset) / divValue + screenPos <- unchanged
```
Everything that converts a plotted value back to a number — `rawFromNorm`
(`app.js:2325`), the cursor readout, the rulers, the Y-axis tick formatter, and
the V-Scale menu's own V/div and Offset fields — reads `vs._resolvedDiv` /
`vs._resolvedOffset`, which `resolveVScale` derives from the array it is handed.
Calibrating that array therefore makes all of them report calibrated units with
no further change.
Only one in-display site bypasses that: `resolveVScale`'s `range` mode
(`app.js:113-125`), which reads `meta.rangeMin` / `meta.rangeMax` straight from
the streamer CONFIG rather than from the data.
**Files:**
- Modify: `Client/udpstreamer/static/app.js` — new globals + helpers near
`findSignalMeta` (`app.js:101-107`), `resolveVScale` (`app.js:113-125`),
`applyVScaleNorm` (`app.js:186-199`), the `ws.onmessage` dispatch
(`app.js:364-375`), and the source-management block (`app.js:3339`).
**Interfaces:**
- Consumes: `Calib` from Task 6; the `calibration`, `configSaved` and
`configReloaded` frames from Tasks 3 and 4.
- Produces, for Tasks 8-10:
- `calTable` — the module-level `Calib.CalTable` instance
- `srcLabelForKey(key)` → `string` — signal key `"s1:Adc[3]"` → source label
- `calForKey(key)` → calibration object (never null; `Calib.IDENTITY` if unset)
- `unitForKey(key)` → `string` — the override if set, else the streamer's unit
- `setCalibrationWS(source, signal, scale, offset, unit)` → void
- `saveConfigWS()` / `reloadConfigWS()` → void
- `onConfigAck(msg)` → void — assigned by Task 10; a no-op stub here
- `applyCalibrationChanged()` → void — re-render everything after a change
- [ ] **Step 1: Add the calibration table, its localStorage mirror, and the key helpers**
In `Client/udpstreamer/static/app.js`, immediately after `findSignalMeta`
(which ends at `app.js:107`), insert:
```js
/* ─── Calibration ────────────────────────────────────────────────────────── */
// Per-signal affine calibration, keyed by (source LABEL, base signal name).
// The label rather than the runtime id ('s1', 's2') is used because ids are
// assigned in add-order at startup, so an id-keyed entry would rebind to a
// different source whenever the source list order changed.
const CAL_LS_KEY = 'udpscope.calibration';
const calTable = new Calib.CalTable();
// Seeded from localStorage so calibration survives a reload against a hub that
// predates this feature (or one started without a config file). The first
// `calibration` frame from the hub overwrites it wholesale.
try {
const saved = localStorage.getItem(CAL_LS_KEY);
if (saved) calTable.replaceAll(JSON.parse(saved));
} catch { /* corrupt or unavailable storage: start empty */ }
function persistCalibration() {
try { localStorage.setItem(CAL_LS_KEY, JSON.stringify(calTable.list())); }
catch { /* quota or private mode: the hub copy is still authoritative */ }
}
// Signal key "s1:Adc[3]" → the source's label ("wave"), or '' if unknown.
function srcLabelForKey(key) {
const colon = key.indexOf(':');
if (colon < 0) return '';
const src = sourcesMap[key.slice(0, colon)];
return src ? (src.label || src.id) : '';
}
// Signal key "s1:Adc[3]" → base signal name ("Adc").
function baseSigForKey(key) {
const colon = key.indexOf(':');
return Calib.baseSignalName(colon < 0 ? key : key.slice(colon + 1));
}
// Never returns null — an uncalibrated signal yields Calib.IDENTITY.
function calForKey(key) {
return calTable.get(srcLabelForKey(key), baseSigForKey(key));
}
// The unit to show: the calibration override when set, else the streamer's.
function unitForKey(key) {
const cal = calForKey(key);
if (cal.unit) return cal.unit;
const meta = findSignalMeta(key);
return (meta && meta.unit) || '';
}
// Allocate a calibrated copy of a raw array. Returns the input untouched when
// the signal is uncalibrated, so the common case costs nothing.
function calibrateArray(key, rawY) {
const cal = calForKey(key);
if (cal.scale === 1 && cal.offset === 0) return rawY;
const out = new Float64Array(rawY.length);
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : v * cal.scale + cal.offset;
}
return out;
}
```
`findSignalMeta` matches on the *full* signal name (`s.name === key.slice(colon+1)`),
so for an element key like `s1:Adc[3]` it returns `null` and `unitForKey` falls
back to `''`. Fix that at the same time — replace the body of `findSignalMeta`
(`app.js:101-107`) with:
```js
function findSignalMeta(key) {
const colon = key.indexOf(':');
if (colon < 0) return null;
const src = sourcesMap[key.slice(0, colon)];
if (!src) return null;
const name = key.slice(colon + 1);
return src.signals.find(s => s.name === name)
|| src.signals.find(s => s.name === Calib.baseSignalName(name))
|| null;
}
```
- [ ] **Step 2: Verify the file still parses**
```bash
cd Client/udpstreamer && node --check static/app.js
```
Expected: silent.
- [ ] **Step 3: Calibrate at the single display entry point**
Replace `applyVScaleNorm` (`app.js:186-199`) with:
```js
// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
// Calibration is applied first, so divValue/offset — and therefore the cursor,
// hover, ruler and Y-axis readouts derived from them — are all in calibrated
// units. Returns y_norm = (y_cal - offset) / divValue + screenPos.
function applyVScaleNorm(p, yArrays) {
const calArrays = yArrays.map((rawY, ki) => calibrateArray(p.traces[ki], rawY));
if (p.mode === 'digital') return applyDigitalNorm(p, calArrays);
if (p.mode === 'mixed') return applyMixedNorm(p, calArrays);
return calArrays.map((y, ki) => {
const key = p.traces[ki];
const { divValue, offset, screenPos } = resolveVScale(p.id, key, y);
const out = new Float64Array(y.length);
for (let i = 0; i < y.length; i++) {
const v = y[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos;
}
return out;
});
}
```
`applyDigitalNorm` and `applyMixedNorm` now receive calibrated arrays. Both are
relative — they derive their own min/max/threshold from the array they are given
— so they need no other change; a negative `scale` correctly inverts a digital
trace's polarity.
- [ ] **Step 4: Calibrate the range-mode bounds**
In `resolveVScale` (`app.js:113-125`), replace the `range` branch:
```js
if (vs.mode === 'range') {
const meta = findSignalMeta(key);
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
const divValue = niceDiv((meta.rangeMax - meta.rangeMin) / 8);
const offset = Math.round((meta.rangeMin + meta.rangeMax) / 2 / divValue) * divValue;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset, screenPos };
}
// Fall through to auto if no range
}
```
with:
```js
if (vs.mode === 'range') {
const meta = findSignalMeta(key);
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
// rangeMin/rangeMax come from the streamer CONFIG in raw units and never
// pass through applyVScaleNorm, so calibrate them here. calRange re-orders
// the pair, which a negative scale would otherwise swap.
const [lo, hi] = Calib.calRange(meta.rangeMin, meta.rangeMax, calForKey(key));
const divValue = niceDiv((hi - lo) / 8);
const offset = Math.round((lo + hi) / 2 / divValue) * divValue;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset, screenPos };
}
// Fall through to auto if no range
}
```
- [ ] **Step 5: Add the WebSocket senders and the change hook**
In `Client/udpstreamer/static/app.js`, replace `saveSourcesWS` (`app.js:3339-3343`)
with:
```js
function saveConfigWS() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'saveSources' }));
}
}
function reloadConfigWS() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reloadConfig' }));
}
}
function setCalibrationWS(source, signal, scale, offset, unit) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'setCalibration', source, signal, scale, offset, unit }));
}
}
```
`saveSourcesWS` had one caller, `saveBtn` in `makeAddSourceSection`
(`app.js:3387`); Task 10 replaces that whole section. Until then, point it at
the new name so the build stays green — change
```js
saveBtn.addEventListener('click', saveSourcesWS);
```
to
```js
saveBtn.addEventListener('click', saveConfigWS);
```
Then add, immediately after `setCalibrationWS`:
```js
// Called after the calibration table changes, from any source (local edit,
// hub broadcast, or reload). Mirrors to localStorage and re-renders everything
// that shows a value or a unit.
function applyCalibrationChanged() {
persistCalibration();
buildSidebar(); // unit badges
plots.forEach(p => { p.needsRedraw = true; });
if (typeof refreshVScaleMenu === 'function') refreshVScaleMenu(); // Task 8
if (typeof sendTrigConfig === 'function') sendTrigConfig(); // Task 9
}
// Replaced in Task 10 with the Sources & Config status renderer.
function onConfigAck(msg) { /* no-op until Task 10 */ }
```
- [ ] **Step 6: Dispatch the three new frames**
In the `ws.onmessage` handler (`app.js:364-375`), add three cases after the
`monotonicState` line:
```js
else if (msg.type === 'monotonicState') onMonotonicState(msg);
else if (msg.type === 'calibration') onCalibration(msg);
else if (msg.type === 'configSaved' || msg.type === 'configReloaded') onConfigAck(msg);
```
and add the handler next to `onSources` (`app.js:3301`):
```js
// The hub's calibration table is authoritative: replace ours wholesale.
function onCalibration(msg) {
calTable.replaceAll(msg.cal || []);
applyCalibrationChanged();
}
```
- [ ] **Step 7: Verify and smoke-test**
```bash
cd Client/udpstreamer && node --check static/app.js && node --check static/calibration.js
```
Expected: silent.
```bash
cd Client/udpstreamer && rm -rf /tmp/calsmoke && mkdir /tmp/calsmoke
cat > /tmp/calsmoke/cfg.json <<'EOF'
[
{"source": "wave", "signal": "Adc", "scale": 2, "offset": 10, "unit": "kV"}
]
EOF
go run . -port 8099 -sources-file /tmp/calsmoke/cfg.json
```
Open `http://127.0.0.1:8099`, then in the browser console run:
```js
calTable.list()
```
Expected: `[{source: 'wave', signal: 'Adc', scale: 2, offset: 10, unit: 'kV'}]` —
proving the hub broadcast a `calibration` frame on connect and the SPA absorbed
it. Then reload the page with the hub stopped and re-run `calTable.list()`:
the same entry must come back from the localStorage mirror.
Stop the hub with Ctrl-C.
- [ ] **Step 8: Commit**
```bash
git add Client/udpstreamer/static/app.js
git commit -m "webui: apply per-signal calibration to the whole display path"
```
---
### Task 8: Calibration editor in the V-Scale toolbar
The V-Scale toolbar (`#vscale-menu`) is already opened by clicking a signal in a
plot, and it is where V/div and Offset live. The calibration editor goes there
too, as a separate group in the same header row, visually divided from the
display-scale controls so the distinction stays legible:
```
V-Scale: Adc[3] [Auto][Range][Manual] │ Cal (Adc, 8 elem) Scale [1] Offset [0] Unit [V] [Reset] ✕
```
The header names the **base** signal and its element count, because the toolbar
can be opened on a single element (`Adc[3]`) while the edit affects all of them.
**Files:**
- Modify: `Client/udpstreamer/static/index.html:187-216` (the `#vscale-menu` block)
- Modify: `Client/udpstreamer/static/style.css:388` (after `.plot-vscale-bar`)
- Modify: `Client/udpstreamer/static/app.js` — `showVScaleMenu` (`app.js:3412-3467`),
`initVScaleMenu` (`app.js:3524-3585`)
**Interfaces:**
- Consumes: `Calib`, `calTable`, `calForKey`, `srcLabelForKey`, `baseSigForKey`,
`setCalibrationWS`, `applyCalibrationChanged` from Tasks 6 and 7;
`_vsMenuKey` / `_vsMenuPlotId` (`app.js:3410`), `findSignalMeta`,
`numElements` (`app.js:504`), `refreshPlotForKey` (`app.js:243`).
- Produces: `refreshVScaleMenu()` → void — re-reads the calibration fields from
the table; already called speculatively by `applyCalibrationChanged` (Task 7).
- [ ] **Step 1: Add the markup**
In `Client/udpstreamer/static/index.html`, inside `.vstb-header`, insert the
calibration group **between** the `#vscale-type-row` block (ends line 213) and
the close button (line 214):
```html
```
- [ ] **Step 2: Add the styles**
In `Client/udpstreamer/static/style.css`, after `.plot-vscale-bar { display:none; }`
(line 388), add:
```css
.vstb-sep { width:1px; height:16px; background:var(--surface1); flex-shrink:0; }
.ctx-num-sm { width:70px; }
.ctx-num-xs { width:46px; }
#vscale-cal-lbl { color:var(--mauve); font-weight:600; }
.cal-invalid { border-color:var(--red) !important; }
```
- [ ] **Step 3: Populate the fields when the toolbar opens**
In `Client/udpstreamer/static/app.js`, add `refreshVScaleMenu` immediately
before `showVScaleMenu` (`app.js:3412`):
```js
// Re-read the calibration fields from calTable for the currently open toolbar.
// Safe to call when the toolbar is closed.
function refreshVScaleMenu() {
if (!_vsMenuKey) return;
const cal = calForKey(_vsMenuKey);
const base = baseSigForKey(_vsMenuKey);
const meta = findSignalMeta(_vsMenuKey);
const n = meta ? numElements(meta) : 1;
const lbl = document.getElementById('vscale-cal-lbl');
lbl.textContent = n > 1 ? 'Cal (' + base + ', ' + n + ' elem)' : 'Cal (' + base + ')';
const scaleEl = document.getElementById('vscale-cal-scale');
const offsetEl = document.getElementById('vscale-cal-offset');
const unitEl = document.getElementById('vscale-cal-unit');
// Skip the field the user is currently typing in, so a hub broadcast does not
// yank the caret out from under them.
const focused = document.activeElement;
if (focused !== scaleEl) scaleEl.value = cal.scale;
if (focused !== offsetEl) offsetEl.value = cal.offset;
if (focused !== unitEl) unitEl.value = cal.unit;
[scaleEl, offsetEl, unitEl].forEach(el => el.classList.remove('cal-invalid'));
const srcLabel = srcLabelForKey(_vsMenuKey);
const usable = srcLabel !== '' && base !== '';
[scaleEl, offsetEl, unitEl, document.getElementById('btn-cal-reset')]
.forEach(el => { el.disabled = !usable; });
}
```
Then, in `showVScaleMenu`, add a call just before the "Move the toolbar div into
this plot's vscale bar" comment (`app.js:3461`):
```js
refreshVScaleMenu();
// Move the toolbar div into this plot's vscale bar.
```
- [ ] **Step 4: Wire the handlers**
In `initVScaleMenu` (`app.js:3524-3585`), insert before the final line
`document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);`:
```js
// ── Calibration ───────────────────────────────────────────────────────
// Commit the three fields as one entry. Validation mirrors the hub exactly
// (Calib.normaliseCal); an invalid value marks the field and is not sent, so
// the last accepted value stays in force.
function commitCal() {
if (!_vsMenuKey) return;
const scaleEl = document.getElementById('vscale-cal-scale');
const offsetEl = document.getElementById('vscale-cal-offset');
const unitEl = document.getElementById('vscale-cal-unit');
const source = srcLabelForKey(_vsMenuKey);
const signal = baseSigForKey(_vsMenuKey);
const entry = Calib.normaliseCal({
source, signal,
scale: parseFloat(scaleEl.value),
offset: parseFloat(offsetEl.value),
unit: unitEl.value,
});
const scaleBad = entry === null && !(isFinite(parseFloat(scaleEl.value)) && parseFloat(scaleEl.value) !== 0);
scaleEl.classList.toggle('cal-invalid', scaleBad);
offsetEl.classList.toggle('cal-invalid', entry === null && !isFinite(parseFloat(offsetEl.value)));
if (entry === null) return;
calTable.set(entry);
setCalibrationWS(entry.source, entry.signal, entry.scale, entry.offset, entry.unit);
applyCalibrationChanged();
}
document.getElementById('vscale-cal-scale').addEventListener('change', commitCal);
document.getElementById('vscale-cal-offset').addEventListener('change', commitCal);
document.getElementById('vscale-cal-unit').addEventListener('change', commitCal);
document.getElementById('btn-cal-reset').addEventListener('click', () => {
if (!_vsMenuKey) return;
const source = srcLabelForKey(_vsMenuKey);
const signal = baseSigForKey(_vsMenuKey);
if (!source || !signal) return;
calTable.set({ source, signal, scale: 1, offset: 0, unit: '' });
setCalibrationWS(source, signal, 1, 0, '');
applyCalibrationChanged();
refreshVScaleMenu();
});
```
`change` rather than `input`: a partially-typed number like `-` or `1e` would
otherwise be rejected on every keystroke and paint the field red while the user
is still typing.
`applyCalibrationChanged` (Task 7) calls `refreshVScaleMenu`, so a manual call is
needed only in the Reset handler, where the fields themselves must be rewritten
while one of them may hold focus.
- [ ] **Step 5: Verify it parses**
```bash
cd Client/udpstreamer && node --check static/app.js
```
Expected: silent.
- [ ] **Step 6: Manual test**
```bash
cd Client/udpstreamer && rm -rf /tmp/caledit && mkdir /tmp/caledit
go run . -port 8099 -sources-file /tmp/caledit/cfg.json
```
In another terminal, start a streamer so there is live data:
```bash
source env.sh && ./run_streamhub.sh --no-hub 2>/dev/null || \
./Build/x86-linux/GTest/MainGTest.ex --gtest_filter='UDPStreamer*' >/dev/null
```
(Any producer sending UDPS to `127.0.0.1:44500` will do; add it in the browser's
Add Source box as `127.0.0.1:44500`.)
Then in the browser:
1. Drag a signal onto a plot and click it to open the V-Scale toolbar.
2. Set `Scale = 2`, `Offset = 100`. The trace must keep its shape while the
Y-axis tick values double and shift by 100; the hover readout and the cursor
readout must agree with the new axis.
3. Set `Unit = kV`. The sidebar badge for that signal must change to `kV`
(Task 9 adds the hover-readout unit).
4. Open a second browser tab. It must show the same Scale/Offset/Unit — proving
the hub broadcast reached it.
5. Press `Reset`. Both tabs must return to `1 / 0 / —`.
6. Type `0` into Scale. The field must turn red and the plot must not change.
7. Open the toolbar on an array element (`Adc[3]`). The Cal label must read
`Cal (Adc, N elem)` and an edit must move every element of the array.
- [ ] **Step 7: Commit**
```bash
git add Client/udpstreamer/static/index.html Client/udpstreamer/static/style.css Client/udpstreamer/static/app.js
git commit -m "webui: add calibration editor to the V-Scale toolbar"
```
---
### Task 9: The three paths that bypass the display transform
Task 7 covered everything that flows through `applyVScaleNorm`. Three things do
not, and each needs explicit handling:
1. **CSV export** (`app.js:2871`) formats ring/history/snapshot values directly.
2. **Trigger threshold** — the hub compares against **raw** samples, so the
number the user types in calibrated units must be inverted before it is sent
and re-applied when it is displayed. (The V2 capture frame arrives raw and
flows through the normal display path, so it needs nothing.)
3. **Unit display** — the sidebar badge and the hover readout.
**Files:**
- Modify: `Client/udpstreamer/static/app.js` — `exportAllCSV` (`app.js:2871-2970`),
`sendTrigConfig` (`app.js:636-642`), the `#trig-threshold` handler
(`app.js:2481`), `buildTrigSignalSelect` (`app.js:2517`), `showHoverReadout`
(`app.js:2338-2360`), the sidebar signal rendering (`app.js:2586-2606`).
**Interfaces:**
- Consumes: `Calib`, `calForKey`, `unitForKey`, `srcLabelForKey`,
`baseSigForKey` from Tasks 6 and 7.
- Produces: `refreshTrigThresholdField()` → void — rewrites `#trig-threshold`
from `trig.threshold`; called from `applyCalibrationChanged`.
- [ ] **Step 1: Calibrate the CSV export**
In `exportAllCSV`, replace the header/rows block near the end
(`app.js:2957-2962`):
```js
// Strip "sourceId:" prefix from column headers for readability.
const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k));
const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].join(',');
const rows = sortedT.map(t =>
[t.toFixed(9), ...lookups.map(lk => (lk.has(t) ? lk.get(t) : ''))].join(',')
);
```
with:
```js
// Strip "sourceId:" prefix from column headers for readability, and append
// the effective unit. These values come straight from the ring/history/
// snapshot and never pass through applyVScaleNorm, so calibrate them here.
const cals = keys.map(k => calForKey(k));
const displayKeys = keys.map(k => {
const name = k.includes(':') ? k.split(':').slice(1).join(':') : k;
const u = unitForKey(k);
return u ? name + ' [' + u + ']' : name;
});
const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].join(',');
const rows = sortedT.map(t =>
[t.toFixed(9), ...lookups.map((lk, i) =>
lk.has(t) ? Calib.applyCal(lk.get(t), cals[i]) : '')].join(',')
);
```
A unit containing a comma would break the CSV. `Calib.normaliseCal` does not
forbid one, so strip it at the source instead — in
`Client/udpstreamer/static/calibration.js`, inside `normaliseCal`, change:
```js
var unit = String(obj.unit == null ? '' : obj.unit).trim();
```
to:
```js
// Commas and quotes would corrupt the CSV export header; drop them here so
// every consumer sees an already-safe unit.
var unit = String(obj.unit == null ? '' : obj.unit).replace(/[",]/g, '').trim();
```
and add a case to `Client/udpstreamer/test/calibration.test.js`:
```js
test('normaliseCal strips characters that would corrupt a CSV header', () => {
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: 'k,V"'}).unit, 'kV');
});
```
```bash
cd Client/udpstreamer && node --test test/
```
Expected: PASS — `# pass 15`, `# fail 0`.
- [ ] **Step 2: Invert the trigger threshold**
`trig.threshold` stays in **calibrated** units everywhere in the SPA; only the
wire value is raw. Replace `sendTrigConfig` (`app.js:636-642`):
```js
function sendTrigConfig() {
wsSend({
type: 'setTrigger', signal: trig.signal, edge: trig.edge,
threshold: trig.threshold, windowSec: trig.windowSec,
prePercent: trig.prePercent, mode: trig.mode,
});
}
```
with:
```js
// trig.threshold is held in calibrated units. The hub's comparator runs on raw
// samples, so invert on the way out: raw = (calibrated - offset) / scale.
function sendTrigConfig() {
const cal = trig.signal ? calForKey(trig.signal) : Calib.IDENTITY;
wsSend({
type: 'setTrigger', signal: trig.signal, edge: trig.edge,
threshold: Calib.invertCal(trig.threshold, cal), windowSec: trig.windowSec,
prePercent: trig.prePercent, mode: trig.mode,
});
}
// Rewrite the threshold input and its unit hint from trig.threshold.
function refreshTrigThresholdField() {
const el = document.getElementById('trig-threshold');
if (document.activeElement !== el) el.value = trig.threshold;
const u = trig.signal ? unitForKey(trig.signal) : '';
el.title = u ? 'Threshold in ' + u : 'Threshold in the signal\u2019s raw units';
}
```
- [ ] **Step 3: Keep the threshold field in sync**
In `Client/udpstreamer/static/app.js`, in `applyCalibrationChanged` (added in
Task 7 Step 5), replace:
```js
if (typeof sendTrigConfig === 'function') sendTrigConfig(); // Task 9
```
with:
```js
// The threshold is held in calibrated units, so a calibration change alters
// the raw value the hub must compare against — resend it.
if (trig.signal) { refreshTrigThresholdField(); sendTrigConfig(); }
```
And in `buildTrigSignalSelect` (`app.js:2517`), append before the closing brace,
after the `if (curBase && …) sel.value = curBase;` line:
```js
refreshTrigThresholdField();
```
so selecting a different signal updates the unit hint.
- [ ] **Step 4: Show the unit in the hover readout**
In `showHoverReadout`, replace the trace loop body (`app.js:2350-2358`):
```js
p.traces.forEach((key, idx) => {
const vNorm = interpAtTime(p.uplot, idx + 1, t);
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
const val = vNorm === null ? '—' : _fmtVal(rawFromNorm(p, key, vNorm));
```
with:
```js
p.traces.forEach((key, idx) => {
const vNorm = interpAtTime(p.uplot, idx + 1, t);
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
// rawFromNorm inverts the vscale transform, which Task 7 made operate on
// calibrated values — so this is already in calibrated units.
const unit = unitForKey(key);
const val = vNorm === null ? '—'
: (_fmtVal(rawFromNorm(p, key, vNorm)) + (unit ? ' ' + unit : ''));
```
- [ ] **Step 5: Show the override unit in the sidebar**
In the sidebar rendering (`app.js:2586-2606`), the streamer's `sig.unit` is used
in three places. Replace them with the effective unit. Inside the
`sigs.forEach(sig => {` block, after `const globalKey = prefix + sig.name;`, add:
```js
const effUnit = unitForKey(globalKey);
```
Then change the three uses:
```js
grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
```
→
```js
grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, effUnit));
```
```js
+ (sig.unit ? '' + escHtml(sig.unit) + '' : '')
```
→
```js
+ (effUnit ? '' + escHtml(effUnit) + '' : '')
```
```js
const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || '');
```
→
```js
const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, effUnit);
```
All array elements share one calibration, so `effUnit` computed once from
`globalKey` is correct for every child.
- [ ] **Step 6: Verify**
```bash
cd Client/udpstreamer && node --check static/app.js && node --check static/calibration.js && node --test test/
```
Expected: the two checks silent; `# pass 15`, `# fail 0`.
- [ ] **Step 7: Manual test**
Start a hub with a live source (as in Task 8 Step 6), then:
1. Set `Scale = 2`, `Offset = 100`, `Unit = kV` on a signal.
2. Press `⬇ CSV`. The downloaded file's header column must read
`Adc [kV]` and its values must be `2 × raw + 100`. Cross-check one row
against the hover readout at the same timestamp — they must match.
3. Select that signal as the trigger source, set a threshold **inside** the
calibrated range (e.g. `100` when raw hovers around `0`), and arm. The
trigger must fire, and the captured trace must cross the threshold line at
the level shown on the Y-axis.
4. With the trigger armed, change `Scale` to `4`. The trigger must keep firing
at the same *calibrated* threshold — i.e. the hub's raw comparison point
halves — confirming the resend in Step 3.
5. Press `Reset`. The threshold field, the CSV header and the sidebar badge must
all return to the streamer's own unit.
- [ ] **Step 8: Commit**
```bash
git add Client/udpstreamer/static/app.js Client/udpstreamer/static/calibration.js Client/udpstreamer/test/calibration.test.js
git commit -m "webui: calibrate CSV export, trigger threshold and unit display"
```
---
### Task 10: "Sources & Config" sidebar section
The collapsible "Add Source" section at the bottom of the sidebar becomes
"Sources & Config". Its address/label/multicast inputs and Connect button are
unchanged; the fire-and-forget "Save list" button is replaced by **Save** and
**Reload** side by side, plus a one-line status area that renders the
`configSaved` / `configReloaded` acknowledgements from Tasks 3 and 4.
**Files:**
- Modify: `Client/udpstreamer/static/app.js` — `makeAddSourceSection`
(`app.js:3345-3398`), `onConfigAck` (the stub added in Task 7 Step 5)
- Modify: `Client/udpstreamer/static/style.css:475` (after `.save-src-btn:hover`)
**Interfaces:**
- Consumes: `saveConfigWS`, `reloadConfigWS`, `onConfigAck` from Task 7.
- Produces: nothing consumed by a later task.
The section is rebuilt by `buildSidebar()` on every `sources` broadcast, so the
status line cannot live in a DOM node that `buildSidebar` discards — it is held
in a module-level variable and re-rendered each time the section is built.
- [ ] **Step 1: Add the styles**
In `Client/udpstreamer/static/style.css`, after
`.save-src-btn:hover { … }` (line 475), add:
```css
.cfg-btn-row { display:flex; gap:6px; }
.cfg-btn-row .add-src-btn { flex:1; }
.reload-src-btn { color:var(--peach); }
.reload-src-btn:hover { background:rgba(250,179,135,0.1); border-color:var(--peach); }
.cfg-status {
font-size:10px; line-height:1.3; padding:2px 0; min-height:13px;
overflow-wrap:anywhere;
}
.cfg-status.ok { color:var(--green); }
.cfg-status.err { color:var(--red); }
```
- [ ] **Step 2: Rewrite the section**
In `Client/udpstreamer/static/app.js`, replace `makeAddSourceSection`
(`app.js:3345-3398`) in full with:
```js
// Last config acknowledgement, kept outside the DOM because buildSidebar()
// discards and recreates this whole section on every `sources` broadcast.
let _cfgStatus = null; // {ok: bool, text: string} or null
function renderCfgStatus(el) {
el.className = 'cfg-status';
if (!_cfgStatus) { el.textContent = ''; return; }
el.classList.add(_cfgStatus.ok ? 'ok' : 'err');
el.textContent = _cfgStatus.text;
}
function onConfigAck(msg) {
const what = msg.type === 'configSaved' ? 'Saved' : 'Reloaded';
if (msg.ok) {
_cfgStatus = { ok: true, text: what + ': ' + (msg.path || 'config file') };
} else {
_cfgStatus = { ok: false, text: what + ' failed: ' + (msg.error || 'unknown error') };
}
const el = document.getElementById('cfg-status');
if (el) renderCfgStatus(el);
}
function makeSourcesConfigSection() {
const section = document.createElement('div');
section.className = 'add-source-section';
const title = document.createElement('div');
title.className = 'add-source-title';
title.innerHTML = '▶ Sources & Config';
const body = document.createElement('div');
body.className = 'add-source-body';
const addrInput = document.createElement('input');
addrInput.className = 'add-src-input'; addrInput.type = 'text';
addrInput.placeholder = 'host:port';
const labelInput = document.createElement('input');
labelInput.className = 'add-src-input'; labelInput.type = 'text';
labelInput.placeholder = 'label (optional)';
const mcastInput = document.createElement('input');
mcastInput.className = 'add-src-input'; mcastInput.type = 'text';
mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)';
const dataPortInput = document.createElement('input');
dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number';
dataPortInput.placeholder = 'data port (multicast only)';
dataPortInput.min = '1'; dataPortInput.max = '65535';
const addBtn = document.createElement('button');
addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
addBtn.addEventListener('click', () => {
const addr = addrInput.value.trim(); if (!addr) return;
const mcastGroup = mcastInput.value.trim();
const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0;
addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort);
addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = '';
});
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
const btnRow = document.createElement('div');
btnRow.className = 'cfg-btn-row';
const saveBtn = document.createElement('button');
saveBtn.className = 'add-src-btn save-src-btn';
saveBtn.textContent = 'Save';
saveBtn.title = 'Write the source list and all signal calibration to the hub\u2019s config file';
saveBtn.addEventListener('click', () => {
_cfgStatus = null;
const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el);
saveConfigWS();
});
const reloadBtn = document.createElement('button');
reloadBtn.className = 'add-src-btn reload-src-btn';
reloadBtn.textContent = 'Reload';
reloadBtn.title = 'Re-read the config file: calibration is replaced wholesale, '
+ 'missing sources are added, and no running source is stopped';
reloadBtn.addEventListener('click', () => {
_cfgStatus = null;
const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el);
reloadConfigWS();
});
btnRow.append(saveBtn, reloadBtn);
const status = document.createElement('div');
status.id = 'cfg-status';
renderCfgStatus(status);
body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, btnRow, status);
section.append(title, body);
title.addEventListener('click', () => {
const open = section.classList.toggle('open');
title.querySelector('.add-src-arrow').style.transform = open ? 'rotate(90deg)' : '';
});
return section;
}
```
The stub `function onConfigAck(msg) { /* no-op until Task 10 */ }` added in
Task 7 Step 5 is now superseded — delete it, keeping only the version above.
- [ ] **Step 3: Update the caller**
`makeAddSourceSection` had one caller, at the end of `buildSidebar`
(`app.js:2618`). Change:
```js
list.appendChild(makeAddSourceSection());
```
to:
```js
list.appendChild(makeSourcesConfigSection());
```
- [ ] **Step 4: Verify no stale references remain**
```bash
cd Client/udpstreamer && grep -n "makeAddSourceSection\|saveSourcesWS" static/app.js; node --check static/app.js
```
Expected: `grep` prints nothing (exit status 1) and `node --check` is silent.
- [ ] **Step 5: Manual test against the Go hub**
```bash
cd Client/udpstreamer && rm -rf /tmp/cfgui && mkdir /tmp/cfgui
go run . -port 8099 -sources-file /tmp/cfgui/cfg.json
```
In the browser at `http://127.0.0.1:8099`:
1. Expand "Sources & Config". Add a source, set a calibration on one of its
signals, press **Save**. The status line must turn green and read
`Saved: /tmp/cfgui/cfg.json`. Confirm with `cat /tmp/cfgui/cfg.json` that both
the source and the calibration object are present.
2. Change the calibration to something else *without* saving, then press
**Reload**. The status line must read `Reloaded: /tmp/cfgui/cfg.json` and the
calibration must snap back to the saved value while the live source keeps
streaming without a gap in the plot.
3. Stop the hub and restart it **without** `-sources-file`. Press **Save**. The
status line must turn red with the hub's error text.
- [ ] **Step 6: Manual test against the C++ StreamHub**
```bash
source env.sh
rm -rf /tmp/cfguicpp && mkdir -p /tmp/cfguicpp
cat > /tmp/cfguicpp/hub.cfg <<'EOF'
+Hub = {
Class = StreamHub
WSPort = 8098
MaxPoints = 2000
SourcesFile = "/tmp/cfguicpp/cfg.json"
}
EOF
./Build/x86-linux/Applications/StreamHub/StreamHub.ex -cfg /tmp/cfguicpp/hub.cfg
```
The C++ StreamHub serves no static files, so point the browser at the Go hub's
page and override the WebSocket target, or simply open the page from a Go hub
started on a different port and connect the browser's WebSocket manually. The
quickest check is the Task 5 parity checker, which already covers this pair —
run it and confirm the behaviours above match:
```bash
./Test/E2E/suite/client/configcheck/configcheck -url ws://127.0.0.1:8098/ws
```
Expected: `configcheck OK`.
- [ ] **Step 7: Commit**
```bash
git add Client/udpstreamer/static/app.js Client/udpstreamer/static/style.css
git commit -m "webui: replace Add Source with Sources & Config save/reload panel"
```
---
### Task 11: Documentation
Three documents describe the surfaces this feature changed. Update them last, so
the wording matches what was actually built.
**Files:**
- Modify: `Docs/StreamHub-API.md:44-50` (`saveSources`), `:125` (after
`setMaxPoints`), `:216` (after `maxPointsUpdated`), `:273` (Limits table)
- Modify: `Docs/WebUI.md:133-149` (V-Scale Toolbar), `:73-83` (Signal Sidebar)
- Modify: `ARCHITECTURE.md:371-405` (§6 command and event tables)
**Interfaces:**
- Consumes: the final behaviour of Tasks 1-10.
- Produces: nothing.
- [ ] **Step 1: Update `Docs/StreamHub-API.md` — commands**
Replace the `saveSources` section (`Docs/StreamHub-API.md:44-50`):
````markdown
### `saveSources`
```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.
````
with:
````markdown
### `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 [§5](#5-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.
````
- [ ] **Step 2: Update `Docs/StreamHub-API.md` — events**
After the `maxPointsUpdated` section (`Docs/StreamHub-API.md:216-222`), add:
````markdown
### `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.
````
- [ ] **Step 3: Add the config file format section to `Docs/StreamHub-API.md`**
Before `## 4. Limits` (`Docs/StreamHub-API.md:273`), insert a new section, and
renumber `## 4. Limits` to `## 5. Limits`:
````markdown
## 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.
````
- [ ] **Step 4: Update `ARCHITECTURE.md` §6**
In the "Commands (client → hub)" table (`ARCHITECTURE.md:378-393`), change the
`saveSources` row:
```
| `saveSources` | — | Persist the current dynamic source list to `SourcesFile` (JSON) |
```
to:
```
| `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` |
```
In the "Events (hub → client)" table (`ARCHITECTURE.md:397-407`), add after the
`maxPointsUpdated` row:
```
| `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` |
```
Then, immediately before `### Binary Push Frame (version 1, …)`
(`ARCHITECTURE.md:409`), insert:
````markdown
### 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.
````
- [ ] **Step 5: Update `Docs/WebUI.md`**
In the V-Scale Toolbar table (`Docs/WebUI.md:138-147`), add three rows before the
`✕` row:
```
| **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) |
```
and add this paragraph after the table's trailing "Offset markers…" note
(`Docs/WebUI.md:148-149`):
```markdown
**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.
```
In the Signal Sidebar section (`Docs/WebUI.md:73-83`), add before the "Click the
sidebar toggle button" line:
```markdown
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.
```
- [ ] **Step 6: Verify the docs are internally consistent**
```bash
grep -n "setCalibration\|reloadConfig\|configSaved\|configReloaded" Docs/StreamHub-API.md ARCHITECTURE.md Docs/WebUI.md
grep -rn "Save list\|Add Source" Docs/ ARCHITECTURE.md
```
Expected: the first command lists all five frames in both `Docs/StreamHub-API.md`
and `ARCHITECTURE.md`. The second prints nothing — no stale reference to the old
button or section name survives.
Also confirm the section renumbering in Step 3 left no dangling links:
```bash
grep -n "^## [0-9]" Docs/StreamHub-API.md
```
Expected: `1. Commands`, `2. Events`, `3. Binary frames`, `4. Config file
format`, `5. Limits` — consecutive, no gaps.
- [ ] **Step 7: Commit**
```bash
git add Docs/StreamHub-API.md Docs/WebUI.md ARCHITECTURE.md
git commit -m "docs: document per-signal calibration and config save/reload"
```
---
## Final verification
Run once, after all eleven tasks are complete.
- [ ] **Step 1: Full C++ build and unit tests**
```bash
source env.sh
make -f Makefile.gcc core && make -f Makefile.gcc apps && make -f Makefile.gcc test
./Build/x86-linux/GTest/MainGTest.ex
```
Expected: clean build, all GTest cases pass.
- [ ] **Step 2: Go tests**
```bash
cd Common/Client/go && go vet ./... && go test ./...
cd ../../../Client/udpstreamer && go vet ./... && go build ./...
cd ../../Test/E2E/suite/client && go vet ./...
```
Expected: `ok marte2/common/wshub`, no vet findings, build succeeds.
- [ ] **Step 3: JS checks**
```bash
cd Client/udpstreamer && node --check static/app.js && node --check static/calibration.js && node --test test/
```
Expected: checks silent, `# fail 0`.
- [ ] **Step 4: Cross-hub parity**
Run the Task 5 checker against both hubs, as in Task 5 Steps 3-5. Both must
print `configcheck OK`.
- [ ] **Step 5: E2E suite**
```bash
./Test/E2E/suite/run_e2e.sh --skip-coverage
```
Expected: the same pass/XFAIL set as before this feature. Calibration is
metadata-only on the hub side, so no scenario's waveform validation should move.
Any change in `validate_waveform.py` fidelity is a regression to investigate, not
an expected consequence.
- [ ] **Step 6: End-to-end manual pass**
Start the full stack (`./run_streamhub.sh`), then in the browser:
1. Set `Scale`, `Offset` and `Unit` on a live signal. Confirm the plot, Y-axis
ticks, hover readout, cursor readout, CSV export and trigger threshold all
agree.
2. Press **Save**, reload the page, and confirm the calibration returns from the
hub (not just from `localStorage` — check by clearing
`localStorage['udpscope.calibration']` first).
3. Edit the calibration without saving, press **Reload**, and confirm the edit is
discarded while the live source keeps streaming without a gap.
4. Open a second browser tab and confirm an edit in one appears in the other.