Commit Graph
98 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 a2efc142c3 fix(udpscope): keep the accumulated-scalar timeline monotonic and bounded
Round 4 of Task 4 review. Four defects in FrameDecoder's rule 3:

- The undeclared-rate (hrt) path positioned each burst at an ABSOLUTE
  hrt/ticksPerSecond(). hrt counts from the producer's boot, so it is ~1e11
  ticks by the time a scope attaches, and the rate is refitted every packet
  with a few parts in 1e4 of wobble. The product is tens of milliseconds of
  jitter in BOTH directions -- not merely imprecise, non-monotonic. Integrate
  short tick deltas into accProdSec instead and let ClockOffset latch the
  epoch that leaves behind.
- The lead bleed used a fixed 0.9 factor, which converges only while the
  declared rate is within ~10%. Squeeze proportionally to the excess instead
  (floored at kMinBleedFactor), settling it in a single burst.
- A single-sample flush fell through to the plain-scalar rule, dating it from
  arrival and leaving lastCounter stale so the next real burst reinstated a
  hole that never existed. Accumulate mode flushes on a timer, so a short
  cycle legitimately yields one sample; keep it on the chain.
- kMaxCounterGap was inert: an absurd gap yields an absurd prediction that the
  arrival backstop already rejects, and no input can distinguish the two
  rules. Removed rather than left implying a behaviour it did not have.

FrameDecoder.h now states the deliberate divergence from StreamHub -- which
converts hrt with the LOCAL MARTe timer frequency, valid only because it runs
on the producer's host -- and why a remote scope's drift is irreducible.

Three new tests, each sabotage-proven non-vacuous: producer restart, short
flushes staying on the chain, and a 20000-packet undeclared run after a
day of producer uptime that asserts SPACING as well as ordering (the
monotonic guard alone restores order while leaving positions wrong).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 22:16:11 +02:00
Martino FerrariandClaude Opus 4.6 3270284cfe fix(udpscope): bound the reconstructed timeline against the wall clock
Round 3 of the Task 4 review. Three defects, all in FrameDecoder rule 3.

The resync backstop was one-directional. `predicted` is never below
lastEmittedEnd + dt, so rejecting a correction that would step backwards
meant only a LAGGING chain could ever be pulled back; a chain running fast
drifted ahead without bound. Two hosts' crystals differ by tens of ppm, so a
declared SamplingRate is always slightly wrong in one direction or the other
and this is certain on a long session. A leading timeline cannot be corrected
in one burst without going backwards -- lastEmittedEnd is by definition past
arrival -- so the excess is bled off by drawing each burst 10 % narrower until
the timeline is back inside the threshold.

A repeated packet counter was treated as a normal packet. The C client
de-duplicates fragments only, so an unfragmented update reaching a host that
joined the group on two interfaces was emitted twice, doubling the values and
advancing the timeline by a burst that never existed.

The samplingRate == 0 path differenced two HrtRateFit::toSeconds() results.
toSeconds() divides an absolute tick count -- ~1e11 on a producer that has
been up a day -- by a rate refitted on every packet, so its few-parts-in-1e4
wobble arrives multiplied by the whole elapsed epoch: tens of milliseconds of
jitter on a value whose consecutive difference is a few milliseconds. Raw
ticks are differenced instead, anchored on the first usable packet so the
wobble applies only to the interval since attach.

The existing hrt-gap test could not have caught the last one: its 10 ms
producer period made the expected answer exactly kDefaultDt, so a decoder
that derived nothing passed. It now uses 25 ms.

Four tests added, all sabotage-proven. The plan is updated to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 21:55:33 +02:00
Martino FerrariandClaude Opus 4.6 7102412a9f fix(udpscope): reconstruct lost accumulated bursts from the packet counter
Review found the decoder was estimating something the wire states exactly.
FrameView::counter increments once per update, so a gap of g means g-1 lost
datagrams; reinstating their duration restores the hole precisely, with no
threshold and no dependence on arrival time. The arrival-anchor comparison
survives only as a backstop for what the counter cannot express — a producer
restart, a counter stuck at zero, a wrong declared rate — and can no longer
step a signal's timestamps backwards, which the ring and trigger forbid.

Also from review: guard the time-signal lookup against a frame carrying more
signals than the installed table, and give FrameBuilder a counter parameter.
Leaving it at zero had hidden the counter rules from every test, and made the
hrt-gap test vacuous — under uniform arrivals the hrt path and packetBurst
agree by construction, so it could not tell which branch answered. Its
arrivals now carry zero-mean jitter.

Each new assertion was proven non-vacuous by sabotage: dropping the gap term,
the backward guard, or the hrt branch fails exactly its own test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 20:16:52 +02:00
Martino FerrariandClaude Opus 4.6 892e3eae28 fix(udpscope): bound accumulated-burst chaining so packet loss cannot displace the trace
Forward-chaining each accumulated burst onto the previous one suppresses
arrival jitter, but an unchecked chain never recovers: one lost datagram, or a
declared sampling rate that differs from the producer's real one, dates every
later sample early for the rest of the run. The chain is now a prediction,
compared each packet against the arrival anchor and abandoned beyond
kBurstResyncThresholdS, which bounds the error instead of accumulating it.

Plan amended so the hrt-fit fallback (unusable here: the fit needs 32 packets
and is itself corrupted by bursty arrivals) cannot come back.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 20:08:05 +02:00
Martino FerrariandClaude Sonnet 4.6 5a8479cda9 feat(udpscope): per-element timestamp reconstruction from UDPS frames
Implements FrameDecoder with five timing rules that mirror
UDPSourceSession.cpp: FullArray (per-element time signal), FirstSample
and LastSample (rate-spread from anchor), accumulated scalar with
declared rate (forward-chain anchoring, immune to arrival jitter), and
PACKET burst (backward-span from previous arrival). 9 new tests, 38
pass total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 20:01:30 +02:00
Martino FerrariandClaude Opus 4.6 c89decef8e docs: say that HrtRateFit::toSeconds returns producer-epoch, not wall, seconds
The fit keeps the slope and discards the intercept, so the result counts from
the producer's boot. Tasks 4 and 7 compose it with ClockOffset::map, which is
correct, but the bare name invites passing it straight to a plot axis.

Also unwrapped the stalled-clock assertion from behind `if (fit.ready())` —
that branch never runs, so the test confirmed nothing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 19:55:24 +02:00
Martino FerrariandClaude Opus 4.6 e4817dd284 fix(udpscope): keep the clock-offset recalibration threshold symmetric
The jitter test fed a receive timestamp that went backwards (1001.02 then
1000.97), putting the second reading 1.03 s from the prediction — twice the
threshold, so not jitter under any reading. That is a digit slip for 1001.97.

It had been worked around by making the threshold one-sided, which passes the
test but never fires when the producer's clock steps forward: the prediction
stays ahead of the wall clock, the error stays negative, and the trace sits in
the future for the rest of the run. Restored std::fabs, corrected the test data,
and added the forward-jump case that the one-sided version silently failed.

Plan amended so the bad data does not come back.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 19:52:41 +02:00
Martino FerrariandClaude Sonnet 4.6 41ab151a2f feat(udpscope): producer-clock calibration and hrt tick-rate fit
Adds TimeBase.h/cpp with ClockOffset (latched wall-clock offset with
one-sided recalibration on positive drift only, so early-arriving packets
do not wobble the trace) and HrtRateFit (sliding-window OLS that recovers
an unknown hrt tick rate from receive timestamps). Also adds
TimeSignalScale() which maps UDPS type codes to seconds-per-count.
9 new tests, all 28 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 19:50:24 +02:00
Martino FerrariandClaude Opus 4.6 ea9689591d test: add coverage for closeLeaf's two untested branches
Gap 1: closeLeaf was only tested closing the first sibling; added test
closing the second sibling to cover the else branch of parent->a.get()==leaf.
The test verifies the correct sibling survives with its signal intact.

Gap 2: closeLeaf was only tested at depth 1 (root's direct children);
added test with depth-2 leaf (in right subtree) to exercise findParent's
recursive search in both subtrees. Tests that the correct leaf is promoted
and remaining signals are preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 19:46:29 +02:00
Martino FerrariandClaude Sonnet 4.6 0e5d103e73 feat(udpscope): BSP pane tree with split, close and hit-testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 19:42:09 +02:00
Martino FerrariandClaude Opus 4.6 c1029a25df fix(udpscope): make the time-order test actually exercise the swap, guard the font copy
The ramp data in EmitsPointsInTimeOrder never produced a bucket whose maximum
preceded its minimum, so an implementation ordering the emitted pair by value
instead of by time would have passed. Replaced with an explicit two-bucket case
whose second bucket reverses the order.

file(COPY) is a hard configure error on a missing source, so a checkout without
the sibling StreamHub resources failed to configure despite the ASCII-icon
fallback the block above had just selected.

Plan amended to match on both points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 19:39:14 +02:00
Martino FerrariandClaude Sonnet 4.6 fba4360c80 feat(udpscope): build scaffold and min/max envelope decimation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 17:27:41 +02:00
Martino FerrariandClaude Opus 4.6 2d62e1808b docs: fix six cross-task defects found in the UDPScope plan pre-flight
Each of these would have surfaced as a compile/link failure or a reviewer
rejection mid-execution, when the implementer holding the task has no view of
the neighbouring task that contradicts it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 17:07:08 +02:00
Martino FerrariandClaude Opus 4.6 cf815e1d3f docs: implementation plan for the UDPScope direct-UDPS oscilloscope
Eighteen TDD tasks covering the build scaffold, pane tree, time base, frame
decoding, trigger FSM, threading, UI, persistence and export, so the scope can
be built task-by-task with a reviewable deliverable at each step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 16:35:41 +02:00
Martino FerrariandClaude Opus 4.6 52958cf8bc docs: design for UDPScope, a direct-to-streamer ImGui oscilloscope
A bench scope that attaches straight to one UDPStreamer through the
standalone C client, so it can be dropped on a machine with no StreamHub,
no Go and no browser. Records the decisions that are easy to get wrong:
the time-base rules must follow UDPSourceSession rather than the C
library's arrival-time estimate, decimation must be min/max rather than
LTTB so glitches survive, and the ring must be sized past the trigger
window by an explicit harvest margin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 11:28:54 +02:00
Martino FerrariandClaude Opus 4.6 7c5eb31a52 feat: standalone C/C++ UDPS client library
Consuming a UDPStreamer feed so far meant either linking MARTe2 (UDPSClient)
or writing Go (Common/Client/go/udpsprotocol). Common/Client/c fills the gap
for plain C/C++ integrators: two files depending on nothing but libc and BSD
sockets, covering the whole receive path — CONNECT, fragment reassembly,
CONFIG/DATA decoding with dequantisation, keepalives and silence-triggered
reconnect. No threads are spawned; udps_client_poll() does all the work and
runs every callback, so it drops into an existing event loop unsynchronised.

Verified against run_udp_producer.sh at 1 Msps: unicast (120 MiB, no loss) and
multicast with 12-fragment cycles (116k datagrams, no loss).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-22 16:51:40 +02:00
Martino Ferrari e03c60db25 Implemented and fixed many issues 2026-08-21 23:24:48 +02:00
Martino FerrariandClaude Sonnet 4.6 14d5351a81 fix: UDPSClient uses two-arg Join so multicast receiver lands on the right interface
UDPSClient::ConnectMulticast was calling the single-arg BasicUDPSocket::Join,
which forwards NULL as the local interface and lets the kernel bind to
INADDR_ANY.  On a multi-homed host (or when the server sends on loopback via
Interface = "127.0.0.1") the client joins the wrong interface and silently
receives nothing.

Fix: read the optional Interface key inside the useMulticast block in
UDPSClient::Initialise; in ConnectMulticast call the two-arg
Join(group, interface) when Interface is set, and fall back to the one-arg
call otherwise to preserve the existing INADDR_ANY behaviour for configs that
omit it.

Forward the new optional Interface key through UDPStreamerClient (read from
DataSource config, written into the UDPSClient ConfigurationDatabase only
when non-empty).  Extend the "Joined multicast group" log to report the
interface name or "default".

Regression test TestExecute_MulticastReceivesDataOnInterface: mock TCP
control listener + multicast UDP DATA socket with IP_MULTICAST_IF set to
127.0.0.1, verifying a uint32 value of 424242 reaches DataSource signal
memory.  Confirmed FAILED without the Join fix and PASSED with it.

Suite: 133/133 (was 132/132 before this commit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 08:23:41 +02:00
Martino FerrariandClaude Opus 4.6 61a2aa3988 docs: Interface takes an interface IP, not an interface name
UDPSServer parses Interface with inet_addr() to set IP_MULTICAST_IF, so
a name like "eth0" yields INADDR_NONE and Initialise fails. Every
example in UDPStreamer.md used "eth0", so anyone following the docs hit
that failure; the .cfg files in Test/ already used a dotted-quad.

Replace the examples with 192.168.1.10, state the dotted-quad
requirement in the parameter table and the Multicast section, and note
that receivers must join on the matching interface or they silently
receive nothing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-17 08:09:21 +02:00
Martino FerrariandClaude Opus 4.6 c827ecefff test: fix UDPStreamer multicast tests broken by mandatory Interface
Commit 3e0a481 made Interface mandatory for multicast in both
UDPStreamer::Initialise and UDPSServer::Initialise and updated
Docs/UDPStreamer.md, but left the GTest configs untouched. All four
multicast tests have failed since.

Add Interface to the five multicast configs. The field is parsed with
inet_addr(), so it takes a dotted-quad, not an interface name; 127.0.0.1
keeps the tests self-contained and off the LAN.

TestInitialise_MulticastMode_InvalidDataPort was passing for the wrong
reason: UDPStreamer.cpp rejected it on the missing Interface before the
DataPort == Port check could run. It now proves what its name claims.

TestExecute_MulticastConnectDataDisconnect additionally needed the
two-argument Join(): the single-argument form passes INADDR_ANY, so the
reader joined the default-route interface while the server sent on
loopback via IP_MULTICAST_IF, and the DATA datagram never arrived.

GTest: 132/132 (was 128/132), multicast subset stable over 3 runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-17 08:06:35 +02:00
Martino FerrariandClaude Opus 4.6 72c286db33 chore: untrack SDD scratch reports
These subagent handoff artifacts were committed before
.superpowers/sdd/.gitignore took effect. They are ephemeral
scratch, not project content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-17 08:01:09 +02:00
Martino Ferrari 7aba1260be Merge branch 'feature/signal-calibration-config'
Per-signal data calibration (value = raw * scale + offset) with an optional
unit override, plus sources+calibration persistence in a single config file,
implemented identically in the Go hub and the C++ StreamHub.
2026-08-17 08:00:09 +02:00
Martino FerrariandClaude Sonnet 4.6 1f8592f854 fix: address all 9 findings from final calibration code review
- calibration.js: fix baseSignalName('[0]') parity with Go/C++ (>= 0 not > 0)
- calibration.test.js: add assertions for '[0]' edge case in two existing tests
- app.js: remove stale typeof guard around refreshVScaleMenu (always defined)
- app.js: call refreshTrigThresholdField on trig-signal change (both assignment sites)
- index.html: drop maxlength='16' on unit input; normaliseCal is the sole enforcer
- configcheck/main.go: delete dead nextOneOf function (no callers)
- hub_calibration_test.go: delete orphaned waitBroadcast comment (function never existed)
- calibration.go: correct arrayIndexSuffix comment to document known Go/C++ difference
- Docs/StreamHub-API.md: add calibration entry count and unit byte limits to §5 table
- spec: fix configReloaded missing path field, '16 chars'→'16 UTF-8 bytes', StreamString→char[], chain scenario→configcheck program, four→five new frames

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 07:57:28 +02:00
Martino FerrariandClaude Sonnet 4.6 42f5a726af fix: three StreamHub C++ defects from final code review
Finding 1 (HandleReloadConfig data loss): move ClearCalibration inside
LoadSourcesFile so the table is wiped only after a successful fread.
Adds a clearCalibration bool parameter (default false); the reload path
passes true, the startup path passes false.

Finding 2 (JSON injection via unit/source/signal): add JsonEscape()
static helper (escapes \", \\, \n \r \t, and \u00XX for other control
chars). Applied at all three emission sites: BroadcastCalibration,
HandleSaveSources, and BroadcastSources (label). Teach JsonGetString to
unescape the same set on read, so values round-trip correctly.

Finding 3 (%.17g verbosity): add ShortFloat() static helper that tries
%.15g then %.16g then %.17g, stopping at the first precision whose
strtod() output compares equal to the original. Applied at both float
emission sites. 0.1 now prints as "0.1", not "0.10000000000000001".

Minor: fix two inaccurate comments in StreamHub.h — the CalibrationEntry
rationale (not a 133 MB / address-limit issue; the real reason is no
per-entry heap churn, STL-free, trivially copyable) and "chars" to "bytes"
for the unit cap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 07:50:01 +02:00
Martino FerrariandClaude Sonnet 4.6 686fc2ce7d docs: fix three review findings in calibration documentation
- Finding 1 (Critical): replace "16 chars" with "16 UTF-8 bytes" in the
  setCalibration unit field description (StreamHub-API.md) and the Cal·Unit
  toolbar row (WebUI.md); note that multi-byte characters consume more than
  one byte and that truncation never splits a character.

- Finding 2 (Important): correct the claim that a sources broadcast after
  reloadConfig is conditional on new sources being added — that is true only
  of the Go hub. The C++ hub calls BroadcastSources() unconditionally on
  success. Both the reloadConfig command description and the configReloaded
  event description in StreamHub-API.md are updated; the Reload bullet in
  WebUI.md is updated with a brief note. Clients must tolerate an unsolicited
  sources frame after any reload.

- Finding 3 (Minor): the configSaved failure example used "no SourcesFile
  configured", which matches neither hub. Corrected to the C++ form
  "no sources file configured" and added a note that the exact error text
  is not part of the protocol contract (Go uses "no sources-file configured").

Source evidence: calibration.go (maxUnitLen, len(), rune-repair loop),
StreamHub.cpp (kMaxUnitLen, byte strncpy, HandleReloadConfig unconditional
BroadcastSources, HandleSaveSources error string).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 07:34:48 +02:00
Martino FerrariandClaude Sonnet 4.6 d26b78b7f6 docs: document per-signal calibration and config save/reload
Update Docs/StreamHub-API.md (new setCalibration/reloadConfig commands,
calibration/configSaved/configReloaded events, §4 config file format),
ARCHITECTURE.md §6 (updated command/event tables and Config File Format
subsection), Docs/WebUI.md (Cal row in V-Scale Toolbar, Sources & Config
sidebar section). Also corrects the spec's Validation sentence to match
the shipped cal-invalid border behaviour instead of silent field revert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 07:27:25 +02:00
Martino FerrariandClaude Sonnet 4.6 e37eec0276 webui: add the Sources & Config sidebar section
Renames the "Add Source" section to "Sources & Config" and replaces the
fire-and-forget "Save list" button with Save and Reload, plus a one-line
status area that renders the hub's configSaved/configReloaded ack.

onConfigAck replaces the no-op stub Task 7 left behind. It branches on the
frame type because configSaved carries a path and configReloaded does not,
and surfaces the hub's error text on failure rather than failing silently.
The status is kept outside the DOM because buildSidebar() recreates this
section on every sources broadcast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 07:23:08 +02:00
Martino FerrariandClaude Sonnet 4.6 5917ab1bf2 Restore calibration parity: revert unit-stripping from normaliseCal, fix CSV quoting at export
Finding 1: revert the comma/quote strip added in 3cb998c from
normaliseCal() in calibration.js. The strip broke byte-identical parity
with Go CalConfig.Normalise and C++ StreamHub::SetCalibrationEntry, both
of which only trim whitespace and cap at 16 UTF-8 bytes. Delete the
companion test that asserted the now-removed behaviour (suite returns to
18 tests).

Finding 2: fix the actual CSV-safety problem at the point of use in
exportAllCSV() in app.js. Header cells (time column and signal columns)
are now RFC 4180-quoted: wrapped in double quotes with any embedded
double quote doubled. This safely handles units or signal names that
contain commas or quotes without touching normaliseCal.

Finding 3: update two stale comments in app.js that called the trigger
threshold or rawFromNorm result 'raw' — Task 9 moved trig.threshold into
calibrated units throughout, so the comments now say 'calibrated'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 01:15:13 +02:00
Martino FerrariandClaude Sonnet 4.6 3cb998c5da webui: calibrate CSV export, trigger threshold and unit display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 00:55:07 +02:00
Martino FerrariandClaude Sonnet 4.6 4908c039a5 fix(webui): keep cal-invalid styling on focused field during hub broadcast
refreshVScaleMenu() was unconditionally removing the cal-invalid class
from all three calibration inputs, which silently cleared the red-border
error indicator on a field the user was editing whenever a hub
calibration broadcast arrived. Apply the same focused-element guard
already used for .value writes so a rejected value's error styling
persists until the user corrects it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 00:52:04 +02:00
Martino FerrariandClaude Sonnet 4.6 4e3a90b2c6 webui: add calibration editor to the V-Scale toolbar
Adds the Cal row (Scale / Offset / Unit / Reset) to #vscale-menu, its
CSS, and the refreshVScaleMenu() / commitCal() logic that wires it to
calTable and the hub via setCalibrationWS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 00:48:39 +02:00
Martino Ferrari 0e9ec61226 webui: apply per-signal calibration to the whole display path 2026-08-17 00:43:18 +02:00
Martino FerrariandClaude Opus 4.6 7312dd0ca0 docs(calibration.js): explain why one UTF-8 repair pass suffices in JS
TextEncoder always emits well-formed UTF-8, so truncation can strand at
most a lead byte plus three continuations — one repair pass covers it.
The C++ hub needs a loop because its input is raw bytes off the wire.
Also drops a dead variable from the byte-boundary test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-17 00:41:00 +02:00
Martino FerrariandClaude Opus 4.6 48d62c1f80 fix(calibration.js): cap unit at 16 UTF-8 bytes, matching both hubs
normaliseCal was using String.length/.slice() (UTF-16 code units), so
multi-byte characters like °, Ω, µ could slip through oversized. Now uses
TextEncoder to slice at 16 bytes, then repairs any incomplete trailing
UTF-8 sequence by walking back over continuation bytes to find the lead
byte and dropping the incomplete rune — exactly mirroring Go's
utf8.DecodeLastRuneInString loop and the C++ walk-back in
StreamHub::SetCalibrationEntry. Adds 4 new test cases covering the
non-ASCII/boundary scenarios, and corrects the canonical test command in
the task-6 report to 'cd Client/udpstreamer && node --test'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-17 00:36:01 +02:00
Martino FerrariandClaude Sonnet 4.6 21d084d2ea webui: add pure calibration module with unit tests
Implements Calib JS module (calibration.js) with affine transform primitives,
CalTable, and normaliseCal matching Go CalConfig.Normalise semantics; 14 node
--test cases all pass. Loads before app.js in index.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 00:29:53 +02:00
Martino FerrariandClaude Sonnet 4.6 b1c2a34eea test(configcheck): harden frame-strictness, sort-order, and document BroadcastSources difference
Fix round 1 review findings:

1. Frame-matching strategy: add protocolFrameTypes set; next() now fails immediately
   on an out-of-order protocol frame instead of silently discarding it. Ambient
   frames (data, stats, triggerState, monotonicState) are logged and skipped.
   nextSkipping() accepts explicitly-listed protocol frames that legitimately differ
   in order across hubs (connect-time "sources" before "calibration" in C++).

2. BroadcastSources documented exception: the extra "sources" frame C++ emits after
   reload is accepted and logged as [KNOWN DIFFERENCE] with full rationale; the
   report recommendation to remove it has been retracted (it is required for correct
   client-side source-list updates after reload-with-new-sources).

3. Sort-order constraint exercised: three calibration entries submitted in
   reverse-sort order (src2/Beta, src1/Zeta, src1/Alpha); each broadcast and the
   saved config file are asserted to be sorted by source then signal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 00:26:12 +02:00
Martino FerrariandClaude Sonnet 4.6 73ba725d8f test: add cross-hub calibration and config-persistence parity checker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 00:14:15 +02:00
Martino FerrariandClaude Sonnet 4.6 bdc74f5fd2 StreamHub: loop UTF-8 tail repair to match Go CalConfig.Normalise()
The single-pass repair left invalid bytes when the candidate lead byte
had class 0 (illegal 0xF8-0xFF bytes, or a bare continuation byte
reached after the 3-byte backward-scan cap). Convert to a loop with a
`cut` flag mirroring Go's loop: each iteration either makes no cut
(exits) or strictly reduces ulen by >= 1 byte (terminates in <= 16
iterations). Also treat expected==0 as a cut target, matching Go's
behaviour of stripping any byte that decodes as an invalid one-byte
sequence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 00:06:45 +02:00
Martino FerrariandClaude Sonnet 4.6 93e00d0c21 fix(StreamHub): correct UTF-8 tail repair to run only after truncation
The previous walk-back in SetCalibrationEntry was unconditional, corrupting
short valid units ending in multi-byte characters (e.g. Omega, mu, degree).
Also failed to drop an orphaned lead byte left after stripping continuation
bytes. Restructured to use a 256-byte staging buffer so truncation can be
detected, then repair runs only in the truncation branch. Algorithm now
matches Go CalConfig.Normalise() exactly: scan back over continuation bytes
(up to 3), find the lead byte, derive expected sequence length, cut if
incomplete. Covers all cases: orphaned continuation, orphaned lead, cut on
lead byte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 00:02:33 +02:00
Martino FerrariandClaude Sonnet 4.6 2f9b135c62 task-4-report: append Fix round 1 section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 23:55:49 +02:00
Martino FerrariandClaude Sonnet 4.6 957be793ae StreamHub: fix calibration trim, UTF-8 unit truncation, and sort order
Finding 1: Add TrimInPlace helper; apply trim→strip-[i]→empty-check order
in SetCalibrationEntry (matching Go Normalise()), so whitespace-padded
source/signal from WS clients normalise identically to config-file loads.

Finding 2: Trim unit before truncating to kMaxUnitLen, then walk back
continuation bytes (0x80-0xBF) to avoid leaving a partial UTF-8 rune,
matching Go's utf8.DecodeLastRuneInString loop.

Finding 3: BroadcastCalibration and HandleSaveSources now emit entries
sorted by source then signal (insertion sort over an index array, no STL),
producing byte-identical calibration frames and config files to the Go hub.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 23:55:46 +02:00
Martino FerrariandClaude Sonnet 4.6 cdafb877a3 StreamHub: per-signal calibration, config reload, whitespace-tolerant JSON
- Add CalibrationEntry (heap-allocated array[256], char[] fields to stay within
  the 133 MB struct's canonical address limit) plus calibrationMutex_ and
  numCalibration_.
- Implement SetCalibrationEntry/ClearCalibration, BroadcastCalibration,
  BroadcastConfigAck, HandleSetCalibration, HandleReloadConfig.
- Wire setCalibration and reloadConfig into OnWSCommand dispatch.
- Broadcast calibration to each newly connected client after triggerState.
- Fix JSON round-trip bug: replace JsonGetString/JsonGetBool helpers with a
  shared whitespace-tolerant JsonFindValue (tolerates "key" : "value" as
  written by HandleSaveSources); add JsonIsFinite (no <cmath>).
- Extend LoadSourcesFile(bool skipActive) to also parse calibration blocks;
  SourceIsActive checks live sessions before starting a duplicate.
- Extend HandleSaveSources to persist calibration blocks; emit configSaved ack.
- Reload semantics: calibration replaced wholesale, sources added only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 19:56:44 +02:00
Martino FerrariandClaude Sonnet 4.6 ffe7cb1cc5 wshub: add setCalibration/reloadConfig frames and config acks
Wire five new WebSocket frames into hub.go: setCalibration (client→hub),
calibration (hub→client broadcast), reloadConfig (client→hub), configSaved and
configReloaded (hub→client acks with ok/path/error). Extend hubCmd with cal
field, add buildCalibrationMsg and buildConfigAckMsg builders, send calibration
on client connect, and run Save/Reload on separate goroutines to avoid blocking
the Run() select loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 19:36:09 +02:00
Martino FerrariandClaude Sonnet 4.6 dfd257cfd9 wshub: persist calibration alongside sources; add config reload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 19:27:23 +02:00
Martino FerrariandClaude Sonnet 4.6 66efd74dd5 wshub: fix CalConfig.Normalise parity gaps vs C++ twin and JS client
Finding 1: strip trailing [digits] array-element suffix from Signal so one
calibration entry covers an entire array signal, matching the C++ strchr
truncation and the JS equivalent.

Finding 2: after the 16-byte Unit truncation, drop any trailing partial UTF-8
rune so json.Marshal never emits replacement characters; keeps byte limit in
sync with C++ strncpy(u, unit, kMaxUnitLen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 19:21:41 +02:00
Martino Ferrari 47f1567a26 wshub: add per-signal calibration store and flat config-file codec 2026-08-16 19:16:22 +02:00
Martino Ferrari 6d26e8191c docs: implementation plan for per-signal calibration and persistent hub config 2026-08-16 19:14:34 +02:00
Martino FerrariandClaude Opus 4.6 5ab1721df5 docs: spec per-signal calibration and persistent hub config
Design for a per-signal affine calibration (scale/offset/unit) applied
client-side and stored server-side alongside the source list, so signals in
raw units can be read in engineering units and the setup survives a reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-16 18:17:07 +02:00
Martino Ferrari 2370848994 added trigger 2026-08-13 10:28:56 +02:00
Martino Ferrari ff5ad22447 included jitter correction on client 2026-08-13 10:28:43 +02:00