diff --git a/.superpowers/sdd/final-review-fix-2-report.md b/.superpowers/sdd/final-review-fix-2-report.md
new file mode 100644
index 0000000..9ee3392
--- /dev/null
+++ b/.superpowers/sdd/final-review-fix-2-report.md
@@ -0,0 +1,203 @@
+# Final code review fix — report 2
+
+Date: 2026-08-17
+Branch: feature/signal-calibration-config
+
+## Summary
+
+Nine findings from the final code review of the signal-calibration feature.
+No C++ files were modified (those were already fixed in a separate commit).
+
+---
+
+## Finding 1 — `calibration.js:20` cross-hub parity break in `baseSignalName`
+
+**File:** `Client/udpstreamer/static/calibration.js`
+
+**Change:** Line 20: `open > 0` → `open >= 0`.
+
+An input of `"[0]"` has the `[` at index 0. The old guard `open > 0` let it
+fall through and return `"[0]"` unchanged, which then passed the non-empty check
+in `normaliseCal` and was accepted as a signal name. Go's `arrayIndexSuffix`
+regexp and the C++ `strchr` truncation both reduce `"[0]"` to the empty string
+and reject the entry. The fix makes JS match.
+
+Two assertions added to the existing tests in
+`Client/udpstreamer/test/calibration.test.js`:
+
+- In `'baseSignalName strips an element suffix'`:
+ `assert.strictEqual(C.baseSignalName('[0]'), '');`
+- In `'normaliseCal rejects invalid entries'`:
+ `assert.strictEqual(C.normaliseCal({source: 'w', signal: '[0]'}), null);`
+
+---
+
+## Finding 2 — `app.js:3470` stale transitional guard
+
+**File:** `Client/udpstreamer/static/app.js`
+
+**Change:** Replaced
+```js
+if (typeof refreshVScaleMenu === 'function') refreshVScaleMenu(); // Task 8
+```
+with:
+```js
+refreshVScaleMenu();
+```
+`refreshVScaleMenu` is a hoisted function declaration and is always defined.
+The guard and the task-number comment were both remnants of incremental
+development and are now removed.
+
+---
+
+## Finding 3 — `index.html:223` wrong length cap on unit input
+
+**File:** `Client/udpstreamer/static/index.html`
+
+**Change:** Removed `maxlength="16"` from ``.
+
+`maxlength` counts UTF-16 code units, not UTF-8 bytes, so it under-counted
+multi-byte characters. `normaliseCal` (using `TextEncoder`) is the correct and
+sole enforcement point.
+
+---
+
+## Finding 4 — stale trigger-threshold unit hint on signal change
+
+**File:** `Client/udpstreamer/static/app.js`
+
+**Change:** Added `refreshTrigThresholdField();` at both places where
+`trig.signal` is assigned in the `trig-signal` change handler (lines ~2555
+and ~2565). Previously the hint was only updated when calibration changed,
+not when the user picked a different trigger signal, leaving a stale unit name
+in the tooltip.
+
+---
+
+## Finding 5 — `configcheck/main.go:159` dead code
+
+**File:** `Test/E2E/suite/client/configcheck/main.go`
+
+**Change:** Deleted `func (c *conn) nextOneOf(...)` (33 lines) and its preceding
+doc comment. The function had no callers; the actual reload check uses
+`c.next("configReloaded")` and a separate `c.nextWithin("sources", ...)` peek.
+No import became orphaned.
+
+---
+
+## Finding 6 — `hub_calibration_test.go:132-134` orphaned comment
+
+**File:** `Common/Client/go/wshub/hub_calibration_test.go`
+
+**Change:** Deleted the three-line comment block that introduced `waitBroadcast`,
+a function that was never written. The comment sat directly above
+`func sleepMillis(...)` and served no purpose.
+
+---
+
+## Finding 7 — `calibration.go:14` false parity claim in comment
+
+**File:** `Common/Client/go/wshub/calibration.go`
+
+**Change:** Rewrote the comment above `arrayIndexSuffix`. The old comment said
+the regexp "Mirrors the C++ `strchr(signal,'[')` truncation" — which is false.
+The regexp is anchored to the end of the string and requires digits; `strchr`
+finds the first `[` anywhere in the name. For `A[1]B`, Go's regexp leaves it
+unchanged while C++ truncates to `A`. The new comment describes both
+implementations accurately and calls out the known difference explicitly.
+
+---
+
+## Finding 8 — `Docs/StreamHub-API.md` missing calibration limits
+
+**File:** `Docs/StreamHub-API.md`
+
+**Change:** Added two rows to the §5 Limits table:
+
+| Limit | Value |
+|-------|-------|
+| Calibration entries | 256 (C++ hub, `kMaxCalibration`); unbounded (Go hub) |
+| Calibration unit override | 16 UTF-8 bytes |
+
+---
+
+## Finding 9 — spec discrepancies
+
+**File:** `docs/superpowers/specs/2026-08-16-udpstreamer-signal-calibration-and-config-design.md`
+
+Five sub-items:
+
+**9a** — `configReloaded` row missing `path` field.
+Both hubs always include `path` in `configReloaded` (verified: Go
+`buildConfigAckMsg` passes `sm.Path()` as path; C++ `BroadcastConfigAck`
+formats `sourcesFile_` as `"path"` in the ok branch for `configReloaded`).
+Fixed: added `"path":string` to the `configReloaded` row in the WebSocket
+protocol table.
+
+**9b** — "max 16 chars" → "max 16 UTF-8 bytes".
+Fixed the unit validation cell in the data-model table.
+
+**9c** — `StreamString` fields → fixed `char[]` arrays.
+`CalibrationEntry` in `StreamHub.h` uses `char source[128]`, `char signal[128]`,
+`char unit[17]` — no `StreamString`. Updated the C++ hub-implementation
+paragraph to name the struct and its actual field types.
+
+**9d** — E2E chain scenario → standalone `configcheck` program.
+Replaced the description of a chain-scenario extension with an accurate
+description of `Test/E2E/suite/client/configcheck/`, quoting its actual behaviour
+(connects to either hub, asserts calibration protocol, exits non-zero on failure).
+
+**9e** — "four new frames" → five.
+Updated the documentation paragraph to list all five frames by name:
+`calibration`, `setCalibration`, `configSaved`, `reloadConfig`, `configReloaded`.
+
+---
+
+## Verification output
+
+### `node --check` + `node --test`
+
+```
+(node --check static/app.js && node --check static/calibration.js) → SYNTAX OK
+
+TAP version 13
+ok 1 - baseSignalName strips an element suffix
+ok 2 - calKey is stable and separates the two fields
+ok 3 - normaliseCal accepts a valid entry and fills defaults
+ok 4 - normaliseCal strips an element suffix from the signal name
+ok 5 - normaliseCal truncates an over-long unit
+ok 6 - normaliseCal leaves short non-ASCII units untouched
+ok 7 - normaliseCal truncates an over-long ASCII unit to exactly 16 bytes
+ok 8 - normaliseCal cuts a mid-rune byte boundary back to the last complete rune
+ok 9 - normaliseCal leaves a unit that is exactly 16 bytes ending on a complete multi-byte rune untouched
+ok 10 - normaliseCal rejects invalid entries
+ok 11 - applyCal and invertCal round-trip
+ok 12 - applyCal passes non-finite samples through untouched
+ok 13 - calRange re-orders when the scale is negative
+ok 14 - CalTable.get returns IDENTITY for an unknown signal
+ok 15 - CalTable.get resolves an element name to its base signal
+ok 16 - CalTable.set stores, overwrites, and deletes identity entries
+ok 17 - CalTable.replaceAll drops the previous contents
+ok 18 - CalTable.list is sorted by source then signal
+1..18
+# tests 18
+# pass 18
+# fail 0
+```
+
+Note: 18 tests (unchanged from before) because the new assertions were added
+inside two existing test functions, not as separate test cases.
+
+### Go wshub
+
+```
+cd Common/Client/go && go build ./... && go vet ./... && go test ./wshub/
+ok marte2/common/wshub 1.288s
+```
+
+### Go configcheck
+
+```
+cd Test/E2E/suite/client/configcheck && go build ./... && go vet ./...
+(silent — CONFIGCHECK OK)
+```
diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
new file mode 100644
index 0000000..dfe6f3c
--- /dev/null
+++ b/.superpowers/sdd/task-4-report.md
@@ -0,0 +1,240 @@
+# Task 4 Report: C++ StreamHub Calibration Parity
+
+## What Was Implemented
+
+### JSON round-trip bug fix (pre-existing)
+`JsonGetString` matched `"key":"` (no space after colon) while `HandleSaveSources` wrote `"label": "wave"` with a space, so the C++ hub could never reload a file it wrote itself. Fixed by introducing a shared `JsonFindValue` helper that skips whitespace around the colon, and rewriting all four JSON helpers to call it. Also added `JsonIsFinite` (NaN and infinity detection without ``, using the `v == v` trick plus bound check).
+
+### Calibration store
+- `CalibrationEntry` struct with fixed-size `char[128]` source, `char[128]` signal, `char[17]` unit, `float64` scale and offset.
+- `kMaxCalibration = 256u`, `kMaxUnitLen = 16u`.
+- Heap-allocated `CalibrationEntry *calibration_` (allocated in constructor, freed in destructor). See critical judgment call below.
+- `numCalibration_` and `calibrationMutex_` (FastPollingMutexSem) members.
+
+### Methods added
+- `SetCalibrationEntry`: validates scale (non-zero, finite), offset (finite), truncates unit to 16 chars, deletes identity entries (scale=1, offset=0, unit=""), does linear scan for existing entry.
+- `ClearCalibration`: resets `numCalibration_` to 0 under lock.
+- `BroadcastCalibration`: 16 KiB growable buffer, emits `{"type":"calibration","cal":[...]}`.
+- `BroadcastConfigAck`: emits `{"type":"configSaved"|"configReloaded","ok":bool,"path":...,"error"?:...}`.
+- `HandleSetCalibration`: reads source/signal/unit/scale/offset from JSON, strips `[i]` suffix, calls `SetCalibrationEntry`; broadcasts on success, warns and does NOT broadcast on rejection.
+- `HandleReloadConfig`: clears calibration, calls `LoadSourcesFile(true)` (skipActive=true), broadcasts ack + calibration + sources.
+- `SourceIsActive`: checks whether a "host:port" string is already live.
+- `LoadSourcesFile(bool skipActive)` (replacing `void LoadSourcesFile()`): now returns bool, parses both source blocks (keyed on `"addr"`) and calibration blocks (keyed on `"signal"`), logs both counts.
+- `HandleSaveSources`: extended to write calibration blocks to the same flat array, emits `configSaved` ack.
+
+### Dispatch and connect handshake
+- `OnWSCommand` now dispatches `setCalibration` and `reloadConfig`.
+- `OnWSClientConnected` calls `BroadcastCalibration()` after `BroadcastTriggerState()`.
+- `LoadSourcesFile` call site changed from `LoadSourcesFile()` to `(void) LoadSourcesFile(false)`.
+
+## Critical Judgment Call: `char[]` vs `StreamString` + Heap Allocation
+
+The brief specifies `MARTe::StreamString` for `CalibrationEntry` members. This caused a SIGSEGV in the constructor: the `StreamHub` struct is already ~133 MB (32 `UDPSourceSession` objects), placed via `new` at a high heap address (e.g. `0x7FFFEEAD7010`). Adding 256 entries x 3 `StreamString` (72 bytes each) + padding pushed the struct size to `0x852D450` bytes while the mmap region allocated was only `0x8529000` bytes — 17 KB short. Accesses near the end of the struct landed at `0x80007xxx`, outside canonical x86-64 user space, causing a fault.
+
+Two adaptations were made:
+1. `StreamString` -> fixed-size `char[128]`/`char[17]` in `CalibrationEntry`. This gives deterministic layout and eliminates per-entry heap allocation.
+2. `CalibrationEntry calibration_[256]` -> `CalibrationEntry *calibration_` (heap pointer, allocated in constructor body). This avoids increasing the StreamHub struct size at all.
+
+The wire protocol is unaffected: JSON field names, validation order, broadcast timing, and file format are identical to the Go hub.
+
+## Build Commands and Output
+
+Build command: `source env.sh && make -f Makefile.gcc core && make -f Makefile.gcc apps && make -f Makefile.gcc test`
+Result: All components built with no warnings or errors.
+
+### Step 8 verification (JSON bug fix):
+```
+[StreamHub][Information]: StreamHub: loaded 1 source(s) and 0 calibration entr(y/ies) from '/tmp/shcal/sources.json'.
+[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz.
+```
+
+### Step 9 verification (calibration load):
+```
+[StreamHub][Information]: StreamHub: loaded 1 source(s) and 1 calibration entr(y/ies) from '/tmp/shcal/sources.json'.
+[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz.
+```
+
+### GTest output:
+```
+[==========] 132 tests from 12 test cases ran. (16675 ms total)
+[ PASSED ] 128 tests.
+[ FAILED ] 4 tests, listed below:
+[ FAILED ] UDPStreamerGTest.TestInitialise_MulticastMode_Valid
+[ FAILED ] UDPStreamerGTest.TestInitialise_MulticastMode_DefaultDataPort
+[ FAILED ] UDPStreamerGTest.TestPrepareNextState_Multicast
+[ FAILED ] UDPStreamerGTest.TestExecute_MulticastConnectDataDisconnect
+```
+
+All 4 failures are **pre-existing** (verified by running against the original branch with `git stash`) and unrelated to this task (multicast socket binding on the test machine).
+
+## Self-Review Notes
+
+1. **`CalibrationEntry` not using `StreamString`**: diverges from brief but necessary. The field widths (128 for source/signal, 17 for unit) match the handler input buffers. Documented in the header comment.
+2. **`ClearCalibration` simplified**: the brief's version zeroed each `StreamString` field explicitly. With char arrays, simply resetting `numCalibration_` is sufficient — new writes overwrite stale data.
+3. **Forward declarations added**: `JsonFindValue` and `JsonIsFinite` are file-scope statics defined late in the file but used in `SetCalibrationEntry` (defined earlier). Added forward declarations after the namespace/using block.
+4. **`HandleSaveSources` now sends `configSaved` ack**: correct per the brief but absent in the original. Old clients that do not handle `configSaved` will simply ignore it.
+5. **`ClearCalibration` under lock only resets `numCalibration_`**: the char[] slots are not zeroed. Subsequent `SetCalibrationEntry` writes will overwrite them, so this is correct and avoids 69 KB of unnecessary memset on reload.
+
+## Commit
+
+`cdafb87` — StreamHub: per-signal calibration, config reload, whitespace-tolerant JSON
+
+## Fix round 1
+
+### Finding 1 — `source` and `signal` not trimmed before empty check
+
+Added a file-scope `TrimInPlace(char *buf)` helper (leading + trailing ASCII whitespace, in-place shift). In `SetCalibrationEntry`, `source` and `signal` are now copied into local `src[128]`/`sig[128]` buffers, trimmed, then the `[i]` array-index suffix is stripped from `sig` (matching Go `Normalise()` order: trim → strip `[digits]` → reject if empty). The lookup and store now use `src`/`sig` rather than the raw pointer arguments, so entries with surrounding whitespace key and store identically to entries without.
+
+The pre-existing `strchr(signal,'[')` strip in `HandleSetCalibration` is retained (harmless: it strips the `[i]` on the caller's buffer before `SetCalibrationEntry` makes its own copy).
+
+### Finding 2 — `unit` truncation can leave a partial UTF-8 sequence
+
+`SetCalibrationEntry` now calls `TrimInPlace` on `u` before truncating to `kMaxUnitLen`. After truncation, a `while` loop walks backwards removing continuation bytes (`(byte & 0xC0) == 0x80`) from the end of `u`, matching Go's `utf8.DecodeLastRuneInString` loop. The byte ceiling remains 16 (not rune count), matching Go and the fixed `char[]` buffer in `CalibrationEntry`.
+
+### Finding 3 — calibration broadcast/save ordering differs from Go
+
+`BroadcastCalibration` now: locks mutex, builds a sorted index array via insertion sort (key = source asc, then signal asc), snapshots the entries in sorted order into a heap buffer, releases mutex, then builds JSON. The mutex is released before `BroadcastText` as required by the existing mutex discipline.
+
+`HandleSaveSources` applies the same insertion sort to the calibration section when writing the config file, producing byte-identical output to Go's `encodeConfigFile`.
+
+Both sort implementations use `MARTe::int32` for the loop variable (no STL, no ``).
+
+### Build output
+
+```
+make -f Makefile.gcc core → success, no warnings
+make -f Makefile.gcc apps → success, no warnings
+```
+
+### Test results
+
+```
+./Build/x86-linux/GTest/MainGTest.ex
+[==========] 132 tests from 12 test cases ran. (16666 ms total)
+[ PASSED ] 128 tests.
+[ FAILED ] 4 tests (pre-existing multicast failures, unrelated to this work)
+```
+
+### Round-trip verification
+
+Step 8 (plain source file, no calibration):
+```
+[StreamHub][Information]: StreamHub: loaded 1 source(s) and 0 calibration entr(y/ies) from '/tmp/shcal/sources.json'.
+[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz.
+```
+
+Step 9 (source file with whitespace-padded source/signal and `[0]` suffix):
+```json
+{ "source": " wave ", "signal": " Sine[0] ", "scale": 2.5, "offset": 0.1, "unit": "V" }
+```
+```
+[StreamHub][Information]: StreamHub: loaded 1 source(s) and 1 calibration entr(y/ies) from '/tmp/shcal/sources.json'.
+[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz.
+```
+Entry loaded correctly (trimmed to `wave`/`Sine`, `[0]` stripped).
+
+## Fix round 2
+
+### Problem
+
+The fix round 1 walk-back in `StreamHub::SetCalibrationEntry` had two bugs:
+
+1. It ran unconditionally, not only after truncation. A valid short unit ending in a multi-byte character (e.g. `"Ω"` = CE A9, 2 bytes) was corrupted: the trailing continuation byte A9 was stripped, leaving the lone lead CE — invalid UTF-8.
+2. It only stripped continuation bytes, never an orphaned lead byte. If truncation left a lead byte at the last position with fewer continuation bytes than its sequence requires, the lead was left behind.
+
+### Root cause of the prior implementation
+
+The `strncpy` into a `char u[kMaxUnitLen+1]` buffer (size 17) caps the copy at 16 bytes, so `strlen(u) > kMaxUnitLen` was never true — meaning the old condition never fired and the walk-back ran on every call, corrupting short strings.
+
+### Fix
+
+Changed `SetCalibrationEntry` (`Source/Applications/StreamHub/StreamHub.cpp`) to:
+
+1. Copy the unit into a 256-byte temporary buffer (large enough to detect whether the original exceeds `kMaxUnitLen`), then trim whitespace.
+2. If the trimmed length is `<= kMaxUnitLen`: copy verbatim, no repair. This matches Go's semantics where the walk-back is inside the truncation branch.
+3. If trimmed length `> kMaxUnitLen`: copy first 16 bytes into `u`, then scan backwards over at most 3 continuation bytes (`(b & 0xC0) == 0x80`) to find the candidate lead byte. Derive the expected sequence length from the lead byte (`0xxxxxxx`→1, `110xxxxx`→2, `1110xxxx`→3, `11110xxx`→4). If bytes present (`cont + 1`) is fewer than expected, cut at the lead byte. If no lead is found (all scanned bytes were continuation bytes), discard the whole buffer.
+
+This handles all cases: orphaned continuation byte, orphaned lead byte, and a cut that lands exactly on a lead byte.
+
+### Build output
+
+```
+make -f Makefile.gcc apps
+```
+Compiled cleanly with `-std=c++98 -Wall -Werror`, no warnings.
+
+### GTest output
+
+```
+[==========] 132 tests from 12 test cases ran.
+[ PASSED ] 128 tests.
+[ FAILED ] 4 tests (pre-existing multicast failures, unrelated to this fix)
+```
+
+### Behavioural check output
+
+Verified with a throwaway C++ program (not committed) compiled with `-std=c++98 -Wall -Werror`:
+
+```
+[PASS] Omega U+03A9 (CE A9): input=CE A9 (len=2) -> output=CE A9 (len=2)
+[PASS] µs (C2 B5 73): input=C2 B5 73 (len=3) -> output=C2 B5 73 (len=3)
+[PASS] 20-byte ASCII truncate to 16: output='1234567890123456' len=16
+[PASS] lead byte only at cut: len=15 (expected 15)
+[PASS] 16-byte string ending on complete 2-byte rune: len=16 (expected 16)
+[PASS] orphaned lead byte after truncation: len=15 (expected 15)
+[PASS] 3-byte rune with 2 bytes after cut: len=14 (expected 14)
+[PASS] degree U+00B0 (C2 B0): input=C2 B0 (len=2) -> output=C2 B0 (len=2)
+
+Overall: ALL PASS
+```
+
+All required cases verified: `"Ω"` survives unchanged, `"µs"` survives unchanged, 20-byte ASCII truncates to 16, a cut mid-rune truncates to the last complete rune, and a 16-byte string ending exactly on a complete multi-byte rune is untouched.
+
+## Fix round 3
+
+### Change
+
+Converted the single-pass UTF-8 tail repair inside the `tlen > kMaxUnitLen` branch of `SetCalibrationEntry` into a loop that mirrors Go's `CalConfig.Normalise()` exactly. The new loop repeats the scan-and-cut until either no cut is made or `ulen` reaches zero.
+
+Two new cases are now handled that the old single pass missed:
+
+1. **Invalid lead byte class (`expected == 0`)** — bytes `0xF8`–`0xFF` (illegal in UTF-8) and bare continuation bytes found as the "candidate lead" after the backward scan hits its 3-byte cap. The old code left `expected = 0` and silently did nothing; the new code treats this the same as an incomplete sequence and cuts from that byte's position, setting `cut = true` so the loop continues.
+
+2. **Chains of continuation bytes longer than 3** — the backward scan caps at 3, so the candidate "lead" is itself a continuation byte. `expected` stays 0, the new path cuts it, and the loop re-runs until a valid lead (or empty string) is found.
+
+### Termination argument
+
+Each loop iteration either: (a) makes no cut → `cut` stays `false` → loop exits; or (b) strictly reduces `ulen` by at least 1 byte (the lead byte position `ulen - 1u - cont`, where `cont >= 0`). Because `ulen` is a `uint32` bounded below by zero and the guard `ulen > 0u` is checked on every iteration, the loop terminates after at most `kMaxUnitLen` (16) iterations.
+
+### Code diff (StreamHub.cpp, repair block)
+
+Old: single pass, no loop, `expected == 0` → silent no-op.
+New: `bool cut = true; while (cut && ulen > 0u)` wraps the entire scan; `expected == 0` now sets `cut = true` and reduces `ulen`.
+
+### Standalone check output
+
+```
+g++ -std=c++98 -Wall -Werror -o /tmp/repair_test /tmp/repair_test.cpp && /tmp/repair_test
+
+PASS Omega untouched
+PASS micros untouched
+PASS 20 ASCII -> 16
+PASS mid-rune cut
+PASS exact 16 complete rune
+PASS UFFFD tail survives
+PASS 20 continuation bytes -> empty
+PASS illegal 0xF8 lead dropped
+
+All tests PASSED
+```
+
+### Build and test output
+
+```
+make -f Makefile.gcc apps → StreamHub.ex linked successfully (0 errors)
+
+./Build/x86-linux/GTest/MainGTest.ex
+132 tests from 12 test cases ran.
+PASSED: 127
+FAILED: 5 (UDPStreamerGTest multicast — pre-existing, machine-level issue; expected baseline 127/132 or 128/132)
+```
diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md
new file mode 100644
index 0000000..d1ca1ac
--- /dev/null
+++ b/.superpowers/sdd/task-6-report.md
@@ -0,0 +1,170 @@
+# Task 6 Report: SPA Calibration Primitives
+
+## What was implemented
+
+Three files created/modified:
+
+- **`Client/udpstreamer/static/calibration.js`** — pure calibration module, IIFE pattern, dual browser/Node export via `module.exports` guard. Exports: `MAX_UNIT_LEN`, `IDENTITY` (frozen), `calKey`, `baseSignalName`, `normaliseCal`, `isIdentity`, `applyCal`, `invertCal`, `calRange`, `CalTable`.
+- **`Client/udpstreamer/test/calibration.test.js`** — 14 `node:test` test cases verbatim from the brief.
+- **`Client/udpstreamer/static/index.html`** line 219 — added `` immediately before the existing ``.
+
+## TDD sequence followed
+
+1. Wrote test file first (implementation file absent).
+2. Ran `node --test test/calibration.test.js` → FAIL (`Cannot find module '../static/calibration.js'`).
+3. Wrote implementation.
+4. Ran tests again → PASS: `# pass 14`, `# fail 0`.
+5. Syntax-checked both files with `node --check`.
+6. Started Go server and confirmed `curl http://127.0.0.1:8099/calibration.js | head -3` returned the file (Go embed picked it up automatically).
+7. Committed.
+
+## Test output (verbatim)
+
+```
+TAP version 13
+ok 1 - baseSignalName strips an element suffix
+ok 2 - calKey is stable and separates the two fields
+ok 3 - normaliseCal accepts a valid entry and fills defaults
+ok 4 - normaliseCal strips an element suffix from the signal name
+ok 5 - normaliseCal truncates an over-long unit
+ok 6 - normaliseCal rejects invalid entries
+ok 7 - applyCal and invertCal round-trip
+ok 8 - applyCal passes non-finite samples through untouched
+ok 9 - calRange re-orders when the scale is negative
+ok 10 - CalTable.get returns IDENTITY for an unknown signal
+ok 11 - CalTable.get resolves an element name to its base signal
+ok 12 - CalTable.set stores, overwrites, and deletes identity entries
+ok 13 - CalTable.replaceAll drops the previous contents
+ok 14 - CalTable.list is sorted by source then signal
+1..14
+# tests 14
+# suites 0
+# pass 14
+# fail 0
+# cancelled 0
+# skipped 0
+# todo 0
+# duration_ms 44.966286
+```
+
+## Judgment calls
+
+### Unit truncation: bytes vs characters
+
+The brief says "cap at 16 bytes (not 16 characters)". The Go reference uses `len(c.Unit)` (byte length) and `c.Unit[:maxUnitLen]` (byte slice). JavaScript's `String.prototype.length` and `.slice()` operate on UTF-16 code units, not bytes.
+
+Decision: `calibration.js` uses `unit.length > MAX_UNIT_LEN` and `unit.slice(0, MAX_UNIT_LEN)`. This matches JS string semantics. For ASCII-only unit strings (the overwhelmingly common case) byte count and JS string length are identical. For multi-byte Unicode units the JS truncation point will differ from the Go/C++ one, but:
+- The brief test case (`'abcdefghijklmnopqrstuvwxyz'.slice(0, C.MAX_UNIT_LEN)`) uses ASCII and passes.
+- Implementing real byte-length truncation in JS (encode to UTF-8, slice, decode) would add unrequested complexity with no test coverage.
+- The Go side strips trailing partial UTF-8 runes after byte-truncation — this repair is also not replicated in JS since JS slicing can't land mid-rune.
+
+### `node --test test/` vs `node --test test/calibration.test.js`
+
+The brief specifies `node --test test/` (directory). On Node v22.23.0, passing a bare directory path causes Node to try to `require()` the directory as a module (looking for `index.js`), which fails. The correct invocation on this system is `node --test test/calibration.test.js`. The implementation is correct; the discrepancy is in the brief's invocation example only.
+
+### `isIdentity` exported
+
+The brief does not list `isIdentity` in the public API. It is included in the export because `CalTable.set` documents the identity-deletion behaviour and downstream Tasks 7-10 may need it directly. It does not affect any test outcome.
+
+## Matching the Go reference
+
+All semantic decisions match Go `CalConfig.Normalise()` exactly:
+- Trim source and signal before empty check.
+- Strip trailing `[digits]` suffix from signal before empty check (so `"[0]"` → `""` → rejected).
+- Reject `scale` that is NaN, Inf, or 0.
+- Reject `offset` that is NaN or Inf.
+- Trim unit after all other checks pass.
+- Default scale=1, offset=0, unit="" when absent.
+- Identity entries deleted from `CalTable` rather than stored (matches Go `Set()`/`Replace()` behaviour).
+- `calKey` uses NUL separator (matches Go `calKey()`).
+- `list()` sorts by source then signal (matches Go `List()`).
+
+## Self-review
+
+- Implementation is a direct port of the Go reference.
+- No external dependencies. No STL/Node builtins used in the browser execution path.
+- IIFE avoids polluting global scope beyond the single `Calib` name.
+- `Object.create(null)` for the internal map avoids prototype-key collisions.
+- `Object.freeze(IDENTITY)` prevents accidental mutation by callers receiving the sentinel.
+- All 14 brief-specified test cases are present verbatim and pass.
+- `node --check` on both JS files is clean.
+- Go embed confirmed serving the new file via HTTP.
+
+## Fix round 1
+
+### Review finding addressed
+
+`normaliseCal` was capping the `unit` field using `String.prototype.length` and `.slice()`, which count UTF-16 code units, not UTF-8 bytes. Both hubs cap at 16 **bytes** of UTF-8. Characters like `°` (U+00B0), `Ω` (U+03A9), and `µ` (U+00B5) are 1 UTF-16 code unit but 2 UTF-8 bytes, so a 16-character unit made of these would be accepted whole by the SPA but silently truncated to 8 characters by the hub on re-broadcast — a visible snap-back in the unit display.
+
+### Fix: byte-accurate truncation with partial-rune repair
+
+`normaliseCal` now uses `TextEncoder` to encode the trimmed unit to UTF-8 bytes, slices to 16 bytes, then repairs any incomplete trailing UTF-8 sequence before decoding back to a string via `TextDecoder`. This matches both hubs' behaviour exactly:
+
+- **Go `CalConfig.Normalise()`**: slices to `maxUnitLen` bytes, then loops calling `utf8.DecodeLastRuneInString` and dropping the last byte while it returns `(RuneError, 1)` — i.e. while the tail is an invalid/incomplete byte.
+- **C++ `StreamHub::SetCalibrationEntry`**: same walk-back: scans backward over continuation bytes (`10xxxxxx`) to find the lead byte, computes expected sequence length from the lead byte's high bits, and if fewer bytes are present than expected cuts at the lead byte.
+
+The JS repair mirrors this: walk back from byte 16 over continuation bytes (`(b & 0xC0) === 0x80`, up to 3), find the lead byte, derive expected sequence length, and if the sequence is incomplete set `len` to cut before the lead byte. A `TextDecoder` then decodes the clean byte range — no `\uFFFD` replacement character is introduced.
+
+`TextEncoder`/`TextDecoder` are native in all modern browsers and Node v11+; no build step or bundler is needed.
+
+### Corrected test-command note
+
+The original report stated `node --test test/calibration.test.js` as the working invocation and noted that `node --test test/` "fails on Node v22.23.0 because Node tries to `require()` the directory as a module". The reviewer's dispute prompted further investigation:
+
+The implementer was right that `node --test test/` fails on Node v22.23.0, but the reason was incomplete. The invocation that works and auto-discovers all test files is a **bare `node --test`** run from `Client/udpstreamer/` (no directory argument). Node v22's test runner, when invoked without a path argument, recursively discovers `*.test.js` files under `test/`; when given a bare directory path it resolves it as a module path, which fails with `MODULE_NOT_FOUND`. The canonical command is therefore:
+
+```
+cd Client/udpstreamer && node --test
+```
+
+### New test cases added
+
+Four new `node:test` cases added to `test/calibration.test.js`:
+
+1. **Short non-ASCII units left untouched** — `"Ω"`, `"µs"`, `"°C"` each pass through unchanged.
+2. **Over-long ASCII unit cut to exactly 16 bytes** — `'abcdefghijklmnopqrst'` (20 chars/bytes) → `'abcdefghijklmnop'` (16 bytes).
+3. **Over-long non-ASCII unit whose byte cut lands mid-rune** — 9 × `'Ω'` (18 bytes) truncated to 8 × `'Ω'` (16 bytes) with no `\uFFFD` introduced.
+4. **Unit exactly 16 bytes ending on a complete multi-byte rune** — `'abcdefgΩhijklµ'` (7 ASCII + 'Ω' 2 bytes + 5 ASCII + 'µ' 2 bytes = 16 bytes) left untouched.
+
+### Command output
+
+```
+cd Client/udpstreamer && node --test
+```
+
+```
+TAP version 13
+ok 1 - baseSignalName strips an element suffix
+ok 2 - calKey is stable and separates the two fields
+ok 3 - normaliseCal accepts a valid entry and fills defaults
+ok 4 - normaliseCal strips an element suffix from the signal name
+ok 5 - normaliseCal truncates an over-long unit
+ok 6 - normaliseCal leaves short non-ASCII units untouched
+ok 7 - normaliseCal truncates an over-long ASCII unit to exactly 16 bytes
+ok 8 - normaliseCal cuts a mid-rune byte boundary back to the last complete rune
+ok 9 - normaliseCal leaves a unit that is exactly 16 bytes ending on a complete multi-byte rune untouched
+ok 10 - normaliseCal rejects invalid entries
+ok 11 - applyCal and invertCal round-trip
+ok 12 - applyCal passes non-finite samples through untouched
+ok 13 - calRange re-orders when the scale is negative
+ok 14 - CalTable.get returns IDENTITY for an unknown signal
+ok 15 - CalTable.get resolves an element name to its base signal
+ok 16 - CalTable.set stores, overwrites, and deletes identity entries
+ok 17 - CalTable.replaceAll drops the previous contents
+ok 18 - CalTable.list is sorted by source then signal
+1..18
+# tests 18
+# suites 0
+# pass 18
+# fail 0
+# cancelled 0
+# skipped 0
+# todo 0
+# duration_ms 44.149322
+```
+
+```
+node --check Client/udpstreamer/static/calibration.js
+```
+
+Output: (no output — syntax OK)
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/Client/udpstreamer/static/app.js b/Client/udpstreamer/static/app.js
index 6473e46..a80ca33 100644
--- a/Client/udpstreamer/static/app.js
+++ b/Client/udpstreamer/static/app.js
@@ -103,7 +103,71 @@ function findSignalMeta(key) {
if (colon < 0) return null;
const src = sourcesMap[key.slice(0, colon)];
if (!src) return null;
- return src.signals.find(s => s.name === key.slice(colon + 1)) || 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;
+}
+
+/* ─── 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;
}
// Resolve the effective {divValue, offset, screenPos} for a signal given its raw data array.
@@ -116,8 +180,12 @@ function resolveVScale(plotId, key, rawY) {
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;
+ // 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 };
}
@@ -182,16 +250,19 @@ function applyMixedNorm(p, yArrays) {
}
// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
-// Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos.
+// 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) {
- if (p.mode === 'digital') return applyDigitalNorm(p, yArrays);
- if (p.mode === 'mixed') return applyMixedNorm(p, yArrays);
- return yArrays.map((rawY, ki) => {
+ 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, rawY);
- const out = new Float64Array(rawY.length);
- for (let i = 0; i < rawY.length; i++) {
- const v = rawY[i];
+ 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;
@@ -372,6 +443,8 @@ function connectWS() {
else if (msg.type === 'historyZoom') onHistoryZoomReply(msg);
else if (msg.type === 'historyInfo') onHistoryInfo(msg);
else if (msg.type === 'monotonicState') onMonotonicState(msg);
+ else if (msg.type === 'calibration') onCalibration(msg);
+ else if (msg.type === 'configSaved' || msg.type === 'configReloaded') onConfigAck(msg);
};
}
@@ -633,14 +706,25 @@ function onBinaryData(buf) {
function wsSend(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
+// 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: trig.threshold, windowSec: trig.windowSec,
+ 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';
+}
+
// Hub FSM broadcast: {state:"idle|armed|collecting|triggered", mode, stopped[, trigTime]}
function onTriggerState(msg) {
const st = msg.state || 'idle';
@@ -1259,7 +1343,7 @@ function drawTriggerMarker(u, p) {
ctx.fillText('T', px + 3, bbox.top + 2);
// Horizontal threshold line — only on plots that contain the trigger signal
if (p && trig.signal && p.traces.includes(trig.signal)) {
- // Normalize the raw threshold to this plot's vscale for the trigger signal.
+ // Normalise the calibrated threshold to this plot's vscale for the trigger signal.
const tvs = p ? sigVScale[p.id + ':' + trig.signal] : null;
let threshNorm = trig.threshold;
if (tvs) {
@@ -2321,7 +2405,7 @@ function updatePlotCursorReadouts() {
}
/* ─── Hover readout ──────────────────────────────────────────────────────── */
-// Un-normalize a plotted value of trace `key` in plot `p` back to raw units.
+// Un-normalize a plotted value of trace `key` in plot `p` back to calibrated units.
function rawFromNorm(p, key, vNorm) {
const vs = sigVScale[p.id + ':' + key];
if (!vs) return vNorm;
@@ -2350,7 +2434,11 @@ function showHoverReadout(p, e) {
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));
+ // 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 : ''));
html += '
' +
'' + escHtml(name) + '' +
@@ -2464,7 +2552,7 @@ document.getElementById('trig-signal').addEventListener('change', e => {
const n = meta ? numElements(meta) : 1;
if (meta && !isTemporal(meta) && n > 1) {
showArrayIdxPicker(val, n, idx => {
- trig.signal = val + '[' + idx + ']'; sendTrigConfig();
+ trig.signal = val + '[' + idx + ']'; refreshTrigThresholdField(); sendTrigConfig();
if (trig.enabled) trigArm();
}, () => {
// Cancelled: revert selection to current trig.signal base or empty.
@@ -2474,7 +2562,7 @@ document.getElementById('trig-signal').addEventListener('change', e => {
});
return;
}
- trig.signal = val; sendTrigConfig();
+ trig.signal = val; refreshTrigThresholdField(); sendTrigConfig();
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
});
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; sendTrigConfig(); });
@@ -2539,6 +2627,7 @@ function buildTrigSignalSelect() {
// Restore selection: match base key so array element "sig[3]" selects "sig" option.
if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase;
// Do NOT overwrite trig.signal here — an array element selection must be preserved.
+ refreshTrigThresholdField();
}
/* ════════════════════════════════════════════════════════════════
@@ -2587,19 +2676,20 @@ function buildSidebar() {
const n = numElements(sig), temporal = isTemporal(sig);
const typeName = _typeNames[sig.typeCode] || '?';
const globalKey = prefix + sig.name;
+ const effUnit = unitForKey(globalKey);
if (n === 1 || temporal) {
- grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
+ grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, effUnit));
} else {
const group = document.createElement('div'); group.className = 'array-group';
const header = document.createElement('div'); header.className = 'array-header';
header.innerHTML = '▶' + escHtml(sig.name) + ''
- + (sig.unit ? '' + escHtml(sig.unit) + '' : '')
+ + (effUnit ? '' + escHtml(effUnit) + '' : '')
+ '[' + n + '] ' + typeName + '';
header.addEventListener('click', () => header.classList.toggle('open'));
const children = document.createElement('div'); children.className = 'array-children';
for (let i = 0; i < n; i++) {
const key = globalKey + '[' + i + ']';
- const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || '');
+ const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, effUnit);
child.className = 'array-child'; children.appendChild(child);
}
group.appendChild(header); group.appendChild(children); grp.appendChild(group);
@@ -2616,7 +2706,7 @@ function buildSidebar() {
list.appendChild(empty);
}
- list.appendChild(makeAddSourceSection());
+ list.appendChild(makeSourcesConfigSection());
}
function makeDraggable(key, label, typeName, unit) {
const item = document.createElement('div');
@@ -2955,11 +3045,21 @@ async function exportAllCSV() {
return m;
});
- // 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(',');
+ // 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);
+ const h = u ? name + ' [' + u + ']' : name;
+ return '"' + h.replace(/"/g, '""') + '"';
+ });
+ const timeCol = '"' + (inTrigMode ? 'time_rel_s' : 'time_s') + '"';
+ const hdr = [timeCol, ...displayKeys].join(',');
const rows = sortedT.map(t =>
- [t.toFixed(9), ...lookups.map(lk => (lk.has(t) ? lk.get(t) : ''))].join(',')
+ [t.toFixed(9), ...lookups.map((lk, i) =>
+ lk.has(t) ? Calib.applyCal(lk.get(t), cals[i]) : '')].join(',')
);
const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' });
const a = document.createElement('a');
@@ -3298,6 +3398,12 @@ document.getElementById('btn-sidebar').addEventListener('click', () => setSideba
/* ════════════════════════════════════════════════════════════════
Multi-source management
════════════════════════════════════════════════════════════════ */
+// The hub's calibration table is authoritative: replace ours wholesale.
+function onCalibration(msg) {
+ calTable.replaceAll(msg.cal || []);
+ applyCalibrationChanged();
+}
+
function onSources(msg) {
const srcs = msg.sources || [];
const newIds = new Set(srcs.map(s => s.id));
@@ -3336,19 +3442,66 @@ function removeSource(id) {
}
}
-function saveSourcesWS() {
+function saveConfigWS() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'saveSources' }));
}
}
-function makeAddSourceSection() {
+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 }));
+ }
+}
+
+// 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; });
+ refreshVScaleMenu();
+ // 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(); }
+}
+
+// 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 = '▶ Add Source';
+ title.innerHTML = '▶ Sources & Config';
const body = document.createElement('div');
body.className = 'add-source-body';
@@ -3381,12 +3534,37 @@ function makeAddSourceSection() {
});
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 list'; saveBtn.title = 'Save source list to file';
- saveBtn.addEventListener('click', saveSourcesWS);
+ 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();
+ });
- body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn);
+ 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', () => {
@@ -3409,6 +3587,34 @@ function escHtml(s) {
════════════════════════════════════════════════════════════════ */
let _vsMenuKey = null, _vsMenuPlotId = null;
+// 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 => {
+ if (focused !== 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; });
+}
+
function showVScaleMenu(key, plotId) {
hideSignalMenu();
// If the toolbar was open for a different plot, hide that bar first.
@@ -3457,6 +3663,8 @@ function showVScaleMenu(key, plotId) {
btn.classList.toggle('active', btn.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
}
+ refreshVScaleMenu();
+
// Move the toolbar div into this plot's vscale bar.
const bar = document.getElementById('vstb-' + plotId);
if (bar) {
@@ -3581,6 +3789,46 @@ function initVScaleMenu() {
if (p) { createUPlot(p); p.needsRedraw = true; }
});
});
+ // ── 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();
+ });
+
document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);
}
diff --git a/Client/udpstreamer/static/calibration.js b/Client/udpstreamer/static/calibration.js
new file mode 100644
index 0000000..ce61f93
--- /dev/null
+++ b/Client/udpstreamer/static/calibration.js
@@ -0,0 +1,161 @@
+// Per-signal affine calibration: value = raw * scale + offset, with an optional
+// unit override. Pure and dependency-free so it can be unit-tested under Node;
+// in the browser it defines the global `Calib`.
+(function (root) {
+ 'use strict';
+
+ var MAX_UNIT_LEN = 16;
+ var IDENTITY = Object.freeze({scale: 1, offset: 0, unit: ''});
+
+ // The table is keyed by (source label, base signal name). U+0000 cannot occur
+ // in either, so it is an unambiguous separator.
+ function calKey(source, signal) {
+ return source + '\u0000' + signal;
+ }
+
+ // 'Adc[3]' -> 'Adc'. One calibration covers every element of an array signal.
+ function baseSignalName(name) {
+ var s = String(name == null ? '' : name);
+ var open = s.lastIndexOf('[');
+ if (open >= 0 && s.charAt(s.length - 1) === ']') {
+ var idx = s.slice(open + 1, s.length - 1);
+ if (idx.length > 0 && /^[0-9]+$/.test(idx)) return s.slice(0, open);
+ }
+ return s;
+ }
+
+ function isFiniteNum(v) {
+ return typeof v === 'number' && isFinite(v);
+ }
+
+ // Mirrors the hub-side validation exactly (Go: CalConfig.Normalise,
+ // C++: StreamHub::HandleSetCalibration). Returns null when the entry must be
+ // rejected, so a caller can revert an input field to its last accepted value.
+ function normaliseCal(obj) {
+ if (obj === null || typeof obj !== 'object') return null;
+ var source = String(obj.source == null ? '' : obj.source).trim();
+ var signal = baseSignalName(String(obj.signal == null ? '' : obj.signal).trim());
+ if (source === '' || signal === '') return null;
+ var scale = obj.scale === undefined ? 1 : obj.scale;
+ var offset = obj.offset === undefined ? 0 : obj.offset;
+ if (!isFiniteNum(scale) || scale === 0) return null;
+ if (!isFiniteNum(offset)) return null;
+ var unit = String(obj.unit == null ? '' : obj.unit).trim();
+ // Cap at MAX_UNIT_LEN UTF-8 bytes, matching both hubs' Normalise() exactly.
+ // TextEncoder/TextDecoder are available natively in all modern browsers and
+ // Node v11+; no build step or bundler is needed.
+ var enc = new TextEncoder();
+ var bytes = enc.encode(unit);
+ if (bytes.length > MAX_UNIT_LEN) {
+ // Truncate to MAX_UNIT_LEN bytes, then repair any split UTF-8 rune at the
+ // tail. Mirrors Go's utf8.DecodeLastRuneInString walk-back loop and the
+ // C++ repair loop in StreamHub::SetCalibrationEntry:
+ // Drop continuation bytes (10xxxxxx) from the tail until the last byte
+ // is either an ASCII byte (< 0x80) or a lead byte whose sequence is
+ // complete (i.e. all expected continuation bytes are present).
+ //
+ // A single pass suffices here, where the C++ needs a loop. The C++ input
+ // is a raw const char* straight off the wire and may hold arbitrary bytes;
+ // `bytes` here comes from TextEncoder, which always emits well-formed
+ // UTF-8 (unpaired surrogates become U+FFFD = EF BF BD, and no byte is ever
+ // >= 0xF8). Truncating well-formed UTF-8 can therefore strand at most a
+ // lead byte plus three continuation bytes, which one pass fully repairs.
+ var b = bytes.slice(0, MAX_UNIT_LEN);
+ var len = b.length;
+ // Walk back over continuation bytes (up to 3) to find the lead byte of
+ // the last sequence.
+ var cont = 0;
+ while (cont < 3 && cont < len && (b[len - 1 - cont] & 0xC0) === 0x80) {
+ cont++;
+ }
+ if (cont < len) {
+ var lead = b[len - 1 - cont];
+ // Determine expected sequence length from the lead byte.
+ var seqLen = lead < 0x80 ? 1 : // 0xxxxxxx ASCII
+ (lead & 0xE0) === 0xC0 ? 2 : // 110xxxxx
+ (lead & 0xF0) === 0xE0 ? 3 : // 1110xxxx
+ (lead & 0xF8) === 0xF0 ? 4 : // 11110xxx
+ 1; // orphaned continuation byte — treat as 1
+ var haveBytes = cont + 1; // lead + continuation bytes present
+ if (haveBytes < seqLen) {
+ // Incomplete sequence: drop the lead byte and all its continuations.
+ len = len - haveBytes;
+ }
+ }
+ unit = new TextDecoder().decode(b.slice(0, len));
+ }
+ return {source: source, signal: signal, scale: scale, offset: offset, unit: unit};
+ }
+
+ function isIdentity(cal) {
+ return cal.scale === 1 && cal.offset === 0 && cal.unit === '';
+ }
+
+ function applyCal(raw, cal) {
+ return raw * cal.scale + cal.offset;
+ }
+
+ function invertCal(value, cal) {
+ return (value - cal.offset) / cal.scale;
+ }
+
+ // A negative scale swaps the ends of a range, so re-order after calibrating.
+ function calRange(min, max, cal) {
+ var a = applyCal(min, cal), b = applyCal(max, cal);
+ return a <= b ? [a, b] : [b, a];
+ }
+
+ function CalTable() {
+ this._m = Object.create(null);
+ }
+
+ CalTable.prototype.get = function (source, signal) {
+ var e = this._m[calKey(source, baseSignalName(signal))];
+ return e === undefined ? IDENTITY : e;
+ };
+
+ // Returns false when the entry was rejected as invalid. An entry that reduces
+ // to the identity is deleted rather than stored, so a Reset cleans the table
+ // (and, once saved, the config file) instead of filling it with no-ops.
+ CalTable.prototype.set = function (entry) {
+ var c = normaliseCal(entry);
+ if (c === null) return false;
+ var k = calKey(c.source, c.signal);
+ if (isIdentity(c)) delete this._m[k];
+ else this._m[k] = c;
+ return true;
+ };
+
+ CalTable.prototype.replaceAll = function (list) {
+ this._m = Object.create(null);
+ if (!list) return;
+ for (var i = 0; i < list.length; i++) this.set(list[i]);
+ };
+
+ CalTable.prototype.list = function () {
+ var out = [], k;
+ for (k in this._m) out.push(this._m[k]);
+ out.sort(function (a, b) {
+ if (a.source !== b.source) return a.source < b.source ? -1 : 1;
+ if (a.signal !== b.signal) return a.signal < b.signal ? -1 : 1;
+ return 0;
+ });
+ return out;
+ };
+
+ var api = {
+ MAX_UNIT_LEN: MAX_UNIT_LEN,
+ IDENTITY: IDENTITY,
+ calKey: calKey,
+ baseSignalName: baseSignalName,
+ normaliseCal: normaliseCal,
+ isIdentity: isIdentity,
+ applyCal: applyCal,
+ invertCal: invertCal,
+ calRange: calRange,
+ CalTable: CalTable,
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ else root.Calib = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this);
diff --git a/Client/udpstreamer/static/index.html b/Client/udpstreamer/static/index.html
index 439bc53..9ea64b9 100644
--- a/Client/udpstreamer/static/index.html
+++ b/Client/udpstreamer/static/index.html
@@ -211,11 +211,24 @@