32 Commits
Author SHA1 Message Date
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
71 changed files with 14845 additions and 870 deletions
+6 -3
View File
@@ -58,7 +58,7 @@ UDP 8081 telemetry, TcpLogger 8082 (REPORT_ERROR → "LOG <LEVEL> <desc>" lines)
|---|---| |---|---|
| `Source/Components/DataSources/UDPStreamer/` | Output DataSource; UDP I/O on bg thread, RT thread only spinlock+memcpy in `Synchronise()` | | `Source/Components/DataSources/UDPStreamer/` | Output DataSource; UDP I/O on bg thread, RT thread only spinlock+memcpy in `Synchronise()` |
| `Source/Components/DataSources/UDPStreamerClient/` | Input DataSource (shared `UDPSClient`), double-buffered ready/scratch | | `Source/Components/DataSources/UDPStreamerClient/` | Input DataSource (shared `UDPSClient`), double-buffered ready/scratch |
| `Source/Components/GAMs/` | `SineArrayGAM` (float32 sine, continuous phase), `TimeArrayGAM` (us-timer → per-sample timestamp array) | | `Source/Components/GAMs/` | `SineArrayGAM` (float32 sine, continuous phase), `TimeArrayGAM` (us-timer → per-sample timestamp array; `Anchor = FirstSample|LastSample|Continuous`, use `Continuous` for contiguous sources so a lost RT cycle cannot hole the time base) |
| `Source/Components/Interfaces/DebugService/` | Registry patching, `DebugBrokerWrapper.h`, TCP/UDP services | | `Source/Components/Interfaces/DebugService/` | Registry patching, `DebugBrokerWrapper.h`, TCP/UDP services |
| `Source/Components/Interfaces/TCPLogger/` | `LoggerConsumerI` forwarding `REPORT_ERROR` to ≤8 TCP clients | | `Source/Components/Interfaces/TCPLogger/` | `LoggerConsumerI` forwarding `REPORT_ERROR` to ≤8 TCP clients |
| `Source/Components/Interfaces/UDPStream/` | Plain-C++ helpers (not MARTe2 Objects): `UDPSClient` (auto-reconnect + fragment reassembly), `UDPSServer` (not thread-safe — owner's Execute thread only) | | `Source/Components/Interfaces/UDPStream/` | Plain-C++ helpers (not MARTe2 Objects): `UDPSClient` (auto-reconnect + fragment reassembly), `UDPSServer` (not thread-safe — owner's Execute thread only) |
@@ -140,8 +140,11 @@ cd Client/streamhub-qt && cmake -B build && cmake --build build
with long options: `--host HOST --port 8090` (single-dash misparsed). Single with long options: `--host HOST --port 8090` (single-dash misparsed). Single
GUI thread, 60 Hz QTimer repaint. GUI thread, 60 Hz QTimer repaint.
- **StreamHub config** is *not* a MARTe2 `RealTimeApplication`: `Hub = { WSPort - **StreamHub config** is *not* a MARTe2 `RealTimeApplication`: `Hub = { WSPort
MaxPoints PushRate MaxPushPoints RingTemporal RingScalar +Recorder{...} MaxPoints PushRate MaxPushPoints RingTemporal RingScalar RingMaxMB AllowedOrigins
Sources={id={Label Addr Port}} }`. `+History` keys: `Directory` (required), +Recorder{...} Sources={id={Label Addr Port}} }`. `AllowedOrigins` is the
WebSocket Origin allowlist — without it a browser serving the SPA from a
different port than the hub is rejected 403.
`+History` keys: `Directory` (required),
`DurationHours` (1), `Decimation` (1), `FlushIntervalSec` (5), `DurationHours` (1), `Decimation` (1), `FlushIntervalSec` (5),
`MinDiskFreeMB` (500). `.shist` files: 64-byte header ('SHR1') + circular `MinDiskFreeMB` (500). `.shist` files: 64-byte header ('SHR1') + circular
(t,v) float64 pairs. (t,v) float64 pairs.
+64 -4
View File
@@ -314,7 +314,7 @@ Hub-side trigger with the web client's semantics (config: signal key
``` ```
IDLE →[arm]→ ARMED IDLE →[arm]→ ARMED
ARMED →[edge crossing]→ COLLECTING (latches trigTime, pre/postSec) ARMED →[edge crossing]→ COLLECTING (latches trigTime, pre/postSec)
COLLECTING →[post window + margin elapsed]→ TRIGGERED (broadcast binary v2 capture) COLLECTING →[every source produced past the window]→ TRIGGERED (broadcast binary v2 capture)
TRIGGERED →[auto-rearm (normal, ~200 ms) | rearm (single)]→ ARMED TRIGGERED →[auto-rearm (normal, ~200 ms) | rearm (single)]→ ARMED
any →[disarm]→ IDLE any →[disarm]→ IDLE
``` ```
@@ -325,6 +325,30 @@ sample of the configured signal. The capture is assembled in the push loop from
LTTB-capped at 20 000 points/signal, and broadcast as a binary version-2 frame. LTTB-capped at 20 000 points/signal, and broadcast as a binary version-2 frame.
A `stopped` flag (`trigStop`) freezes auto-rearm. A `stopped` flag (`trigStop`) freezes auto-rearm.
COLLECTING is left on the **data's** clock, not `clock_gettime()`: `trigTime`
comes from sample timestamps, and a source that free-runs on its own clock sits
seconds away from wall time, so a wall-clock deadline chops exactly that offset
off every capture's tail. `UDPSourceSession::ProducerNewestTime()` reports how
far a source has produced — counting only signals actually timestamped from a
time signal, since PACKET-timed ones (the time array itself included) are
stamped on arrival and would just report "now".
Sources are harvested **one at a time**, each as soon as *it* passes
`trigTime + postSec + 0.15 s` (`BeginTriggerCapture` / `HarvestTriggerCapture` /
`FinishTriggerCapture`, the frame accumulating across push ticks). Making every
source wait for the slowest lets the leaders' rings roll past the pre-trigger
region before it is ever read. A 2 s wall-clock watchdog per capture bounds the
wait for a source that stopped advancing; it is harvested short, with a warning
naming the source and how far it got.
Because `RingTemporal` only holds ~1 s at 1 MSps, `setTrigger` publishes the
requested window and the push loop calls `GrowRingsForTrigger()`: each ring
measures its own rate (`Count() / TimeSpan()` — UDPS sources usually report
`samplingRate = 0`) and is grown in place to `rate × (window + 0.5 s) × 1.2`,
clamped per signal to `RingMaxMB`. `SignalRingBuffer::Grow()` preserves
contents *and* `totalWritten`, so live push cursors stay valid. Without this a
long window only ever captures its tail.
### Configuration File (MARTe2 cfg format) ### Configuration File (MARTe2 cfg format)
``` ```
@@ -334,9 +358,11 @@ Hub = {
PushRate = 30 // push loop Hz PushRate = 30 // push loop Hz
MaxPushPoints = 50 // LTTB cap per signal per tick MaxPushPoints = 50 // LTTB cap per signal per tick
StatsRate = 1 // stats broadcast Hz StatsRate = 1 // stats broadcast Hz
RingTemporal = 1000000 // ring capacity (points) for multi-element signals RingTemporal = 1000000 // initial ring capacity (points) for multi-element signals
RingScalar = 100000 // ring capacity (points) for scalar signals RingScalar = 100000 // ring capacity (points) for scalar signals
RingMaxMB = 128 // per-signal ceiling when a trigger window grows a ring
SourcesFile = "streamhub_sources.json" // dynamic-source persistence SourcesFile = "streamhub_sources.json" // dynamic-source persistence
AllowedOrigins = "http://127.0.0.1:8099,http://localhost:8099" // see below
Sources = { Sources = {
App1 = { App1 = {
Label = "MARTe2 App 1" Label = "MARTe2 App 1"
@@ -358,6 +384,16 @@ Sources added at runtime (`addSource`) get generated ids `s1, s2, …`;
`saveSources` persists them to `SourcesFile` (JSON array of `saveSources` persists them to `SourcesFile` (JSON array of
`{label, addr, multicastGroup?, dataPort?}`), reloaded at start-up. `{label, addr, multicastGroup?, dataPort?}`), reloaded at start-up.
`AllowedOrigins` is a comma/space-separated allowlist of `scheme://host[:port]`
values accepted in the WebSocket `Origin` header (max 8 entries, 128 chars
each), matching the Go hub's option. Without it the handshake only accepts an
`Origin` whose host matches the request `Host` — so a browser that loaded the
SPA from a *different* port than the hub (the `run_streamhub.sh` layout, SPA on
8099 and hub on 8090) is rejected with 403. Non-browser clients send no `Origin`
and are unaffected. This is the CSWSH guard of RFC 6455 §10.2: browsers attach
cookies to cross-origin WebSocket handshakes, so `Origin` is the only thing
distinguishing a legitimate page from an attacker's.
### Build ### Build
```bash ```bash
@@ -380,7 +416,9 @@ binary frames carry data push payloads.
| `ping` | — | Hub replies `{"type":"pong"}` | | `ping` | — | Hub replies `{"type":"pong"}` |
| `addSource` | `label`, `addr` (`"host:port"`), `multicastGroup?`, `dataPort?` | Connect to a new UDPS source; hub assigns id `s1, s2, …` | | `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 | | `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 | | `getSources` | — | Trigger `sources` broadcast |
| `getConfig` | `sourceId` | Trigger `config` broadcast for one source | | `getConfig` | `sourceId` | Trigger `config` broadcast for one source |
| `getStats` | — | Trigger `stats` broadcast | | `getStats` | — | Trigger `stats` broadcast |
@@ -399,11 +437,33 @@ binary frames carry data push payloads.
| `sources` | `sources:[{id, label, addr:"host:port", state}]` | On connect; after add/remove/getSources; on first CONFIG | | `sources` | `sources:[{id, label, addr:"host:port", state}]` | On connect; after add/remove/getSources; on first CONFIG |
| `config` | `sourceId`, `publishMode`, `signals:[{name, typeCode, quantType, numDimensions, numRows, numCols, rangeMin, rangeMax, timeMode, samplingRate, timeSignalIdx, unit}]` | After CONFIG received from source | | `config` | `sourceId`, `publishMode`, `signals:[{name, typeCode, quantType, numDimensions, numRows, numCols, rangeMin, rangeMax, timeMode, samplingRate, timeSignalIdx, unit}]` | After CONFIG received from source |
| `stats` | `sources:{id:{state, totalReceived, totalLost, rateHz, rateStdHz, fragsPerCycle, bytesPerCycle, cycleAvgMs, cycleStdMs, cycleMinMs, cycleMaxMs, cycleHistMin, cycleHistMax, cycleHist:[20]}}` | At `StatsRate` Hz | | `stats` | `sources:{id:{state, totalReceived, totalLost, rateHz, rateStdHz, fragsPerCycle, bytesPerCycle, cycleAvgMs, cycleStdMs, cycleMinMs, cycleMaxMs, cycleHistMin, cycleHistMax, cycleHist:[20]}}` | At `StatsRate` Hz |
| `triggerState` | `state` (`"idle"`\|`"armed"`\|`"collecting"`\|`"triggered"`), `mode`, `stopped`, `trigTime?` | On any trigger FSM transition | | `triggerState` | `state` (`"idle"`\|`"armed"`\|`"collecting"`\|`"triggered"`), `mode`, `stopped`, `trigTime?`, `preSec?`, `postSec?` | On any trigger FSM transition |
| `zoom` | `reqId`, `signals:{"src:sig":{t:[…], v:[…]}}` (`t` printed `%.17g`, `v` `%.9g`) | Unicast reply to `zoom` | | `zoom` | `reqId`, `signals:{"src:sig":{t:[…], v:[…]}}` (`t` printed `%.17g`, `v` `%.9g`) | Unicast reply to `zoom` |
| `maxPointsUpdated` | `maxPoints` | After ring buffer resize | | `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` | | `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) ### Binary Push Frame (version 1, hub → client, binary WS frame)
Little-endian throughout. Sent at `PushRate` Hz per source; contains **only Little-endian throughout. Sent at `PushRate` Hz per source; contains **only
+9 -1
View File
@@ -160,7 +160,15 @@ void Hub::onTriggerState(const std::string& json) {
trigger_.trigTime = msg.trigTime; trigger_.trigTime = msg.trigTime;
trigger_.hasTrigTime = true; trigger_.hasTrigTime = true;
} }
if (msg.state == "idle") { trigger_.hasTrigTime = false; } if (msg.hasWindow) {
trigger_.firedPreS = msg.preSec;
trigger_.firedPostS = msg.postSec;
trigger_.hasFiredWin = true;
}
if (msg.state == "idle") {
trigger_.hasTrigTime = false;
trigger_.hasFiredWin = false;
}
Q_EMIT triggerStateChanged(); Q_EMIT triggerStateChanged();
} }
+5
View File
@@ -60,6 +60,11 @@ struct TriggerCfgState {
bool stopped = false; bool stopped = false;
bool hasTrigTime = false; bool hasTrigTime = false;
double trigTime = 0.0; double trigTime = 0.0;
/* Window the hub latched at fire time. Not the same as windowSec/prePercent
* above, which are editable and may have moved on since the trigger fired. */
bool hasFiredWin = false;
double firedPreS = 0.0;
double firedPostS = 0.0;
}; };
/** Per-signal vertical scale state (oscilloscope style). */ /** Per-signal vertical scale state (oscilloscope style). */
+233 -77
View File
@@ -78,6 +78,49 @@ static double normalizeY(double raw, const VScale& vs) {
return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos; return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos;
} }
/* Resolve the one scale every trace shares in unified mode: same rules as the
* per-signal version applied to the union of the plot — range takes the union
* of the declared ranges, auto fits the union of the data. */
static void resolveUnifiedVScale(VScale& vs,
const std::vector<PlotAssignment>& slots,
const std::vector<Source>& sources,
const std::vector<std::vector<double> >& vStore) {
if (vs.mode == 2) {
vs.resolvedDiv = std::max(vs.divValue, 1e-30);
vs.resolvedOffset = vs.offset;
return;
}
double mn = 1e300, mx = -1e300;
if (vs.mode == 1) {
for (const auto& a : slots) {
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
if (a.signalIdx < 0 ||
a.signalIdx >= (int)sources[a.sourceIdx].signals.size()) continue;
const auto& m = sources[a.sourceIdx].signals[a.signalIdx].meta;
if (!(m.rangeMax > m.rangeMin)) continue;
if (m.rangeMin < mn) mn = m.rangeMin;
if (m.rangeMax > mx) mx = m.rangeMax;
}
if (mx > mn) {
vs.resolvedDiv = std::max((mx - mn) / 8.0, 1e-30);
vs.resolvedOffset = (mn + mx) / 2.0;
return;
}
mn = 1e300; mx = -1e300; /* no usable range: fall through to auto */
}
for (const auto& vv : vStore) {
for (double v : vv) {
if (!std::isfinite(v)) continue;
if (v < mn) mn = v;
if (v > mx) mx = v;
}
}
if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30);
vs.resolvedOffset = (mx + mn) / 2.0;
}
static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) { static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) {
mn = 1e300; mx = -1e300; mn = 1e300; mx = -1e300;
for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } } for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } }
@@ -189,6 +232,50 @@ void PlotCanvas::drawMarker(QPainter& p, double cx, double cy, int marker, doubl
} }
} }
/** @brief What the plot renders on the trigger-relative axis, if anything. */
struct TrigView {
bool rel = false; /* render against t - trig instead of wall clock */
bool fromCap = false; /* data comes from the capture frame, not the ring */
double trigT = 0.0;
double preS = 0.0;
double postS = 0.0;
};
/* Two ways to end up in trigger-relative time. Either a v2 capture frame has
* arrived, or a trigger has fired and its window is still filling. In the
* second case the hub sends nothing until the whole window has been produced —
* several seconds for a long window at a high rate — so the trace is drawn from
* the local rings onto the final axis, growing left to right. Filling wins
* over the last capture: once a new trigger fires the old waveform is history.
* A capture latches its own pre/post at fire time, so later edits in the
* trigger bar must not move the axis of a finished capture. */
static TrigView resolveTrigView(Hub* hub, const GlobalView* gv, bool paused) {
TrigView tv;
if (!gv->trigView) { return tv; }
const TriggerCfgState& t = hub->trigger();
if (!paused && t.status == "collecting" && t.hasTrigTime) {
tv.rel = true;
tv.trigT = t.trigTime;
/* Prefer the window the hub latched at fire time; the local config is
* only a fallback for hubs that do not report it, and may have been
* edited since the trigger fired. */
tv.preS = t.hasFiredWin ? t.firedPreS
: t.windowSec * t.prePercent * 0.01;
tv.postS = t.hasFiredWin ? t.firedPostS : t.windowSec - tv.preS;
return tv;
}
const CaptureFrame* cap = hub->capture();
if (cap != nullptr) {
tv.rel = true;
tv.fromCap = true;
tv.trigT = cap->trigTime;
tv.preS = cap->preSec;
tv.postS = cap->postSec;
}
return tv;
}
void PlotCanvas::paintEvent(QPaintEvent*) { void PlotCanvas::paintEvent(QPaintEvent*) {
QPainter p(this); QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::Antialiasing, true);
@@ -205,13 +292,14 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
p.fillRect(rect(), col::base()); p.fillRect(rect(), col::base());
p.fillRect(r, col::crust()); p.fillRect(r, col::crust());
const CaptureFrame* cap = hub->capture();
const bool trigView = (cap != nullptr) && gv->trigView;
auto& zc = hub->zoomCache(w_->plotIdx_); auto& zc = hub->zoomCache(w_->plotIdx_);
auto& hc = hub->histZoomCache(w_->plotIdx_); auto& hc = hub->histZoomCache(w_->plotIdx_);
const bool paused = w_->paused_; const bool paused = w_->paused_;
bool& live = w_->live_; bool& live = w_->live_;
const TrigView tv = resolveTrigView(hub, gv, paused);
const CaptureFrame* cap = hub->capture();
/* ── pause snapshot ─────────────────────────────────────────────────── */ /* ── pause snapshot ─────────────────────────────────────────────────── */
auto& snap = w_->snap_; auto& snap = w_->snap_;
if (paused) { if (paused) {
@@ -239,15 +327,15 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
/* ── gather data per slot ───────────────────────────────────────────── */ /* ── gather data per slot ───────────────────────────────────────────── */
std::vector<std::vector<double>> tStore(slots.size()), vStore(slots.size()); std::vector<std::vector<double>> tStore(slots.size()), vStore(slots.size());
const bool liveHiRes = !trigView && live && !paused && const bool liveHiRes = !tv.rel && live && !paused &&
gv->windowSec <= kLiveHiResMaxWin && zc.valid && gv->windowSec <= kLiveHiResMaxWin && zc.valid &&
(zc.t1 - zc.t0) >= gv->windowSec * 0.9 && (wallNow - zc.t1) < 3.0; (zc.t1 - zc.t0) >= gv->windowSec * 0.9 && (wallNow - zc.t1) < 3.0;
const bool useZoomData = !trigView && !paused && zc.valid && const bool useZoomData = !tv.rel && !paused && zc.valid &&
(liveHiRes || (liveHiRes ||
(!live && zc.t0 <= w_->plotXMin_ + 1e-9 && zc.t1 >= w_->plotXMax_ - 1e-9)); (!live && zc.t0 <= w_->plotXMin_ + 1e-9 && zc.t1 >= w_->plotXMax_ - 1e-9));
bool useHistData = !trigView && !paused && !live && hc.valid && bool useHistData = !tv.rel && !paused && !live && hc.valid &&
hc.t0 <= w_->plotXMin_ + 1e-9 && hc.t1 >= w_->plotXMax_ - 1e-9; hc.t0 <= w_->plotXMin_ + 1e-9 && hc.t1 >= w_->plotXMax_ - 1e-9;
if (useHistData) { if (useHistData) {
bool any = false; bool any = false;
@@ -271,17 +359,25 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx]; const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
const std::string key = hub->slotKey(a); const std::string key = hub->slotKey(a);
if (trigView) { if (tv.fromCap) {
for (const auto& cs : cap->signals) { for (const auto& cs : cap->signals) {
if (cs.key != key) continue; if (cs.key != key) continue;
size_t n = std::min(cs.t.size(), cs.v.size()); size_t n = std::min(cs.t.size(), cs.v.size());
tStore[si].reserve(n); vStore[si].reserve(n); tStore[si].reserve(n); vStore[si].reserve(n);
for (size_t i = 0; i < n; i++) { for (size_t i = 0; i < n; i++) {
tStore[si].push_back(cs.t[i] - cap->trigTime); tStore[si].push_back(cs.t[i] - tv.trigT);
vStore[si].push_back(cs.v[i]); vStore[si].push_back(cs.v[i]);
} }
break; break;
} }
} else if (tv.rel) {
/* Filling: local ring, clipped to the (absolute) trigger window and
* shifted onto the trigger-relative axis. */
sig.buf.readRange(tv.trigT - tv.preS, tv.trigT + tv.postS,
tStore[si], vStore[si]);
for (size_t i = 0; i < tStore[si].size(); i++) {
tStore[si][i] -= tv.trigT;
}
} else if (useZoomData) { } else if (useZoomData) {
bool found = false; bool found = false;
for (const auto& zs : zc.pts) { for (const auto& zs : zc.pts) {
@@ -302,11 +398,18 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
resolveVScale(a, sig, vStore[si]); resolveVScale(a, sig, vStore[si]);
} }
if (w_->vMode_ == 3) {
resolveUnifiedVScale(w_->uniVS_, slots, sources, vStore);
}
/* ── X range ────────────────────────────────────────────────────────── */ /* ── X range ────────────────────────────────────────────────────────── */
double xMin, xMax; double xMin, xMax;
if (trigView) { if (tv.rel) {
if (w_->trigZoomed_) { xMin = w_->plotXMin_; xMax = w_->plotXMax_; } if (w_->trigZoomed_) { xMin = w_->plotXMin_; xMax = w_->plotXMax_; }
else { xMin = -cap->preSec; xMax = cap->postSec; } /* Full window from the start, even while filling: a trace growing into
* a fixed axis reads as progress, whereas an axis that grows with the
* data shifts the whole trace every frame. */
else { xMin = -tv.preS; xMax = tv.postS; }
} else if (live && !paused) { } else if (live && !paused) {
if (liveHiRes) { xMax = zc.t1; xMin = zc.t1 - gv->windowSec; } if (liveHiRes) { xMax = zc.t1; xMin = zc.t1 - gv->windowSec; }
else { xMax = wallNow; xMin = wallNow - gv->windowSec; } else { xMax = wallNow; xMin = wallNow - gv->windowSec; }
@@ -319,19 +422,25 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
/* ── grid + ticks ───────────────────────────────────────────────────── */ /* ── grid + ticks ───────────────────────────────────────────────────── */
p.setPen(QPen(QColor(0x31,0x32,0x44,160), 1.0)); p.setPen(QPen(QColor(0x31,0x32,0x44,160), 1.0));
/* Y grid: 9 division lines */ /* Y grid: 9 division lines */
const auto& av = (w_->vMode_ == 0 && w_->activeSlot_ >= 0 && /* Which scale labels the axis: the active signal's in normal mode, the one
w_->activeSlot_ < (int)slots.size()) * the whole plot shares in unified mode (where nothing has to be selected).
? slots[w_->activeSlot_].vs : VScale(); * Banded modes have no single scale, so they keep the plain division numbers. */
const VScale* axisVS = nullptr;
if (w_->vMode_ == 0 && w_->activeSlot_ >= 0 &&
w_->activeSlot_ < (int)slots.size()) {
axisVS = &slots[w_->activeSlot_].vs;
} else if (w_->vMode_ == 3) {
axisVS = &w_->uniVS_;
}
p.setFont(QFont(font().family(), 8)); p.setFont(QFont(font().family(), 8));
for (int d = -4; d <= 4; d++) { for (int d = -4; d <= 4; d++) {
double y = yToPx(d, r); double y = yToPx(d, r);
p.setPen(QPen(QColor(0x31,0x32,0x44, d==0?220:120), d==0?1.2:1.0)); p.setPen(QPen(QColor(0x31,0x32,0x44, d==0?220:120), d==0?1.2:1.0));
p.drawLine(QPointF(r.left(), y), QPointF(r.right(), y)); p.drawLine(QPointF(r.left(), y), QPointF(r.right(), y));
QString lbl; QString lbl;
if (w_->vMode_ == 0 && w_->activeSlot_ >= 0 && if (axisVS != nullptr) {
w_->activeSlot_ < (int)slots.size()) { lbl = fmtVal(axisVS->resolvedOffset +
double rawVal = av.resolvedOffset + (d - av.screenPos) * av.resolvedDiv; (d - axisVS->screenPos) * axisVS->resolvedDiv);
lbl = fmtVal(rawVal);
} else { } else {
lbl = QString::number(d); lbl = QString::number(d);
} }
@@ -346,7 +455,7 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
p.setPen(QPen(QColor(0x31,0x32,0x44,120), 1.0)); p.setPen(QPen(QColor(0x31,0x32,0x44,120), 1.0));
p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom())); p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom()));
p.setPen(QColor(0xa6,0xad,0xc8)); p.setPen(QColor(0xa6,0xad,0xc8));
QString xl = trigView ? fmtVal(xv) + "s" : QString::number(xv, 'f', 3); QString xl = tv.rel ? fmtVal(xv) + "s" : QString::number(xv, 'f', 3);
int flags = (t==0?Qt::AlignLeft:(t==10?Qt::AlignRight:Qt::AlignHCenter)) int flags = (t==0?Qt::AlignLeft:(t==10?Qt::AlignRight:Qt::AlignHCenter))
| Qt::AlignTop; | Qt::AlignTop;
p.drawText(QRectF(x-40, r.bottom()+2, 80, 14), flags, xl); p.drawText(QRectF(x-40, r.bottom()+2, 80, 14), flags, xl);
@@ -396,8 +505,10 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
if (w_->vMode_ == 1) bandNormalize(vDec, vNorm, myKi, nTraces, true); if (w_->vMode_ == 1) bandNormalize(vDec, vNorm, myKi, nTraces, true);
else if (w_->vMode_ == 2) bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed); else if (w_->vMode_ == 2) bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed);
else { else {
/* unified shares one scale, normal gives each trace its own */
const VScale& nvs = (w_->vMode_ == 3) ? w_->uniVS_ : a.vs;
vNorm.resize(nOut); vNorm.resize(nOut);
for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], a.vs); for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], nvs);
} }
QColor c = sig.color; QColor c = sig.color;
@@ -423,7 +534,7 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
} }
/* trigger instant marker at t=0 */ /* trigger instant marker at t=0 */
if (trigView) { if (tv.rel) {
double x = xToPx(0.0, xMin, xMax, r); double x = xToPx(0.0, xMin, xMax, r);
p.setPen(QPen(QColor(255,255,0,200), 1.5, Qt::DashLine)); p.setPen(QPen(QColor(255,255,0,200), 1.5, Qt::DashLine));
p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom())); p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom()));
@@ -476,8 +587,7 @@ void PlotCanvas::wheelEvent(QWheelEvent* e) {
Hub* hub = w_->hub_; Hub* hub = w_->hub_;
GlobalView* gv = w_->gv_; GlobalView* gv = w_->gv_;
auto& slots = w_->slots_; auto& slots = w_->slots_;
const CaptureFrame* cap = hub->capture(); const TrigView tv = resolveTrigView(hub, gv, w_->paused_);
const bool trigView = (cap != nullptr) && gv->trigView;
bool& live = w_->live_; bool& live = w_->live_;
double dy = e->angleDelta().y(); double dy = e->angleDelta().y();
@@ -488,43 +598,51 @@ void PlotCanvas::wheelEvent(QWheelEvent* e) {
const double now = nowSec(); const double now = nowSec();
auto enterTrigZoom = [&]() { auto enterTrigZoom = [&]() {
if (trigView && !w_->trigZoomed_) { if (tv.rel && !w_->trigZoomed_) {
w_->setStoredX(-cap->preSec, cap->postSec); w_->setStoredX(-tv.preS, tv.postS);
w_->trigZoomed_ = true; w_->trigZoomed_ = true;
} }
}; };
auto xZoomStored = [&](double f) { auto xZoomStored = [&](double f) {
if (trigView) enterTrigZoom(); if (tv.rel) enterTrigZoom();
if (now - w_->lastHistPushMs_ > 0.6) { w_->pushZoomHist(); w_->lastHistPushMs_ = now; } if (now - w_->lastHistPushMs_ > 0.6) { w_->pushZoomHist(); w_->lastHistPushMs_ = now; }
double cx = (w_->plotXMin_ + w_->plotXMax_) * 0.5; double cx = (w_->plotXMin_ + w_->plotXMax_) * 0.5;
double half = (w_->plotXMax_ - w_->plotXMin_) * 0.5 * f; double half = (w_->plotXMax_ - w_->plotXMin_) * 0.5 * f;
w_->setStoredX(cx - half, cx + half); w_->setStoredX(cx - half, cx + half);
}; };
auto makeManual = [&](PlotAssignment& a) { /* Seed manual from the resolved values so the gesture sticks. */
if (a.vs.mode != 2) { auto makeManual = [&](VScale& vs) {
a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30); if (vs.mode != 2) {
a.vs.offset = a.vs.resolvedOffset; vs.divValue = std::max(vs.resolvedDiv, 1e-30);
a.vs.mode = 2; vs.offset = vs.resolvedOffset;
vs.mode = 2;
} }
}; };
/* Scroll adjusts the scale the axis is labelled with: the active signal's in
* normal mode, the plot's shared one in unified mode (nothing to select). */
VScale* wheelVS = nullptr;
if (w_->vMode_ == 3) {
wheelVS = &w_->uniVS_;
} else if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) {
wheelVS = &slots[w_->activeSlot_].vs;
}
if (ctrl) { if (ctrl) {
if (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0); if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
else xZoomStored(factor); else xZoomStored(factor);
} else if (shift) { } else if (shift) {
if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) { if (wheelVS != nullptr) {
auto& a = slots[w_->activeSlot_]; makeManual(*wheelVS);
makeManual(a); wheelVS->screenPos += (dy > 0) ? 0.5 : -0.5;
a.vs.screenPos += (dy > 0) ? 0.5 : -0.5;
} }
} else { } else {
if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) { if (wheelVS != nullptr) {
auto& a = slots[w_->activeSlot_]; makeManual(*wheelVS);
makeManual(a); wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30);
a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30);
} else { } else {
if (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0); if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
else xZoomStored(factor); else xZoomStored(factor);
} }
} }
@@ -549,8 +667,7 @@ void PlotCanvas::mouseMoveEvent(QMouseEvent* e) {
GlobalView* gv = w_->gv_; GlobalView* gv = w_->gv_;
Hub* hub = w_->hub_; Hub* hub = w_->hub_;
const QRectF r = plotRect(); const QRectF r = plotRect();
const CaptureFrame* cap = hub->capture(); const TrigView tv = resolveTrigView(hub, gv, w_->paused_);
const bool trigView = (cap != nullptr) && gv->trigView;
bool& live = w_->live_; bool& live = w_->live_;
if (dragCursor_ != 0) { if (dragCursor_ != 0) {
@@ -560,11 +677,11 @@ void PlotCanvas::mouseMoveEvent(QMouseEvent* e) {
return; return;
} }
if (panning_) { if (panning_) {
if (trigView && !w_->trigZoomed_) { if (tv.rel && !w_->trigZoomed_) {
w_->setStoredX(-cap->preSec, cap->postSec); w_->setStoredX(-tv.preS, tv.postS);
w_->trigZoomed_ = true; w_->trigZoomed_ = true;
} }
if (!trigView && live) { w_->initPlotX(nowSec()); live = false; } if (!tv.rel && live) { w_->initPlotX(nowSec()); live = false; }
double dxPix = e->pos().x() - lastPos_.x(); double dxPix = e->pos().x() - lastPos_.x();
lastPos_ = e->pos(); lastPos_ = e->pos();
double xRange = w_->plotXMax_ - w_->plotXMin_; double xRange = w_->plotXMax_ - w_->plotXMin_;
@@ -677,11 +794,10 @@ void PlotWidget::onCaptureReceived() {
void PlotWidget::tick() { void PlotWidget::tick() {
Hub* hub = hub_; Hub* hub = hub_;
GlobalView* gv = gv_; GlobalView* gv = gv_;
const CaptureFrame* cap = hub->capture(); const TrigView tv = resolveTrigView(hub, gv, paused_);
const bool trigView = (cap != nullptr) && gv->trigView;
const double now = nowSec(); const double now = nowSec();
if (!trigView && !paused_) { if (!tv.rel && !paused_) {
std::string csv; std::string csv;
for (const auto& a : slots_) { for (const auto& a : slots_) {
std::string k = hub->slotKey(a); std::string k = hub->slotKey(a);
@@ -736,9 +852,13 @@ void PlotWidget::rebuildHeader() {
auto* b = new QToolButton(header_); auto* b = new QToolButton(header_);
b->setCheckable(true); b->setCheckable(true);
b->setChecked(activeSlot_ == i); b->setChecked(activeSlot_ == i);
b->setText(QString("%1 %2/div") /* In unified mode every badge would repeat the same div value, which
.arg(QString::fromStdString(sig.meta.name)) * the header's Y-Scale button already shows — so show just the name. */
.arg(fmtVal(a.vs.resolvedDiv))); b->setText(vMode_ == 3
? QString::fromStdString(sig.meta.name)
: QString("%1 %2/div")
.arg(QString::fromStdString(sig.meta.name))
.arg(fmtVal(a.vs.resolvedDiv)));
QColor c = sig.color; QColor c = sig.color;
QString fg = (activeSlot_ == i) ? "#11111b" : "#11111b"; QString fg = (activeSlot_ == i) ? "#11111b" : "#11111b";
QColor bg = (activeSlot_ == i) ? col::blue() : c; QColor bg = (activeSlot_ == i) ? col::blue() : c;
@@ -797,11 +917,17 @@ void PlotWidget::rebuildHeader() {
headerLay_->addWidget(fit); headerLay_->addWidget(fit);
} }
/* N / D / M */ /* N / U / D / M */
const char* vl[3] = {"N", "D", "M"}; const char* vl[4] = {"N", "U", "D", "M"};
for (int vm = 0; vm < 3; vm++) { const char* vtip[4] = {"Normal: one vertical scale per signal",
"Unified: one vertical scale shared by every signal",
"Digital", "Mixed"};
const int vmode[4] = {0, 3, 1, 2};
for (int i = 0; i < 4; i++) {
const int vm = vmode[i];
auto* vb = new QToolButton(header_); auto* vb = new QToolButton(header_);
vb->setText(vl[vm]); vb->setText(vl[i]);
vb->setToolTip(vtip[i]);
vb->setCheckable(true); vb->setCheckable(true);
vb->setChecked(vMode_ == vm); vb->setChecked(vMode_ == vm);
connect(vb, &QToolButton::clicked, this, [this, vm]() { connect(vb, &QToolButton::clicked, this, [this, vm]() {
@@ -810,9 +936,57 @@ void PlotWidget::rebuildHeader() {
headerLay_->addWidget(vb); headerLay_->addWidget(vb);
} }
/* Unified mode's single scale belongs to the plot, not to any one signal,
* so it is edited from here rather than from a badge's context menu. */
if (vMode_ == 3) {
auto* yb = new QToolButton(header_);
yb->setText(QString("Y-Scale: %1/div").arg(fmtVal(uniVS_.resolvedDiv)));
yb->setToolTip("Vertical scale shared by every signal in this plot");
connect(yb, &QToolButton::clicked, this, [this, yb]() {
showUnifiedVScaleMenu(yb->mapToGlobal(QPoint(0, yb->height())));
});
headerLay_->addWidget(yb);
}
headerLay_->addStretch(1); headerLay_->addStretch(1);
} }
/** Populate @a vs with the Auto/Range/Manual entries driving @a evs. */
void PlotWidget::buildVScaleMenu(QMenu* vs, VScale& evs) {
const char* modes[] = {"Auto", "Range", "Manual"};
for (int mm = 0; mm < 3; mm++) {
QAction* act = vs->addAction(modes[mm]);
act->setCheckable(true); act->setChecked(evs.mode == mm);
connect(act, &QAction::triggered, this, [this, &evs, mm]() {
evs.mode = mm; rebuildHeader(); canvas_->update();
});
}
vs->addSeparator();
vs->addAction("Manual V/div…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "V/div", "Units per division",
evs.mode==2?evs.divValue:evs.resolvedDiv, -1e12, 1e12, 6, &ok);
if (ok) { evs.divValue = v; evs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Offset…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "Offset", "Center value",
evs.mode==2?evs.offset:evs.resolvedOffset, -1e12, 1e12, 6, &ok);
if (ok) { evs.offset = v; evs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Position (div)…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "Position", "Divisions from center",
evs.screenPos, -8, 8, 2, &ok);
if (ok) { evs.screenPos = v; canvas_->update(); }
});
}
void PlotWidget::showUnifiedVScaleMenu(const QPoint& globalPos) {
QMenu m;
m.addAction("Y-Scale — all signals")->setEnabled(false);
m.addSeparator();
buildVScaleMenu(&m, uniVS_);
m.exec(globalPos);
}
void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) { void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) {
auto& sources = hub_->sources(); auto& sources = hub_->sources();
if (slotIdx < 0 || slotIdx >= (int)slots_.size()) return; if (slotIdx < 0 || slotIdx >= (int)slots_.size()) return;
@@ -846,30 +1020,12 @@ void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) {
connect(dg, &QAction::toggled, this, [&](bool on){ a.vs.digitalInMixed = on; canvas_->update(); }); connect(dg, &QAction::toggled, this, [&](bool on){ a.vs.digitalInMixed = on; canvas_->update(); });
} }
m.addSeparator(); /* In unified mode the plot has one scale for every trace, so it is edited
QMenu* vs = m.addMenu("V-scale"); * from the header's Y-Scale button instead of from any one signal. */
const char* modes[] = {"Auto", "Range", "Manual"}; if (vMode_ != 3) {
for (int mm = 0; mm < 3; mm++) { m.addSeparator();
QAction* act = vs->addAction(modes[mm]); buildVScaleMenu(m.addMenu("V-scale"), a.vs);
act->setCheckable(true); act->setChecked(a.vs.mode == mm);
connect(act, &QAction::triggered, this, [&, mm]() { a.vs.mode = mm; rebuildHeader(); canvas_->update(); });
} }
vs->addSeparator();
vs->addAction("Manual V/div…", [&]() {
bool ok; double v = QInputDialog::getDouble(this, "V/div", "Units per division",
a.vs.mode==2?a.vs.divValue:a.vs.resolvedDiv, -1e12, 1e12, 6, &ok);
if (ok) { a.vs.divValue = v; a.vs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Offset…", [&]() {
bool ok; double v = QInputDialog::getDouble(this, "Offset", "Center value",
a.vs.mode==2?a.vs.offset:a.vs.resolvedOffset, -1e12, 1e12, 6, &ok);
if (ok) { a.vs.offset = v; a.vs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Position (div)…", [&]() {
bool ok; double v = QInputDialog::getDouble(this, "Position", "Divisions from center",
a.vs.screenPos, -8, 8, 2, &ok);
if (ok) { a.vs.screenPos = v; canvas_->update(); }
});
m.addSeparator(); m.addSeparator();
m.addAction("Remove from plot", [&]() { m.addAction("Remove from plot", [&]() {
+5 -1
View File
@@ -21,6 +21,7 @@
class QHBoxLayout; class QHBoxLayout;
class QToolButton; class QToolButton;
class QLabel; class QLabel;
class QMenu;
namespace shq { namespace shq {
@@ -69,6 +70,8 @@ private:
friend class PlotCanvas; friend class PlotCanvas;
void rebuildHeader(); void rebuildHeader();
void buildVScaleMenu(QMenu* vs, VScale& evs);
void showUnifiedVScaleMenu(const QPoint& globalPos);
void showBadgeMenu(int slotIdx, const QPoint& globalPos); void showBadgeMenu(int slotIdx, const QPoint& globalPos);
void pushZoomHist(); void pushZoomHist();
void initPlotX(double tMax); void initPlotX(double tMax);
@@ -87,7 +90,8 @@ private:
bool paused_ = false; bool paused_ = false;
double plotXMin_ = 0.0; double plotXMin_ = 0.0;
double plotXMax_ = 0.0; double plotXMax_ = 0.0;
int vMode_ = 0; /* 0 normal 1 digital 2 mixed */ int vMode_ = 0; /* 0 normal 1 digital 2 mixed 3 unified */
VScale uniVS_; /* the one scale every trace shares in mode 3 */
int activeSlot_ = -1; int activeSlot_ = -1;
bool trigZoomed_ = false; bool trigZoomed_ = false;
+6
View File
@@ -825,11 +825,17 @@ void App::onTriggerState(const std::string& json) {
trigger_.trigTime = msg.trigTime; trigger_.trigTime = msg.trigTime;
trigger_.hasTrigTime = true; trigger_.hasTrigTime = true;
} }
if (msg.hasWindow) {
trigger_.firedPreS = msg.preSec;
trigger_.firedPostS = msg.postSec;
trigger_.hasFiredWin = true;
}
/* Double-buffer semantics: the last recorded capture stays on display /* Double-buffer semantics: the last recorded capture stays on display
* (even while re-armed/collecting) and is only replaced when a new * (even while re-armed/collecting) and is only replaced when a new
* capture frame has been fully received and parsed (handleBinary v2). */ * capture frame has been fully received and parsed (handleBinary v2). */
if (msg.state == "idle") { if (msg.state == "idle") {
trigger_.hasTrigTime = false; trigger_.hasTrigTime = false;
trigger_.hasFiredWin = false;
} }
} }
+11 -2
View File
@@ -61,6 +61,11 @@ struct TriggerState {
bool stopped = false; bool stopped = false;
bool hasTrigTime = false; bool hasTrigTime = false;
double trigTime = 0.0; double trigTime = 0.0;
/* Window the hub latched at fire time. Not the same as windowSec/prePercent
* above, which are editable and may have moved on since the trigger fired. */
bool hasFiredWin = false;
double firedPreS = 0.0;
double firedPostS = 0.0;
}; };
/** Per-signal vertical scale state (oscilloscope style). */ /** Per-signal vertical scale state (oscilloscope style). */
@@ -183,9 +188,12 @@ public:
plotXMax_[i] = tMax; plotXMax_[i] = tMax;
} }
/** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed. */ /** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed 3=unified. */
int& plotVMode(int i) { return plotVMode_[i]; } int& plotVMode(int i) { return plotVMode_[i]; }
/** @brief The one scale every trace shares in unified mode (vMode 3). */
VScale& plotUnifiedVS(int i) { return plotUniVS_[i]; }
/* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */ /* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */
bool& cursorsOn() { return cursorsOn_; } bool& cursorsOn() { return cursorsOn_; }
double& cursorA() { return cursorA_; } double& cursorA() { return cursorA_; }
@@ -302,7 +310,8 @@ private:
double windowSec_ = 10.0; /* live scroll window width */ double windowSec_ = 10.0; /* live scroll window width */
double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */ double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */
double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */ double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */
int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed */ int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed 3=unified */
VScale plotUniVS_[kMaxPlotSlots]; /* shared scale used by vMode 3 */
/* Cursors (global) */ /* Cursors (global) */
bool cursorsOn_ = false; bool cursorsOn_ = false;
+192 -61
View File
@@ -85,6 +85,49 @@ static double normalizeY(double raw, const VScale& vs) {
return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos; return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos;
} }
/** Resolve the one scale every trace shares in unified mode.
*
* Same rules as the per-signal version, applied to the union of the plot:
* range takes the union of the declared ranges, auto fits the union of the
* data. Signals whose slot is empty contribute nothing. */
static void resolveUnifiedVScale(VScale& vs,
const std::vector<PlotAssignment>& slots,
const std::vector<Source>& sources,
const std::vector<std::vector<double> >& vStore) {
if (vs.mode == 2) { /* manual */
vs.resolvedDiv = std::max(vs.divValue, 1e-30);
vs.resolvedOffset = vs.offset;
return;
}
double mn = 1e300, mx = -1e300;
if (vs.mode == 1) { /* range: union of every declared range */
for (const auto& a : slots) {
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
const auto& m = sources[a.sourceIdx].signals[a.signalIdx].meta;
if (!(m.rangeMax > m.rangeMin)) continue;
if (m.rangeMin < mn) mn = m.rangeMin;
if (m.rangeMax > mx) mx = m.rangeMax;
}
if (mx > mn) {
vs.resolvedDiv = std::max((mx - mn) / 8.0, 1e-30);
vs.resolvedOffset = (mn + mx) / 2.0;
return;
}
mn = 1e300; mx = -1e300; /* no usable range: fall through to auto */
}
for (const auto& vv : vStore) {
for (double v : vv) {
if (!std::isfinite(v)) continue;
if (v < mn) mn = v;
if (v > mx) mx = v;
}
}
if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30);
vs.resolvedOffset = (mx + mn) / 2.0;
}
/** Min/max of a vector (returns false if empty/non-finite). */ /** Min/max of a vector (returns false if empty/non-finite). */
static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) { static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) {
mn = 1e300; mx = -1e300; mn = 1e300; mx = -1e300;
@@ -149,9 +192,36 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
const double wallNow = std::chrono::duration<double>( const double wallNow = std::chrono::duration<double>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::system_clock::now().time_since_epoch()).count();
/* Trigger view: render the hub capture relative to the trigger instant */ /* Trigger view: render the hub capture relative to the trigger instant.
*
* Two ways to end up in trigger-relative time. Either a v2 capture frame
* has arrived (trigView), or a trigger has fired and its window is still
* filling (trigFill). In the second case the hub sends nothing until the
* whole window has been produced — several seconds for a long window at a
* high rate — so the trace is drawn from this client's own rings on the
* final axis, growing left to right. Filling wins over the previous
* capture: once a new trigger fires, the stale waveform is history. */
const CaptureFrame* cap = app.capture(); const CaptureFrame* cap = app.capture();
const bool trigView = (cap != nullptr) && app.showTrigBar(); const TriggerState& trg = app.trigger();
/* Prefer the window the hub latched at fire time; the local config is only
* a fallback for hubs that do not report it, and may have been edited
* since the trigger fired. */
const double fillPreS = trg.hasFiredWin ? trg.firedPreS
: trg.windowSec * trg.prePercent * 0.01;
const double fillPostS = trg.hasFiredWin ? trg.firedPostS
: trg.windowSec - fillPreS;
const bool trigFill = app.showTrigBar() && !paused &&
trg.status == "collecting" && trg.hasTrigTime;
const bool trigView = (cap != nullptr) && app.showTrigBar() && !trigFill;
const bool trigRel = trigView || trigFill;
/* Window edges of whatever is on screen. A capture latches its own
* pre/post at fire time, so later edits in the trigger bar must not move
* the axis of a finished capture. */
const double trigT = trigView ? cap->trigTime : trg.trigTime;
const double trigPreS = trigView ? cap->preSec : fillPreS;
const double trigPostS = trigView ? cap->postSec : fillPostS;
/* Hi-res zoom cache for this plot */ /* Hi-res zoom cache for this plot */
auto& zc = app.zoomCache(plotIdx); auto& zc = app.zoomCache(plotIdx);
@@ -194,13 +264,13 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
* from decimated pushes) undersamples the visible range. Periodically * from decimated pushes) undersamples the visible range. Periodically
* fetch a fresh ~2400-pt slice from the hub raw ring and anchor the X * fetch a fresh ~2400-pt slice from the hub raw ring and anchor the X
* axis to the fetched slice (scope-style refresh at the fetch rate). */ * axis to the fetched slice (scope-style refresh at the fetch rate). */
const bool liveHiRes = !trigView && live && !paused && const bool liveHiRes = !trigRel && live && !paused &&
app.windowSec() <= kLiveHiResMaxWin && app.windowSec() <= kLiveHiResMaxWin &&
zc.valid && zc.valid &&
(zc.t1 - zc.t0) >= app.windowSec() * 0.9 && (zc.t1 - zc.t0) >= app.windowSec() * 0.9 &&
(wallNow - zc.t1) < 3.0; (wallNow - zc.t1) < 3.0;
const bool useZoomData = !trigView && !paused && zc.valid && const bool useZoomData = !trigRel && !paused && zc.valid &&
(liveHiRes || (liveHiRes ||
(!live && (!live &&
zc.t0 <= app.plotXMin(plotIdx) + 1e-9 && zc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
@@ -215,7 +285,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
const bool haveHistCover = hc.valid && const bool haveHistCover = hc.valid &&
hc.t0 <= app.plotXMin(plotIdx) + 1e-9 && hc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
hc.t1 >= app.plotXMax(plotIdx) - 1e-9; hc.t1 >= app.plotXMax(plotIdx) - 1e-9;
bool useHistData = !trigView && !paused && !live && haveHistCover; bool useHistData = !trigRel && !paused && !live && haveHistCover;
if (useHistData) { if (useHistData) {
/* Check that at least one signal has actual data points */ /* Check that at least one signal has actual data points */
bool anyData = false; bool anyData = false;
@@ -231,7 +301,12 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
* copied tens of MB per signal per frame. A 10% margin keeps a sample on * copied tens of MB per signal per frame. A 10% margin keeps a sample on
* each side so the later fine clip still has its boundary points. */ * each side so the later fine clip still has its boundary points. */
double visT0, visT1; double visT0, visT1;
if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); } if (trigFill) {
/* Absolute bounds of the trigger window: the ring is indexed on the
* hub clock, the axis on trigger-relative time. */
visT0 = trigT - trigPreS; visT1 = trigT + trigPostS;
}
else if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); }
else { visT1 = app.plotXMax(plotIdx); visT0 = app.plotXMin(plotIdx); } else { visT1 = app.plotXMax(plotIdx); visT0 = app.plotXMin(plotIdx); }
{ {
double margin = (visT1 - visT0) * 0.1; double margin = (visT1 - visT0) * 0.1;
@@ -272,6 +347,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
break; break;
} }
} else if (trigFill) {
/* Live ring, clipped to the (absolute) window and shifted onto the
* trigger-relative axis. visT0/visT1 already carry a margin, so
* clip here rather than reusing readBase. */
(void) sig.buf.readRange(trigT - trigPreS, trigT + trigPostS,
tStore[si], vStore[si]);
for (size_t i = 0; i < tStore[si].size(); i++) {
tStore[si][i] -= trigT;
}
} else if (useZoomData) { } else if (useZoomData) {
bool found = false; bool found = false;
for (const auto& zs : zc.signals) { for (const auto& zs : zc.signals) {
@@ -302,6 +386,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
resolveVScale(a, sig, vStore[si]); resolveVScale(a, sig, vStore[si]);
} }
VScale& uniVS = app.plotUnifiedVS(plotIdx);
if (vMode == 3) {
resolveUnifiedVScale(uniVS, slots, sources, vStore);
}
/* clamp active slot */ /* clamp active slot */
if (actSlot >= (int)slots.size()) actSlot = -1; if (actSlot >= (int)slots.size()) actSlot = -1;
@@ -326,9 +415,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
ImVec4(0.067f,0.067f,0.106f,1.f)); ImVec4(0.067f,0.067f,0.106f,1.f));
char badge[80]; char badge[80];
/* show vscale info: resolved div value */ /* Show the div value actually in force: the plot's shared one in
* unified mode, this signal's otherwise. */
char dvbuf[16]; char dvbuf[16];
fmtVal(dvbuf, sizeof(dvbuf), a.vs.resolvedDiv); fmtVal(dvbuf, sizeof(dvbuf),
(vMode == 3) ? uniVS.resolvedDiv : a.vs.resolvedDiv);
snprintf(badge, sizeof(badge), "%s %s/div##b%d", snprintf(badge, sizeof(badge), "%s %s/div##b%d",
sig.meta.name.c_str(), dvbuf, i); sig.meta.name.c_str(), dvbuf, i);
@@ -398,7 +489,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
/* Back / Fit / Reset (zoom history) */ /* Back / Fit / Reset (zoom history) */
if (!live || (trigView && app.trigZoomed(plotIdx))) { if (!live || (trigRel && app.trigZoomed(plotIdx))) {
ImGui::SameLine(); ImGui::SameLine();
auto& hist = app.zoomHist(plotIdx); auto& hist = app.zoomHist(plotIdx);
if (hist.empty()) { ImGui::BeginDisabled(); } if (hist.empty()) { ImGui::BeginDisabled(); }
@@ -408,7 +499,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
if (hist.empty()) { ImGui::EndDisabled(); } if (hist.empty()) { ImGui::EndDisabled(); }
ImGui::SameLine(); ImGui::SameLine();
if (trigView) { if (trigRel) {
/* Reset to full capture window */ /* Reset to full capture window */
if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) { if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) {
app.trigZoomed(plotIdx) = false; app.trigZoomed(plotIdx) = false;
@@ -440,10 +531,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* Norm/Dig/Mix mode — compact toggle buttons matching SmallButton height */ /* Norm/Dig/Mix mode — compact toggle buttons matching SmallButton height */
ImGui::SameLine(); ImGui::SameLine();
{ {
static const char* kVLabels[] = {"N", "D", "M"}; static const char* kVLabels[] = {"N", "U", "D", "M"};
static const char* kVTooltips[] = {"Normal", "Digital", "Mixed"}; static const char* kVTooltips[] = {
for (int vm = 0; vm < 3; vm++) { "Normal: one vertical scale per signal",
char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[vm], plotIdx, vm); "Unified: one vertical scale shared by every signal",
"Digital", "Mixed" };
static const int kVModes[] = {0, 3, 1, 2};
for (int i = 0; i < 4; i++) {
const int vm = kVModes[i];
char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[i], plotIdx, vm);
bool sel = (vMode == vm); bool sel = (vMode == vm);
if (sel) { if (sel) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f)); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
@@ -451,28 +547,41 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
if (ImGui::SmallButton(vmId)) { vMode = vm; } if (ImGui::SmallButton(vmId)) { vMode = vm; }
if (sel) { ImGui::PopStyleColor(2); } if (sel) { ImGui::PopStyleColor(2); }
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[vm]); } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[i]); }
if (vm < 2) { ImGui::SameLine(0.f, 1.f); } if (i < 3) { ImGui::SameLine(0.f, 1.f); }
} }
} }
/* ── VScale toolbar (shown when an active signal is selected) ───────── */ /* ── VScale toolbar ──────────────────────────────────────────────────── *
* Normal mode edits the active signal's scale; unified mode edits the one
* scale the whole plot shares, so it needs no selection. */
VScale *toolVS = static_cast<VScale *>(0);
if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) { if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
auto& a = slots[actSlot]; toolVS = &slots[actSlot].vs;
} else if (vMode == 3) {
toolVS = &uniVS;
}
if (toolVS != static_cast<VScale *>(0)) {
VScale& tvs = *toolVS;
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f));
if (vMode == 3) {
ImGui::TextDisabled("all signals");
ImGui::SameLine(0.f,10.f);
}
/* mode buttons */ /* mode buttons */
static const char* kModeLabels[] = {"Auto","Range","Manual"}; static const char* kModeLabels[] = {"Auto","Range","Manual"};
for (int m = 0; m < 3; m++) { for (int m = 0; m < 3; m++) {
bool sel = (a.vs.mode == m); bool sel = (tvs.mode == m);
if (sel) { if (sel) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::PushStyleColor(ImGuiCol_Button,
ImVec4(0.537f,0.706f,0.980f,0.3f)); ImVec4(0.537f,0.706f,0.980f,0.3f));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(0.537f,0.706f,0.980f,1.f)); ImVec4(0.537f,0.706f,0.980f,1.f));
} }
if (ImGui::SmallButton(kModeLabels[m])) { a.vs.mode = m; } if (ImGui::SmallButton(kModeLabels[m])) { tvs.mode = m; }
if (sel) ImGui::PopStyleColor(2); if (sel) ImGui::PopStyleColor(2);
if (m < 2) ImGui::SameLine(0.f,2.f); if (m < 2) ImGui::SameLine(0.f,2.f);
} }
@@ -480,23 +589,23 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* resolved info */ /* resolved info */
char rbuf[24], obuf[24]; char rbuf[24], obuf[24];
fmtVal(rbuf, sizeof(rbuf), a.vs.resolvedDiv); fmtVal(rbuf, sizeof(rbuf), tvs.resolvedDiv);
fmtVal(obuf, sizeof(obuf), a.vs.resolvedOffset); fmtVal(obuf, sizeof(obuf), tvs.resolvedOffset);
if (a.vs.mode == 2) { /* manual: editable */ if (tvs.mode == 2) { /* manual: editable */
ImGui::SetNextItemWidth(70.f); ImGui::SetNextItemWidth(70.f);
ImGui::InputDouble("V/div##vd", &a.vs.divValue, 0,0,"%.4g"); ImGui::InputDouble("V/div##vd", &tvs.divValue, 0,0,"%.4g");
ImGui::SameLine(0.f,4.f); ImGui::SameLine(0.f,4.f);
ImGui::SetNextItemWidth(80.f); ImGui::SetNextItemWidth(80.f);
ImGui::InputDouble("Offset##vo", &a.vs.offset, 0,0,"%.4g"); ImGui::InputDouble("Offset##vo", &tvs.offset, 0,0,"%.4g");
} else { } else {
ImGui::TextDisabled("%s/div @%s", rbuf, obuf); ImGui::TextDisabled("%s/div @%s", rbuf, obuf);
} }
ImGui::SameLine(0.f,10.f); ImGui::SameLine(0.f,10.f);
ImGui::SetNextItemWidth(50.f); ImGui::SetNextItemWidth(50.f);
float sp = (float)a.vs.screenPos; float sp = (float)tvs.screenPos;
if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) { if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) {
a.vs.screenPos = sp; tvs.screenPos = sp;
} }
ImGui::PopStyleVar(); ImGui::PopStyleVar();
@@ -539,7 +648,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) { if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) {
/* Both axes locked so ImPlot never overrides our explicit limits. */ /* Both axes locked so ImPlot never overrides our explicit limits. */
ImPlot::SetupAxes(trigView ? "t - trig (s)" : "Time (s)", nullptr, ImPlot::SetupAxes(trigRel ? "t - trig (s)" : "Time (s)", nullptr,
ImPlotAxisFlags_Lock, ImPlotAxisFlags_Lock,
ImPlotAxisFlags_Lock); ImPlotAxisFlags_Lock);
@@ -549,13 +658,17 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* X axis: trig view → capture window (zoomable); live → wall clock; else stored */ /* X axis: trig view → capture window (zoomable); live → wall clock; else stored */
double xMin, xMax; double xMin, xMax;
bool& trigZm = app.trigZoomed(plotIdx); bool& trigZm = app.trigZoomed(plotIdx);
if (trigView) { if (trigRel) {
if (trigZm) { if (trigZm) {
xMin = app.plotXMin(plotIdx); xMin = app.plotXMin(plotIdx);
xMax = app.plotXMax(plotIdx); xMax = app.plotXMax(plotIdx);
} else { } else {
xMin = -cap->preSec; /* Full window from the start, even while filling: a trace that
xMax = cap->postSec; * grows into a fixed axis reads as progress; an axis that
* grows with the data makes the whole trace shift every
* frame and the time base meaningless. */
xMin = -trigPreS;
xMax = trigPostS;
} }
} else if (live && !paused) { } else if (live && !paused) {
if (liveHiRes) { if (liveHiRes) {
@@ -568,7 +681,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} else { } else {
xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx); xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx);
} }
if (trigView || (live && !paused) || !live) { if (trigRel || (live && !paused) || !live) {
if (xMax > xMin) { if (xMax > xMin) {
ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always); ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always);
} }
@@ -579,8 +692,16 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
static char yTickBufs[9][20]; static char yTickBufs[9][20];
static const char* yTickLabels[9]; static const char* yTickLabels[9];
const VScale *axisVS = static_cast<const VScale *>(0);
if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) { if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
const auto& av = slots[actSlot].vs; axisVS = &slots[actSlot].vs;
} else if (vMode == 3) {
/* Unified: the shared scale labels the axis for every trace at
* once, so no signal has to be selected first. */
axisVS = &uniVS;
}
if (axisVS != static_cast<const VScale *>(0)) {
const VScale& av = *axisVS;
for (int d = 0; d < 9; d++) { for (int d = 0; d < 9; d++) {
double divPos = yTickVals[d]; double divPos = yTickVals[d];
double rawVal = av.resolvedOffset + (divPos - av.screenPos) * av.resolvedDiv; double rawVal = av.resolvedOffset + (divPos - av.screenPos) * av.resolvedDiv;
@@ -636,15 +757,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* Helper: enter zoomed mode for trigger view (seed from capture window) */ /* Helper: enter zoomed mode for trigger view (seed from capture window) */
auto enterTrigZoom = [&]() { auto enterTrigZoom = [&]() {
if (trigView && !trigZm) { if (trigRel && !trigZm) {
app.setPlotX(plotIdx, -cap->preSec, cap->postSec); app.setPlotX(plotIdx, -trigPreS, trigPostS);
trigZm = true; trigZm = true;
} }
}; };
/* Helper: X-zoom the stored range by factor around center */ /* Helper: X-zoom the stored range by factor around center */
auto xZoomStored = [&](double factor) { auto xZoomStored = [&](double factor) {
if (trigView) { enterTrigZoom(); } if (trigRel) { enterTrigZoom(); }
if (now - lastHistPush[plotIdx] > 0.6) { if (now - lastHistPush[plotIdx] > 0.6) {
app.pushZoomHist(plotIdx); app.pushZoomHist(plotIdx);
lastHistPush[plotIdx] = now; lastHistPush[plotIdx] = now;
@@ -659,37 +780,45 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
const double zoomOut = 1.25; const double zoomOut = 1.25;
double factor = (wheel > 0.f) ? zoomIn : zoomOut; double factor = (wheel > 0.f) ? zoomIn : zoomOut;
/* Scroll adjusts the scale the axis is labelled with: the
* active signal's in normal mode, the plot's shared one in
* unified mode (where there is nothing to select). */
VScale *wheelVS = static_cast<VScale *>(0);
if (vMode == 3) {
wheelVS = &uniVS;
} else if (actSlot >= 0 && actSlot < (int)slots.size()) {
wheelVS = &slots[actSlot].vs;
}
/* Seed manual from the resolved values so the gesture sticks. */
auto latchManual = [](VScale& v) {
if (v.mode != 2) {
v.divValue = std::max(v.resolvedDiv, 1e-30);
v.offset = v.resolvedOffset;
v.mode = 2;
}
};
if (ctrl) { if (ctrl) {
/* ── X zoom ─────────────────────────────────────────── */ /* ── X zoom ─────────────────────────────────────────── */
if (!trigView && live) { if (!trigRel && live) {
app.setWindowSec(app.windowSec() * factor); app.setWindowSec(app.windowSec() * factor);
} else { } else {
xZoomStored(factor); xZoomStored(factor);
} }
} else if (shift) { } else if (shift) {
/* ── Y offset of active signal ───────────────────────── */ /* ── Y pan ───────────────────────────────────────────── */
if (actSlot >= 0 && actSlot < (int)slots.size()) { if (wheelVS != static_cast<VScale *>(0)) {
auto& a = slots[actSlot]; latchManual(*wheelVS);
if (a.vs.mode != 2) { wheelVS->screenPos += (wheel > 0.f) ? 0.5 : -0.5;
a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30);
a.vs.offset = a.vs.resolvedOffset;
a.vs.mode = 2;
}
a.vs.screenPos += (wheel > 0.f) ? 0.5 : -0.5;
} }
} else { } else {
/* ── Y zoom of active signal ─────────────────────────── */ /* ── Y zoom ──────────────────────────────────────────── */
if (actSlot >= 0 && actSlot < (int)slots.size()) { if (wheelVS != static_cast<VScale *>(0)) {
auto& a = slots[actSlot]; latchManual(*wheelVS);
if (a.vs.mode != 2) { wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30);
a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30);
a.vs.offset = a.vs.resolvedOffset;
a.vs.mode = 2;
}
a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30);
} else { } else {
/* No active signal: plain scroll → X zoom */ /* No active signal: plain scroll → X zoom */
if (!trigView && live) { if (!trigRel && live) {
app.setWindowSec(app.windowSec() * factor); app.setWindowSec(app.windowSec() * factor);
} else { } else {
xZoomStored(factor); xZoomStored(factor);
@@ -701,8 +830,8 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* Right-drag → X pan. Transition live→non-live on drag start; /* Right-drag → X pan. Transition live→non-live on drag start;
* in trigger view, enter trigger-zoom mode. */ * in trigger view, enter trigger-zoom mode. */
if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) { if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
if (trigView) { enterTrigZoom(); } if (trigRel) { enterTrigZoom(); }
if (!trigView && live) { if (!trigRel && live) {
app.initPlotX(plotIdx, wallNow); app.initPlotX(plotIdx, wallNow);
live = false; live = false;
lastHistPush[plotIdx] = now; lastHistPush[plotIdx] = now;
@@ -721,7 +850,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
/* ── Hi-res WS zoom requests (suppressed while paused) ──────────── */ /* ── Hi-res WS zoom requests (suppressed while paused) ──────────── */
if (!trigView && !paused) { if (!trigRel && !paused) {
std::string csv; std::string csv;
for (const auto& a : slots) { for (const auto& a : slots) {
std::string k = app.slotKey(a); std::string k = app.slotKey(a);
@@ -821,9 +950,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} else if (vMode == 2) { /* mixed */ } else if (vMode == 2) { /* mixed */
bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed); bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed);
} else { } else {
/* unified shares one scale, normal gives each trace its own */
const VScale& nvs = (vMode == 3) ? uniVS : a.vs;
vNorm.resize(nOut); vNorm.resize(nOut);
for (size_t k = 0; k < nOut; k++) { for (size_t k = 0; k < nOut; k++) {
vNorm[k] = normalizeY(vDec[k], a.vs); vNorm[k] = normalizeY(vDec[k], nvs);
} }
} }
@@ -836,7 +967,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
/* Trigger instant marker (capture view: t = 0) */ /* Trigger instant marker (capture view: t = 0) */
if (trigView) { if (trigRel) {
double t0m = 0.0; double t0m = 0.0;
ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f), ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f),
1.5f, ImPlotDragToolFlags_NoInputs); 1.5f, ImPlotDragToolFlags_NoInputs);
+6
View File
@@ -458,6 +458,12 @@ bool ParseTriggerState(const std::string& json, TriggerStateMsg& out) {
double tt = 0.0; double tt = 0.0;
out.hasTrigTime = jsonGetDouble(json.c_str(), "trigTime", tt); out.hasTrigTime = jsonGetDouble(json.c_str(), "trigTime", tt);
out.trigTime = tt; out.trigTime = tt;
double pre = 0.0, post = 0.0;
out.hasWindow = jsonGetDouble(json.c_str(), "preSec", pre) &&
jsonGetDouble(json.c_str(), "postSec", post);
out.preSec = pre;
out.postSec = post;
return true; return true;
} }
+5
View File
@@ -109,6 +109,11 @@ struct TriggerStateMsg {
bool stopped = false; bool stopped = false;
bool hasTrigTime = false; bool hasTrigTime = false;
double trigTime = 0.0; double trigTime = 0.0;
/* Window latched at fire time, sent alongside trigTime. Older hubs omit
* it, hence hasWindow fall back to the local trigger config then. */
bool hasWindow = false;
double preSec = 0.0;
double postSec = 0.0;
}; };
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
+56 -3
View File
@@ -9,6 +9,9 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath"
"syscall"
"marte2/common/wshub" "marte2/common/wshub"
) )
@@ -21,20 +24,56 @@ var staticFiles embed.FS
// multiFlag allows a flag to be repeated: --source a --source b // multiFlag allows a flag to be repeated: --source a --source b
type multiFlag []string type multiFlag []string
func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) } func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) }
func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil } func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil }
// defaultHistoryDir is where samples are archived unless -history-dir says
// otherwise. History is on by default because it is what holds a trigger
// capture at full resolution: the in-memory rings roll past a captured window
// within seconds of it being taken, and a zoom after that has nothing but the
// capture's own decimated copy to draw. Per-signal files are bounded by
// -history-max-mpts, so the default costs a fixed amount of space.
func defaultHistoryDir() string {
return filepath.Join(os.TempDir(), "udpstreamer-history")
}
func main() { func main() {
var sourceArgs multiFlag var sourceArgs multiFlag
flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port[/multicastGroup:dataPort] (repeatable)`) flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port[/multicastGroup:dataPort] (repeatable)`)
sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)") sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)")
listenAddr := flag.String("addr", ":8080", "HTTP listen address") listenAddr := flag.String("addr", ":8080", "HTTP listen address")
histDir := flag.String("history-dir", defaultHistoryDir(), "Directory for disk-backed signal history (empty disables it)")
histWindow := flag.Float64("history-window-sec", 0, "Timespan the history files hold before any client says what it displays (0 keeps the 10 s default); the hub re-sizes them to the live or trigger window afterwards")
histDecim := flag.Int("history-decimation", 1, "Keep every Nth sample in the history files")
histFlush := flag.Int("history-flush-sec", 5, "Seconds between history header flushes")
histMinFree := flag.Int("history-min-free-mb", 500, "Pause history writing below this much free disk (negative disables the check)")
histMaxMPts := flag.Float64("history-max-mpts", 0, "Per-signal history budget in millions of points, also settable in the web UI (0 keeps the 16 MPts / 256 MB default)")
ringMPts := flag.Float64("ring-mpts", 0, "Per-signal in-memory buffer in millions of points (0 keeps the 10 MPts / 160 MB default)")
flag.Parse() flag.Parse()
hub := wshub.NewHub() hub := wshub.NewHub()
// The budget bounds memory, not the window: a window too long to hold at the
// source rate is buffered as min/max pairs rather than truncated to the tail.
hub.SetRingBudget(int(*ringMPts * 1e6))
sm := wshub.NewSourceManager(hub, *sourcesFile) sm := wshub.NewSourceManager(hub, *sourcesFile)
hub.SetSourceManager(sm) hub.SetSourceManager(sm)
if err := hub.EnableHistory(wshub.HistoryConfig{
Directory: *histDir,
WindowSec: *histWindow,
Decimation: *histDecim,
FlushIntervalSec: *histFlush,
MinDiskFreeMB: *histMinFree,
MaxPointsPerSignal: int(*histMaxMPts * 1e6),
}); err != nil {
log.Fatalf("history: %v", err)
}
if *histDir == "" {
log.Print("history disabled: zooming into a trigger capture will fall back " +
"to the capture's own decimated copy once the rings roll past it")
} else {
log.Printf("history: %s", *histDir)
}
go hub.Run() go hub.Run()
// Load sources from file first (if specified), then add any CLI --source flags. // Load sources from file first (if specified), then add any CLI --source flags.
@@ -60,7 +99,21 @@ func main() {
}) })
log.Printf("UDPStreamer WebUI listening on %s (build=%s)", *listenAddr, buildVersion) log.Printf("UDPStreamer WebUI listening on %s (build=%s)", *listenAddr, buildVersion)
if err := http.ListenAndServe(*listenAddr, nil); err != nil {
// Serve in the background so Ctrl-C can flush the history files: the
// samples written since the last periodic flush are on disk but are not
// yet accounted for in the file headers, so exiting outright loses them.
srvErr := make(chan error, 1)
go func() { srvErr <- http.ListenAndServe(*listenAddr, nil) }()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
select {
case err := <-srvErr:
hub.CloseHistory()
log.Fatalf("http: %v", err) log.Fatalf("http: %v", err)
case s := <-sig:
log.Printf("received %s, flushing history", s)
hub.CloseHistory()
} }
} }
File diff suppressed because it is too large Load Diff
+161
View File
@@ -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);
@@ -0,0 +1,51 @@
'use strict';
// Min/max (peak-envelope) decimation — O(n). Runs off-main-thread to avoid
// blocking the render loop.
//
// The range is split into threshold/2 equal buckets and each contributes its
// smallest and largest sample, in the order the two occurred — the way an
// oscilloscope draws a trace it cannot show pixel-for-pixel.
//
// This replaced LTTB, which picks the sample forming the largest triangle with
// its neighbours: a plausible-looking shape, but it silently drops a one-sample
// spike whenever a smoother neighbour scores higher — exactly the sample worth
// looking at. The envelope cannot drop it, because a spike is by definition its
// bucket's min or max. Every output point is a real sample at its real
// timestamp; nothing is interpolated or averaged.
//
// Kept identical to minMaxDecimate() in Common/Client/go/wshub/hub.go and to
// decimate() in app.js, so a trace looks the same whichever thinned it.
function decimate(t, v, threshold) {
const len = t.length;
if (len <= threshold || threshold < 4) {
// Copy to new arrays so we can transfer them back without detaching the input.
return { t: new Float64Array(t), v: new Float64Array(v) };
}
const buckets = threshold >> 1;
const outT = new Float64Array(threshold);
const outV = new Float64Array(threshold);
let n = 0;
for (let b = 0; b < buckets; b++) {
const lo = Math.floor(b * len / buckets);
const hi = (b === buckets - 1) ? len : Math.floor((b + 1) * len / buckets);
if (lo >= hi) continue;
let iMin = lo, iMax = lo;
for (let j = lo + 1; j < hi; j++) {
if (v[j] < v[iMin]) iMin = j;
if (v[j] > v[iMax]) iMax = j;
}
// Emit in time order so the result plots as one ascending trace.
if (iMin > iMax) { const s = iMin; iMin = iMax; iMax = s; }
outT[n] = t[iMin]; outV[n] = v[iMin]; n++;
// A bucket whose samples are all equal has one extreme, not two.
if (iMax !== iMin) { outT[n] = t[iMax]; outV[n] = v[iMax]; n++; }
}
// slice() so the transferred buffers are exactly the used length.
return { t: outT.slice(0, n), v: outV.slice(0, n) };
}
self.onmessage = function({ data: { id, t, v, threshold } }) {
const result = decimate(t, v, threshold);
// Transfer the output buffers back to the main thread zero-copy.
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
};
+62 -14
View File
@@ -30,11 +30,14 @@
</div> </div>
<span class="ctrl-label" id="lbl-window">Window:</span> <span class="ctrl-label" id="lbl-window">Window:</span>
<select id="window-select" class="ctrl-select"> <select id="window-select" class="ctrl-select">
<option value="1">1 s</option><option value="5" selected>5 s</option> <option value="1">1 s</option><option value="2">2 s</option>
<option value="10">10 s</option><option value="30">30 s</option> <option value="5" selected>5 s</option><option value="10">10 s</option>
<option value="60">60 s</option> <option value="15">15 s</option><option value="30">30 s</option>
<option value="60">60 s</option><option value="120">2 min</option>
<option value="300">5 min</option><option value="600">10 min</option>
</select> </select>
<button id="btn-cursor" class="ctrl-btn">Cursors</button> <button id="btn-cursor" class="ctrl-btn">Cursors</button>
<button id="btn-cursor-reset" class="ctrl-btn" style="display:none" title="Bring cursors A/B back into the visible window">↔ Reset</button>
<button id="btn-ruler" class="ctrl-btn" title="Horizontal value rulers">Rulers</button> <button id="btn-ruler" class="ctrl-btn" title="Horizontal value rulers">Rulers</button>
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button> <button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button> <button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
@@ -43,7 +46,8 @@
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button> <button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button> <button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
<label class="ctrl-check" title="Snap jittery inter-frame timestamps to ideal spacing (eliminates overlaps/gaps from software-dispatch jitter)"> <label class="ctrl-check" title="Snap jittery inter-frame timestamps to ideal spacing (eliminates overlaps/gaps from software-dispatch jitter)">
<input type="checkbox" id="cb-monotonic"> Sync TS <input type="checkbox" id="cb-monotonic">
Sync TS
</label> </label>
</div> </div>
<!-- ── Trigger bar ───────────────────────────────────────────── --> <!-- ── Trigger bar ───────────────────────────────────────────── -->
@@ -70,10 +74,19 @@
<div class="trig-group"> <div class="trig-group">
<span class="trig-label">Window</span> <span class="trig-label">Window</span>
<select id="trig-window" class="trig-select"> <select id="trig-window" class="trig-select">
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option> <option value="0.0001">100 μs</option><option value="0.0002">200 μs</option>
<option value="0.01">10 ms</option><option value="0.1">100 ms</option> <option value="0.0005">500 μs</option><option value="0.001">1 ms</option>
<option value="0.5">500 ms</option><option value="1" selected>1 s</option> <option value="0.002">2 ms</option><option value="0.005">5 ms</option>
<option value="0.01">10 ms</option><option value="0.02">20 ms</option>
<option value="0.05">50 ms</option><option value="0.1">100 ms</option>
<option value="0.2">200 ms</option><option value="0.5">500 ms</option>
<option value="1" selected>1 s</option><option value="2">2 s</option>
<option value="5">5 s</option><option value="10">10 s</option> <option value="5">5 s</option><option value="10">10 s</option>
<option value="20">20 s</option><option value="30">30 s</option>
<option value="60">60 s</option>
<option value="120">2 m</option>
<option value="300">5 m</option>
<option value="600">10 m</option>
</select> </select>
</div> </div>
<div class="trig-sep"></div> <div class="trig-sep"></div>
@@ -83,6 +96,11 @@
<span class="trig-range-val" id="trig-pre-val">20%</span> <span class="trig-range-val" id="trig-pre-val">20%</span>
</div> </div>
<div class="trig-sep"></div> <div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label" title="Re-arm delay after a capture — prevents double triggering">Holdoff</span>
<input id="trig-holdoff" class="trig-input" type="number" min="0" max="60" step="0.01" value="0.2">
<span class="trig-label">s</span>
</div>
<div class="trig-group"> <div class="trig-group">
<span class="trig-label">Mode</span> <span class="trig-label">Mode</span>
<select id="trig-mode" class="trig-select"> <select id="trig-mode" class="trig-select">
@@ -124,10 +142,30 @@
<span id="status-text">Disconnected</span> <span id="status-text">Disconnected</span>
<span id="sb-tsage"></span> <span id="sb-tsage"></span>
<button id="btn-stats" class="ctrl-btn" style="height:16px;padding:0 7px;font-size:10px;line-height:1">📊 Stats</button> <button id="btn-stats" class="ctrl-btn" style="height:16px;padding:0 7px;font-size:10px;line-height:1">📊 Stats</button>
<span id="history-badge" style="display:none;font-size:10px;color:#f9e2af;margin-left:8px"></span> <button id="history-badge" style="display:none" title="Disk history — click to set the per-signal budget"></button>
</div> </div>
<span id="build-version"></span> <span id="build-version"></span>
</div> </div>
<!-- ── History budget popup ──────────────────────────────────── -->
<div id="history-panel" style="display:none">
<div class="ctx-menu-header">Disk history budget</div>
<div class="ctx-row">
<label>Budget</label>
<input type="number" id="hist-budget" class="ctx-num" min="0.001" step="1">
<span class="ctx-range-val">MPts/signal</span>
</div>
<div class="hist-note">
The budget buys resolution, not duration: a signal too fast to store
sample-for-sample is archived as a min/max envelope wide enough to fit,
so the configured window is always covered.
</div>
<div id="hist-signal-res"></div>
<div class="hist-note hist-warn">Applying re-creates the history files — archived data is lost.</div>
<div class="ctx-row" style="margin:0;justify-content:flex-end">
<button class="ctx-btn" id="btn-hist-cancel">Cancel</button>
<button class="ctx-btn" id="btn-hist-apply">Apply</button>
</div>
</div>
<div id="layout-menu"></div> <div id="layout-menu"></div>
<!-- ── Signal style context menu ─────────────────────────────── --> <!-- ── Signal style context menu ─────────────────────────────── -->
<div id="sig-ctx-menu" style="display:none"> <div id="sig-ctx-menu" style="display:none">
@@ -172,7 +210,8 @@
</div> </div>
<!-- ── Array index picker (trigger signal) ──────────────────────── --> <!-- ── Array index picker (trigger signal) ──────────────────────── -->
<div id="array-idx-picker" style="display:none"> <div id="array-idx-picker" style="display:none">
<div class="ctx-menu-header">Element index: <span id="aip-sig" class="ctx-menu-key"></span></div> <div class="ctx-menu-header">Element index:
<span id="aip-sig" class="ctx-menu-key"></span></div>
<div class="ctx-row"> <div class="ctx-row">
<label>Index</label> <label>Index</label>
<input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0"> <input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0">
@@ -186,7 +225,8 @@
<!-- ── VScale toolbar (moved into plot card when active) ─────────── --> <!-- ── VScale toolbar (moved into plot card when active) ─────────── -->
<div id="vscale-menu" style="display:none"> <div id="vscale-menu" style="display:none">
<div class="vstb-header"> <div class="vstb-header">
<span class="vstb-label">V-Scale: <span id="vscale-menu-key" class="ctx-menu-key"></span></span> <span class="vstb-label"><span id="vscale-menu-title">V-Scale</span>:
<span id="vscale-menu-key" class="ctx-menu-key"></span></span>
<div class="ctx-btns" id="vscale-mode-btns"> <div class="ctx-btns" id="vscale-mode-btns">
<button class="ctx-btn active" data-mode="auto">Auto</button> <button class="ctx-btn active" data-mode="auto">Auto</button>
<button class="ctx-btn" data-mode="range">Range</button> <button class="ctx-btn" data-mode="range">Range</button>
@@ -200,10 +240,6 @@
<label class="vstb-lbl" title="Raw value at screen centre — unbounded, may lie outside the plotted range">Offset</label> <label class="vstb-lbl" title="Raw value at screen centre — unbounded, may lie outside the plotted range">Offset</label>
<input type="number" id="vscale-offset" class="ctx-num" step="any" value="0"> <input type="number" id="vscale-offset" class="ctx-num" step="any" value="0">
</div> </div>
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">Pos</label>
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
</div>
<div id="vscale-type-row" style="display:none;align-items:center;gap:4px"> <div id="vscale-type-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">Type</label> <label class="vstb-lbl">Type</label>
<div class="ctx-btns" id="vscale-type-btns"> <div class="ctx-btns" id="vscale-type-btns">
@@ -211,11 +247,23 @@
<button class="ctx-btn" data-type="digital">Digital</button> <button class="ctx-btn" data-type="digital">Digital</button>
</div> </div>
</div> </div>
<div class="vstb-sep"></div>
<div id="vscale-cal-row" style="display:flex;align-items:center;gap:4px">
<label class="vstb-lbl" id="vscale-cal-lbl" title="Data calibration: value = raw × Scale + Offset. Applies to the plot, cursors, hover readout, CSV export and trigger threshold.">Cal</label>
<label class="vstb-lbl">Scale</label>
<input type="number" id="vscale-cal-scale" class="ctx-num ctx-num-sm" step="any" value="1">
<label class="vstb-lbl">Offset</label>
<input type="number" id="vscale-cal-offset" class="ctx-num ctx-num-sm" step="any" value="0">
<label class="vstb-lbl">Unit</label>
<input type="text" id="vscale-cal-unit" class="ctx-num ctx-num-xs" placeholder="—">
<button class="ctx-btn" id="btn-cal-reset" title="Clear this signal's calibration">Reset</button>
</div>
<button id="btn-vscale-close" class="vstb-close" title="Close"></button> <button id="btn-vscale-close" class="vstb-close" title="Close"></button>
</div> </div>
</div> </div>
<!-- Follows the mouse over a plot: time + per-trace values. --> <!-- Follows the mouse over a plot: time + per-trace values. -->
<div id="hover-readout" style="display:none"></div> <div id="hover-readout" style="display:none"></div>
<script src="/calibration.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>
-39
View File
@@ -1,39 +0,0 @@
'use strict';
// LTTB (Largest Triangle Three Buckets) decimation — O(n).
// Runs off-main-thread to avoid blocking the render loop.
function lttb(t, v, threshold) {
const len = t.length;
if (len <= threshold) {
// Copy to new arrays so we can transfer them back without detaching the input.
return { t: new Float64Array(t), v: new Float64Array(v) };
}
const outT = new Float64Array(threshold);
const outV = new Float64Array(threshold);
outT[0] = t[0]; outV[0] = v[0];
outT[threshold - 1] = t[len - 1]; outV[threshold - 1] = v[len - 1];
const every = (len - 2) / (threshold - 2);
let a = 0;
for (let i = 0; i < threshold - 2; i++) {
const avgS = Math.floor((i + 1) * every) + 1;
const avgE = Math.min(Math.floor((i + 2) * every) + 1, len);
let avgT = 0, avgV = 0, n = 0;
for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; }
if (n) { avgT /= n; avgV /= n; }
const rS = Math.floor(i * every) + 1;
const rE = Math.min(Math.floor((i + 1) * every) + 1, len);
let maxA = -1, next = rS;
const aT = t[a], aV = v[a];
for (let j = rS; j < rE; j++) {
const area = Math.abs((aT - avgT) * (v[j] - aV) - (aT - t[j]) * (avgV - aV));
if (area > maxA) { maxA = area; next = j; }
}
outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next;
}
return { t: outT, v: outV };
}
self.onmessage = function({ data: { id, t, v, threshold } }) {
const result = lttb(t, v, threshold);
// Transfer the output buffers back to the main thread zero-copy.
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
};
+50 -19
View File
@@ -141,10 +141,17 @@ input[type=range].trig-range::-webkit-slider-thumb {
#trig-status-badge.armed { background:rgba(166,227,161,0.12); border-color:var(--green); color:var(--green); } #trig-status-badge.armed { background:rgba(166,227,161,0.12); border-color:var(--green); color:var(--green); }
#trig-status-badge.waiting { background:rgba(249,226,175,0.12); border-color:var(--yellow); color:var(--yellow); } #trig-status-badge.waiting { background:rgba(249,226,175,0.12); border-color:var(--yellow); color:var(--yellow); }
#trig-status-badge.triggered { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); } #trig-status-badge.triggered { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); }
#btn-trig-rearm, #btn-trig-stop { #btn-trig-force, #btn-trig-rearm, #btn-trig-stop {
border:none; border-radius:5px; border:none; border-radius:5px;
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer; display:none; padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer;
} }
#btn-trig-rearm, #btn-trig-stop { display:none; }
#btn-trig-force {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1);
transition:background var(--transition),border-color var(--transition),color var(--transition);
}
#btn-trig-force:hover { background:var(--surface1); border-color:var(--mauve); color:var(--mauve); }
#btn-trig-rearm { background:var(--mauve); color:var(--crust); } #btn-trig-rearm { background:var(--mauve); color:var(--crust); }
#btn-trig-stop { background:var(--surface1); color:var(--yellow); border:1px solid var(--yellow); } #btn-trig-stop { background:var(--surface1); color:var(--yellow); border:1px solid var(--yellow); }
#btn-trig-rearm:hover, #btn-trig-stop:hover { opacity:0.85; } #btn-trig-rearm:hover, #btn-trig-stop:hover { opacity:0.85; }
@@ -192,23 +199,6 @@ input[type=range].trig-range::-webkit-slider-thumb {
.sig-name { flex:1; font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .sig-name { flex:1; font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sig-unit { font-size:11px; color:var(--subtext0); font-style:italic; } .sig-unit { font-size:11px; color:var(--subtext0); font-style:italic; }
.type-badge { font-size:10px; background:var(--surface1); color:var(--subtext1); padding:1px 5px; border-radius:3px; white-space:nowrap; } .type-badge { font-size:10px; background:var(--surface1); color:var(--subtext1); padding:1px 5px; border-radius:3px; white-space:nowrap; }
.array-group {}
.array-header {
padding:6px 14px 6px 10px; cursor:pointer; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:6px; user-select:none;
}
.array-header:hover { background:var(--surface0); }
.array-arrow { font-size:10px; color:var(--subtext0); transition:transform var(--transition); display:inline-block; }
.array-header.open .array-arrow { transform:rotate(90deg); }
.array-children { display:none; padding-left:16px; }
.array-header.open + .array-children { display:block; }
.array-child {
padding:4px 14px 4px 8px; cursor:grab; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:8px;
user-select:none; color:var(--subtext1); font-size:12px;
}
.array-child:hover { background:var(--surface0); }
.array-child:active { cursor:grabbing; }
/* ── Main area ────────────────────────────────────────────────── */ /* ── Main area ────────────────────────────────────────────────── */
#main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; } #main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
@@ -324,6 +314,32 @@ input[type=range].trig-range::-webkit-slider-thumb {
border:1px solid var(--mauve); border:1px solid var(--mauve);
} }
/* ── History budget ───────────────────────────────────────────── */
#history-badge {
font-size:10px; color:var(--yellow); margin-left:8px; cursor:pointer;
background:transparent; border:1px solid transparent; border-radius:4px;
padding:1px 5px; white-space:nowrap;
}
#history-badge:hover { border-color:var(--yellow); background:rgba(249,226,175,0.10); }
#history-panel {
position:fixed; z-index:300;
background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius);
box-shadow:0 8px 24px rgba(0,0,0,0.6); padding:10px; width:290px;
}
.hist-note { font-size:10px; color:var(--overlay0); line-height:1.4; margin:6px 0; }
.hist-warn { color:var(--peach); }
#hist-signal-res {
font-size:10px; font-family:monospace; color:var(--subtext0);
max-height:120px; overflow-y:auto;
border-top:1px solid var(--surface0); border-bottom:1px solid var(--surface0);
padding:5px 0;
}
.hist-res-row { display:flex; justify-content:space-between; gap:8px; }
.hist-res-row .hist-res-key {
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--subtext1);
}
.hist-res-row .hist-res-val { color:var(--mauve); flex-shrink:0; }
/* ── Signal style context menu ────────────────────────────────── */ /* ── Signal style context menu ────────────────────────────────── */
#sig-ctx-menu { #sig-ctx-menu {
position:fixed; z-index:300; position:fixed; z-index:300;
@@ -386,6 +402,11 @@ input[type=range].trig-range::-webkit-slider-thumb {
} }
.vstb-close:hover { color:var(--red); } .vstb-close:hover { color:var(--red); }
.plot-vscale-bar { display:none; } .plot-vscale-bar { display:none; }
.vstb-sep { width:1px; height:16px; background:var(--surface1); flex-shrink:0; }
.ctx-num-sm { width:70px; }
.ctx-num-xs { width:46px; }
#vscale-cal-lbl { color:var(--mauve); font-weight:600; }
.cal-invalid { border-color:var(--red) !important; }
/* ── Per-plot cursor value readout (in plot card header) ────────── */ /* ── Per-plot cursor value readout (in plot card header) ────────── */
.plot-cursor-ro { .plot-cursor-ro {
@@ -473,6 +494,16 @@ input[type=range].trig-range::-webkit-slider-thumb {
.add-src-btn:hover { background:rgba(137,180,250,0.15); border-color:var(--accent); } .add-src-btn:hover { background:rgba(137,180,250,0.15); border-color:var(--accent); }
.save-src-btn { color:var(--green); } .save-src-btn { color:var(--green); }
.save-src-btn:hover { background:rgba(166,227,161,0.1); border-color:var(--green); } .save-src-btn:hover { background:rgba(166,227,161,0.1); border-color:var(--green); }
.cfg-btn-row { display:flex; gap:6px; }
.cfg-btn-row .add-src-btn { flex:1; }
.reload-src-btn { color:var(--peach); }
.reload-src-btn:hover { background:rgba(250,179,135,0.1); border-color:var(--peach); }
.cfg-status {
font-size:10px; line-height:1.3; padding:2px 0; min-height:13px;
overflow-wrap:anywhere;
}
.cfg-status.ok { color:var(--green); }
.cfg-status.err { color:var(--red); }
/* ── Stats panel ─────────────────────────────────────────────── */ /* ── Stats panel ─────────────────────────────────────────────── */
#stats-panel { #stats-panel {
+158
View File
@@ -0,0 +1,158 @@
const test = require('node:test');
const assert = require('node:assert');
const C = require('../static/calibration.js');
test('baseSignalName strips an element suffix', () => {
assert.strictEqual(C.baseSignalName('Adc'), 'Adc');
assert.strictEqual(C.baseSignalName('Adc[3]'), 'Adc');
assert.strictEqual(C.baseSignalName('Adc[12]'), 'Adc');
assert.strictEqual(C.baseSignalName('A[1]B'), 'A[1]B');
assert.strictEqual(C.baseSignalName(''), '');
// A name that is entirely the suffix "[0]" must reduce to the empty string,
// matching Go (arrayIndexSuffix regexp) and C++ (strchr truncation) behaviour.
assert.strictEqual(C.baseSignalName('[0]'), '');
});
test('calKey is stable and separates the two fields', () => {
assert.strictEqual(C.calKey('a', 'b'), C.calKey('a', 'b'));
assert.notStrictEqual(C.calKey('ab', 'c'), C.calKey('a', 'bc'));
});
test('normaliseCal accepts a valid entry and fills defaults', () => {
assert.deepStrictEqual(
C.normaliseCal({source: ' wave ', signal: ' Adc ', scale: 2, offset: -1, unit: ' V '}),
{source: 'wave', signal: 'Adc', scale: 2, offset: -1, unit: 'V'});
assert.deepStrictEqual(
C.normaliseCal({source: 'wave', signal: 'Adc'}),
{source: 'wave', signal: 'Adc', scale: 1, offset: 0, unit: ''});
});
test('normaliseCal strips an element suffix from the signal name', () => {
assert.strictEqual(C.normaliseCal({source: 'w', signal: 'Adc[3]'}).signal, 'Adc');
});
test('normaliseCal truncates an over-long unit', () => {
const long = 'abcdefghijklmnopqrstuvwxyz';
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: long}).unit,
long.slice(0, C.MAX_UNIT_LEN));
});
test('normaliseCal leaves short non-ASCII units untouched', () => {
// 'Ω' is U+03A9, 2 UTF-8 bytes — well within 16 bytes.
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: 'Ω'}).unit, 'Ω');
// 'µs' is U+00B5 + U+0073, 3 UTF-8 bytes.
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: 'µs'}).unit, 'µs');
// '°C' is U+00B0 + U+0043, 3 UTF-8 bytes.
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: '°C'}).unit, '°C');
});
test('normaliseCal truncates an over-long ASCII unit to exactly 16 bytes', () => {
// 20 ASCII characters — each 1 byte, so cut at character 16.
const long = 'abcdefghijklmnopqrst'; // 20 chars
const result = C.normaliseCal({source: 'w', signal: 's', unit: long}).unit;
assert.strictEqual(result, 'abcdefghijklmnop'); // first 16 bytes/chars
assert.strictEqual(new TextEncoder().encode(result).length, 16);
});
test('normaliseCal cuts a mid-rune byte boundary back to the last complete rune', () => {
// Each 'Ω' (U+03A9) is 2 UTF-8 bytes (CE A9).
// 8 × 'Ω' = 16 bytes exactly — fits without truncation.
const fits = 'ΩΩΩΩΩΩΩΩ';
assert.strictEqual(new TextEncoder().encode(fits).length, 16);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: fits}).unit, fits);
// 9 × 'Ω' = 18 bytes. Slicing at 16 bytes lands in the middle of the 9th
// 'Ω' (only 1 of its 2 bytes is in the window) so only 8 'Ω' should survive.
// No U+FFFD replacement character must appear.
const toolong = 'ΩΩΩΩΩΩΩΩΩ';
const result = C.normaliseCal({source: 'w', signal: 's', unit: toolong}).unit;
assert.strictEqual(result, 'ΩΩΩΩΩΩΩΩ');
assert.ok(!result.includes('\uFFFD'), 'must not contain U+FFFD replacement character');
assert.strictEqual(new TextEncoder().encode(result).length, 16);
});
test('normaliseCal leaves a unit that is exactly 16 bytes ending on a complete multi-byte rune untouched', () => {
// 'abcdefgΩhijklµ' → 7 ASCII + 'Ω' (2 bytes) + 5 ASCII + 'µ' (2 bytes) = 16 bytes
const u = 'abcdefgΩhijklµ';
assert.strictEqual(new TextEncoder().encode(u).length, 16);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: u}).unit, u);
});
test('normaliseCal rejects invalid entries', () => {
assert.strictEqual(C.normaliseCal(null), null);
assert.strictEqual(C.normaliseCal({signal: 's'}), null);
assert.strictEqual(C.normaliseCal({source: 'w'}), null);
assert.strictEqual(C.normaliseCal({source: ' ', signal: 's'}), null);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: 0}), null);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: NaN}), null);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: Infinity}), null);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', offset: NaN}), null);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: '2'}), null);
// A signal name that is entirely an array-index suffix reduces to the empty
// string after stripping, so the entry must be rejected — matching Go and C++.
assert.strictEqual(C.normaliseCal({source: 'w', signal: '[0]'}), null);
});
test('applyCal and invertCal round-trip', () => {
const cal = {scale: 0.5, offset: -1.25, unit: 'V'};
assert.strictEqual(C.applyCal(10, cal), 3.75);
assert.strictEqual(C.invertCal(3.75, cal), 10);
assert.strictEqual(C.applyCal(7, C.IDENTITY), 7);
assert.strictEqual(C.invertCal(7, C.IDENTITY), 7);
});
test('applyCal passes non-finite samples through untouched', () => {
assert.ok(Number.isNaN(C.applyCal(NaN, {scale: 2, offset: 1, unit: ''})));
});
test('calRange re-orders when the scale is negative', () => {
assert.deepStrictEqual(C.calRange(0, 10, {scale: 2, offset: 1, unit: ''}), [1, 21]);
assert.deepStrictEqual(C.calRange(0, 10, {scale: -2, offset: 1, unit: ''}), [-19, 1]);
});
test('CalTable.get returns IDENTITY for an unknown signal', () => {
const t = new C.CalTable();
assert.deepStrictEqual(t.get('w', 'Adc'), C.IDENTITY);
});
test('CalTable.get resolves an element name to its base signal', () => {
const t = new C.CalTable();
t.set({source: 'w', signal: 'Adc', scale: 3, offset: 0, unit: ''});
assert.strictEqual(t.get('w', 'Adc[7]').scale, 3);
});
test('CalTable.set stores, overwrites, and deletes identity entries', () => {
const t = new C.CalTable();
assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 2}), true);
assert.strictEqual(t.get('w', 'Adc').scale, 2);
t.set({source: 'w', signal: 'Adc', scale: 5});
assert.strictEqual(t.get('w', 'Adc').scale, 5);
assert.strictEqual(t.list().length, 1);
// Resetting to identity removes the entry entirely.
assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 1, offset: 0, unit: ''}), true);
assert.strictEqual(t.list().length, 0);
// An invalid entry is refused and changes nothing.
assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 0}), false);
assert.strictEqual(t.list().length, 0);
});
test('CalTable.replaceAll drops the previous contents', () => {
const t = new C.CalTable();
t.set({source: 'w', signal: 'Old', scale: 2});
t.replaceAll([
{source: 'w', signal: 'B', scale: 2},
{source: 'w', signal: 'A', scale: 3},
{source: 'w', signal: 'Bad', scale: 0},
{source: 'w', signal: 'Ident', scale: 1, offset: 0, unit: ''},
]);
assert.deepStrictEqual(t.list().map(e => e.signal), ['A', 'B']);
});
test('CalTable.list is sorted by source then signal', () => {
const t = new C.CalTable();
t.set({source: 'z', signal: 'a', scale: 2});
t.set({source: 'a', signal: 'z', scale: 2});
t.set({source: 'a', signal: 'b', scale: 2});
assert.deepStrictEqual(t.list().map(e => e.source + '/' + e.signal),
['a/b', 'a/z', 'z/a']);
});
+234
View File
@@ -0,0 +1,234 @@
package wshub
import (
"encoding/json"
"log"
"math"
"regexp"
"sort"
"strings"
"sync"
"unicode/utf8"
)
// arrayIndexSuffix matches a trailing "[digits]" at the very end of a signal
// name, used to strip array-element suffixes so one entry covers the whole
// array. The regexp is anchored to the end of the string and requires digits,
// so it only removes a well-formed trailing element index: "Adc[3]" → "Adc",
// "[0]" → "", "A[1]B" → "A[1]B" (no match).
//
// Known difference vs C++: the C++ hub uses strchr(signal,'[') which finds the
// FIRST '[' anywhere in the name, so C++ reduces "A[1]B" to "A" while this
// regexp leaves it unchanged. Both implementations agree on the common cases
// ("Name[i]" and "[i]" alone) that arise from real UDPS signal names.
var arrayIndexSuffix = regexp.MustCompile(`\[\d+\]$`)
// maxUnitLen bounds the calibration unit override. Mirrored by kMaxUnitLen in
// the C++ StreamHub and MAX_UNIT_LEN in the SPA's calibration.js.
const maxUnitLen = 16
// CalConfig is one per-signal affine calibration: y = raw*Scale + Offset.
//
// The key is (Source label, base Signal name). It is deliberately the source
// *label* and not the runtime id ("s1", "s2"): ids are assigned in add-order at
// startup, so a calibration keyed by id would rebind to a different source
// whenever the source list order changed.
type CalConfig struct {
Source string `json:"source"`
Signal string `json:"signal"`
Scale float64 `json:"scale"`
Offset float64 `json:"offset"`
Unit string `json:"unit,omitempty"`
}
// calKey builds the calTable map key. NUL cannot occur in either component,
// so the concatenation is unambiguous.
func calKey(source, signal string) string { return source + "\x00" + signal }
// Normalise trims and validates the entry in place, reporting whether it is
// usable. A zero or non-finite Scale is rejected because it makes the
// calibration non-invertible, which the trigger threshold path depends on.
func (c *CalConfig) Normalise() bool {
c.Source = strings.TrimSpace(c.Source)
c.Signal = strings.TrimSpace(c.Signal)
// Strip a trailing "[digits]" suffix so one entry covers an entire array
// signal. "Adc[3]" → "Adc". Must run before the empty check below so
// that "[0]" → "" → rejected, matching C++ and JS behaviour.
c.Signal = arrayIndexSuffix.ReplaceAllString(c.Signal, "")
if c.Source == "" || c.Signal == "" {
return false
}
if math.IsNaN(c.Scale) || math.IsInf(c.Scale, 0) || c.Scale == 0 {
return false
}
if math.IsNaN(c.Offset) || math.IsInf(c.Offset, 0) {
return false
}
c.Unit = strings.TrimSpace(c.Unit)
if len(c.Unit) > maxUnitLen {
c.Unit = c.Unit[:maxUnitLen]
// The byte cut may land mid-rune. Drop any trailing partial rune so
// the result is always valid UTF-8; json.Marshal would otherwise emit
// replacement characters and break the save→load round-trip.
for {
r, size := utf8.DecodeLastRuneInString(c.Unit)
if r != utf8.RuneError || size != 1 {
break
}
c.Unit = c.Unit[:len(c.Unit)-1]
}
}
return true
}
// IsIdentity reports whether the entry carries no information and can be
// dropped rather than stored and persisted.
func (c CalConfig) IsIdentity() bool {
return c.Scale == 1 && c.Offset == 0 && c.Unit == ""
}
// calTable is the hub's calibration store, safe for concurrent use.
type calTable struct {
mu sync.RWMutex
entries map[string]CalConfig
}
func newCalTable() *calTable {
return &calTable{entries: make(map[string]CalConfig)}
}
// Set validates and stores one entry, reporting whether it was accepted.
// Storing an identity entry removes any existing one for that key.
func (t *calTable) Set(c CalConfig) bool {
if !c.Normalise() {
return false
}
t.mu.Lock()
defer t.mu.Unlock()
if c.IsIdentity() {
delete(t.entries, calKey(c.Source, c.Signal))
} else {
t.entries[calKey(c.Source, c.Signal)] = c
}
return true
}
// Replace swaps the whole table for the given entries, silently dropping the
// invalid and identity ones. Used by config load and reload.
func (t *calTable) Replace(list []CalConfig) {
next := make(map[string]CalConfig, len(list))
for _, c := range list {
if !c.Normalise() || c.IsIdentity() {
continue
}
next[calKey(c.Source, c.Signal)] = c
}
t.mu.Lock()
t.entries = next
t.mu.Unlock()
}
// List returns the entries sorted by source then signal, so both the wire
// message and the config file have a stable order.
func (t *calTable) List() []CalConfig {
t.mu.RLock()
out := make([]CalConfig, 0, len(t.entries))
for _, c := range t.entries {
out = append(out, c)
}
t.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].Source != out[j].Source {
return out[i].Source < out[j].Source
}
return out[i].Signal < out[j].Signal
})
return out
}
// ─── Config file codec ────────────────────────────────────────────────────────
// configFileEntry is the union of a source block and a calibration block.
//
// The file is one FLAT array of FLAT objects — never a nested one. The C++
// StreamHub's LoadSourcesFile is a hand-rolled scanner that takes each "{" up
// to the next "}" as one object, so a nested block would truncate the parse.
// Scale and Offset are pointers so that an absent field can be told apart from
// an explicit zero and defaulted to the identity values.
type configFileEntry struct {
// Source fields.
Label string `json:"label,omitempty"`
Addr string `json:"addr,omitempty"`
MulticastGroup string `json:"multicastGroup,omitempty"`
DataPort int `json:"dataPort,omitempty"`
// Calibration fields.
Source string `json:"source,omitempty"`
Signal string `json:"signal,omitempty"`
Scale *float64 `json:"scale,omitempty"`
Offset *float64 `json:"offset,omitempty"`
Unit string `json:"unit,omitempty"`
}
// parseConfigFile splits the flat array into sources and calibration entries.
// A block with "addr" is a source, one with "signal" is a calibration; anything
// else is skipped with a warning.
func parseConfigFile(data []byte) ([]SourceConfig, []CalConfig, error) {
var raw []configFileEntry
if err := json.Unmarshal(data, &raw); err != nil {
return nil, nil, err
}
srcs := make([]SourceConfig, 0, len(raw))
cals := make([]CalConfig, 0, len(raw))
for _, e := range raw {
switch {
case e.Addr != "":
srcs = append(srcs, SourceConfig{
Label: e.Label,
Addr: e.Addr,
MulticastGroup: e.MulticastGroup,
DataPort: e.DataPort,
})
case e.Signal != "":
c := CalConfig{Source: e.Source, Signal: e.Signal, Scale: 1, Offset: 0, Unit: e.Unit}
if e.Scale != nil {
c.Scale = *e.Scale
}
if e.Offset != nil {
c.Offset = *e.Offset
}
if !c.Normalise() {
log.Printf("wshub: skipping invalid calibration entry %q/%q", e.Source, e.Signal)
continue
}
cals = append(cals, c)
default:
log.Printf("wshub: skipping unrecognised config block")
}
}
return srcs, cals, nil
}
// encodeConfigFile renders the sources followed by the calibration entries as
// one flat array, in the indented shape the existing files already use.
func encodeConfigFile(srcs []SourceConfig, cals []CalConfig) ([]byte, error) {
out := make([]configFileEntry, 0, len(srcs)+len(cals))
for _, s := range srcs {
out = append(out, configFileEntry{
Label: s.Label,
Addr: s.Addr,
MulticastGroup: s.MulticastGroup,
DataPort: s.DataPort,
})
}
for _, c := range cals {
scale, offset := c.Scale, c.Offset
out = append(out, configFileEntry{
Source: c.Source,
Signal: c.Signal,
Scale: &scale,
Offset: &offset,
Unit: c.Unit,
})
}
return json.MarshalIndent(out, "", " ")
}
+237
View File
@@ -0,0 +1,237 @@
package wshub
import (
"math"
"strings"
"testing"
"unicode/utf8"
)
func TestCalConfigNormalise(t *testing.T) {
cases := []struct {
name string
in CalConfig
want bool
wantUnit string
}{
{"plain", CalConfig{Source: "wave", Signal: "Adc", Scale: 2, Offset: -1, Unit: "V"}, true, "V"},
{"trims", CalConfig{Source: " wave ", Signal: " Adc ", Scale: 1, Unit: " V "}, true, "V"},
{"emptySource", CalConfig{Signal: "Adc", Scale: 1}, false, ""},
{"emptySignal", CalConfig{Source: "wave", Scale: 1}, false, ""},
{"zeroScale", CalConfig{Source: "wave", Signal: "Adc", Scale: 0}, false, ""},
{"nanScale", CalConfig{Source: "wave", Signal: "Adc", Scale: math.NaN()}, false, ""},
{"infScale", CalConfig{Source: "wave", Signal: "Adc", Scale: math.Inf(1)}, false, ""},
{"nanOffset", CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Offset: math.NaN()}, false, ""},
{"infOffset", CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Offset: math.Inf(-1)}, false, ""},
{"negScaleOK", CalConfig{Source: "wave", Signal: "Adc", Scale: -1}, true, ""},
{"longUnit", CalConfig{Source: "wave", Signal: "Adc", Scale: 1,
Unit: "0123456789abcdefGHIJ"}, true, "0123456789abcdef"},
// Finding 1: array-element suffix stripping for cross-implementation parity.
{"arrayIndex3", CalConfig{Source: "wave", Signal: "Adc[3]", Scale: 1}, true, ""},
{"arrayIndex12", CalConfig{Source: "wave", Signal: "Adc[12]", Scale: 1}, true, ""},
{"arrayNoSuffix", CalConfig{Source: "wave", Signal: "Adc", Scale: 1}, true, ""},
{"arrayMidBracket", CalConfig{Source: "wave", Signal: "A[1]B", Scale: 1}, true, ""},
{"arrayNonNumeric", CalConfig{Source: "wave", Signal: "Adc[x]", Scale: 1}, true, ""},
{"arrayZeroOnly", CalConfig{Source: "wave", Signal: "[0]", Scale: 1}, false, ""},
}
for _, c := range cases {
got := c.in
if ok := got.Normalise(); ok != c.want {
t.Errorf("%s: Normalise() = %v, want %v", c.name, ok, c.want)
continue
}
if c.want && got.Unit != c.wantUnit {
t.Errorf("%s: Unit = %q, want %q", c.name, got.Unit, c.wantUnit)
}
}
if len("0123456789abcdef") != maxUnitLen {
t.Fatalf("test assumes maxUnitLen == 16, got %d", maxUnitLen)
}
// Verify stripped Signal values for array-index cases.
arraySignalCases := []struct {
input string
want string
}{
{"Adc[3]", "Adc"},
{"Adc[12]", "Adc"},
{"Adc", "Adc"},
{"A[1]B", "A[1]B"},
{"Adc[x]", "Adc[x]"},
}
for _, ac := range arraySignalCases {
got := CalConfig{Source: "wave", Signal: ac.input, Scale: 1}
got.Normalise()
if got.Signal != ac.want {
t.Errorf("Signal strip %q: got %q, want %q", ac.input, got.Signal, ac.want)
}
}
// Finding 2: UTF-8 unit truncation must not split a multi-byte rune.
// "°" is U+00B0, encoded as 2 bytes in UTF-8.
degree := "°"
if len(degree) != 2 {
t.Fatalf("test expects '°' to be 2 bytes, got %d", len(degree))
}
unit16 := strings.Repeat(degree, 8) // exactly 16 bytes — must survive intact
c8 := CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Unit: unit16}
c8.Normalise()
if c8.Unit != unit16 {
t.Errorf("16-byte degree unit mangled: got %q, want %q", c8.Unit, unit16)
}
if !utf8.ValidString(c8.Unit) {
t.Errorf("16-byte degree unit is not valid UTF-8: %q", c8.Unit)
}
unit18 := strings.Repeat(degree, 9) // 18 bytes — must truncate to 8 degrees (16 bytes), not 16 bytes with a broken half-rune
c9 := CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Unit: unit18}
c9.Normalise()
if c9.Unit != unit16 {
t.Errorf("18-byte degree unit truncated to %q, want %q", c9.Unit, unit16)
}
if !utf8.ValidString(c9.Unit) {
t.Errorf("truncated degree unit is not valid UTF-8: %q", c9.Unit)
}
}
func TestCalTableSetListAndIdentityRemoval(t *testing.T) {
tab := newCalTable()
if !tab.Set(CalConfig{Source: "b", Signal: "Y", Scale: 3, Offset: 1, Unit: "A"}) {
t.Fatal("Set(b/Y) rejected")
}
if !tab.Set(CalConfig{Source: "a", Signal: "X", Scale: 2}) {
t.Fatal("Set(a/X) rejected")
}
if tab.Set(CalConfig{Source: "a", Signal: "Z", Scale: 0}) {
t.Error("Set with scale=0 accepted, want rejected")
}
got := tab.List()
if len(got) != 2 {
t.Fatalf("List() = %d entries, want 2", len(got))
}
// Sorted by source then signal.
if got[0].Source != "a" || got[1].Source != "b" {
t.Errorf("List() order = %q,%q, want a,b", got[0].Source, got[1].Source)
}
// An identity entry removes the stored one.
if !tab.Set(CalConfig{Source: "a", Signal: "X", Scale: 1, Offset: 0, Unit: ""}) {
t.Fatal("identity Set rejected")
}
if got := tab.List(); len(got) != 1 || got[0].Source != "b" {
t.Errorf("after identity Set, List() = %+v, want only b/Y", got)
}
}
func TestCalTableReplace(t *testing.T) {
tab := newCalTable()
tab.Set(CalConfig{Source: "old", Signal: "X", Scale: 5})
tab.Replace([]CalConfig{
{Source: "new", Signal: "Y", Scale: 2},
{Source: "bad", Signal: "Z", Scale: 0}, // invalid → dropped
{Source: "id", Signal: "W", Scale: 1, Offset: 0, Unit: ""}, // identity → dropped
})
got := tab.List()
if len(got) != 1 || got[0].Source != "new" {
t.Fatalf("List() = %+v, want only new/Y", got)
}
}
func TestParseConfigFileCurrentFormat(t *testing.T) {
// A file written by the current binaries — sources only, spaces after colons.
data := []byte(`[
{
"label": "wave",
"addr": "127.0.0.1:44500"
},
{
"label": "mc",
"addr": "127.0.0.1:44501",
"multicastGroup": "239.0.0.1",
"dataPort": 44502
}
]`)
srcs, cals, err := parseConfigFile(data)
if err != nil {
t.Fatalf("parseConfigFile: %v", err)
}
if len(srcs) != 2 || len(cals) != 0 {
t.Fatalf("got %d sources / %d cals, want 2 / 0", len(srcs), len(cals))
}
if srcs[1].MulticastGroup != "239.0.0.1" || srcs[1].DataPort != 44502 {
t.Errorf("multicast source = %+v", srcs[1])
}
}
func TestParseConfigFileMixed(t *testing.T) {
data := []byte(`[
{"label":"wave","addr":"127.0.0.1:44500"},
{"source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"},
{"source":"wave","signal":"Bare"},
{"source":"wave","signal":"Bad","scale":0},
{"nonsense":true}
]`)
srcs, cals, err := parseConfigFile(data)
if err != nil {
t.Fatalf("parseConfigFile: %v", err)
}
if len(srcs) != 1 {
t.Fatalf("got %d sources, want 1", len(srcs))
}
if len(cals) != 2 {
t.Fatalf("got %d cals, want 2 (Adc and Bare; Bad is invalid)", len(cals))
}
if cals[0].Scale != 0.00030518 || cals[0].Offset != -1.25 || cals[0].Unit != "V" {
t.Errorf("Adc = %+v", cals[0])
}
// Absent scale/offset default to the identity values, not to zero.
if cals[1].Signal != "Bare" || cals[1].Scale != 1 || cals[1].Offset != 0 {
t.Errorf("Bare = %+v, want scale 1 / offset 0", cals[1])
}
}
func TestParseConfigFileMalformed(t *testing.T) {
if _, _, err := parseConfigFile([]byte("not json")); err == nil {
t.Error("parseConfigFile(garbage) = nil error, want error")
}
}
func TestEncodeConfigFileRoundTrip(t *testing.T) {
srcs := []SourceConfig{
{Label: "wave", Addr: "127.0.0.1:44500"},
{Label: "mc", Addr: "127.0.0.1:44501", MulticastGroup: "239.0.0.1", DataPort: 44502},
}
cals := []CalConfig{
{Source: "wave", Signal: "Adc", Scale: 0.5, Offset: 0, Unit: "V"},
}
data, err := encodeConfigFile(srcs, cals)
if err != nil {
t.Fatalf("encodeConfigFile: %v", err)
}
gotSrcs, gotCals, err := parseConfigFile(data)
if err != nil {
t.Fatalf("parseConfigFile(encoded): %v\n%s", err, data)
}
if len(gotSrcs) != 2 || len(gotCals) != 1 {
t.Fatalf("round-trip gave %d sources / %d cals, want 2 / 1\n%s",
len(gotSrcs), len(gotCals), data)
}
if gotSrcs[1] != srcs[1] {
t.Errorf("source round-trip: got %+v, want %+v", gotSrcs[1], srcs[1])
}
if gotCals[0] != cals[0] {
t.Errorf("cal round-trip: got %+v, want %+v", gotCals[0], cals[0])
}
// offset 0 must survive as an explicit field, not be dropped by omitempty.
if !bytesContains(data, []byte(`"offset": 0`)) {
t.Errorf("encoded file lost the zero offset:\n%s", data)
}
}
func bytesContains(hay, needle []byte) bool {
for i := 0; i+len(needle) <= len(hay); i++ {
if string(hay[i:i+len(needle)]) == string(needle) {
return true
}
}
return false
}
@@ -0,0 +1,168 @@
package wshub
import (
"encoding/binary"
"math"
"testing"
)
// decodeCaptureSpan pulls the time extent of one signal out of a v2 frame.
func decodeCaptureSpan(t *testing.T, buf []byte, key string) (first, last float64, n int) {
t.Helper()
off := 1 + 8 + 8 + 8
nSig := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
for i := 0; i < nSig; i++ {
kl := int(binary.LittleEndian.Uint16(buf[off:]))
off += 2
k := string(buf[off : off+kl])
off += kl
cnt := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
if k == key && cnt > 0 {
first = math.Float64frombits(binary.LittleEndian.Uint64(buf[off:]))
last = math.Float64frombits(binary.LittleEndian.Uint64(buf[off+(cnt-1)*8:]))
n = cnt
}
off += cnt * 16
}
return
}
// The rings only reach back over the window once they have rolled over at the
// current bucket, which takes as long as the window itself — so a window widened
// mid-run leaves the first captures asking for history the rings never stored.
// The archive kept it, and the capture must come back whole.
func TestCaptureBackfillsItsHeadFromTheArchive(t *testing.T) {
h := NewHub()
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 60, Decimation: 1, MinDiskFreeMB: -1,
}, 1000)
h.hist = hw
// 20 s of 1 kSps, archived in full…
ts, vs := ramp(1000, 0.001, 20000)
hw.write(key, ts, vs)
// …but a ring that only ever holds the last 5 s of it.
rb := newSigRing(5000)
rb.write(ts, vs)
h.rings[key] = rb
// A 15 s window, of which the ring has the newest third.
const t0, t1 = 1005.0, 1020.0
buf := h.buildTriggerCapture(1015, 10, 5)
if buf == nil {
t.Fatal("no capture frame built")
}
first, last, n := decodeCaptureSpan(t, buf, key)
if first > t0+0.05 {
t.Errorf("capture starts at %.3f, want the window's start %.3f — the archive holds it",
first, t0)
}
if last < t1-0.05 {
t.Errorf("capture ends at %.3f, want %.3f", last, t1)
}
if n < 100 {
t.Errorf("capture has %d points, too few for a 15 s window at 1 kSps", n)
}
// The join between the two sources must not break time order, or every
// binary search over the capture — client-side and in the hold — misreads it.
ct, _, ok := h.capture.slice(key, t0, t1)
if !ok {
t.Fatal("the hold declined the window it just published")
}
for i := 1; i < len(ct); i++ {
if ct[i] < ct[i-1] {
t.Fatalf("capture time goes backwards at %d: %.6f then %.6f", i, ct[i-1], ct[i])
}
}
}
// A capture that neither source could fill must not answer for the stretch it is
// missing: the client has to fall through to the archive instead of redrawing
// the same hole on every zoom.
func TestHoldDeclinesTheStretchACaptureNeverGot(t *testing.T) {
h := NewHub()
ts, vs := ramp(1000, 0.001, 20000)
rb := newSigRing(5000) // the newest 5 s only, and no archive to fill from
rb.write(ts, vs)
h.rings["src:sig"] = rb
if buf := h.buildTriggerCapture(1015, 10, 5); buf == nil {
t.Fatal("no capture frame built")
}
if _, _, ok := h.capture.slice("src:sig", 1005, 1020); ok {
t.Error("the hold answered for 15 s it only has the last 5 s of")
}
// What it does hold, it still serves.
if _, _, ok := h.capture.slice("src:sig", 1016, 1019); !ok {
t.Error("the hold declined a range well inside its data")
}
}
// TestCaptureCoverageAcrossShots walks a whole acquisition the way Run() does —
// ingest, retune, dueCapture, rearm — and reports how much of each window the
// capture actually came back with.
func TestCaptureCoverageAcrossShots(t *testing.T) {
const (
key = "s1:Ch1"
rate = 100e3 // scaled 10x down from the 1 MSps producer
budget = 400_000
window = 120.0
prePct = 20.0
batchSec = 1.0 / 30.0
simSec = 900.0
)
h := NewHub()
h.SetRingBudget(budget)
h.rings[key] = newSigRing(ringCapInitial)
h.trigger.SetConfig(trigConfig{signalKey: key, edge: "rising", threshold: 0,
windowSec: window, prePercent: prePct, mode: "normal", holdoffSec: 0.2})
rateHz := float64(rate)
nBatch := int(rateHz * batchSec)
ts := make([]float64, nBatch)
vs := make([]float64, nBatch)
armed := false
shots := 0
for now := 0.0; now < simSec; now += batchSec {
for i := range ts {
ts[i] = now + float64(i)/rateHz
// 0.05 Hz sine: one rising zero crossing every 20 s.
vs[i] = math.Sin(2 * math.Pi * 0.05 * ts[i])
}
h.ingest(key, 1, ts, vs)
h.retuneRings(now)
// Arm once the stream is going, as a user would.
if !armed && now > 5 {
h.trigger.Arm()
armed = true
}
if trigTime, pre, post, ok := h.trigger.dueCapture(now + batchSec); ok {
buf := h.buildTriggerCapture(trigTime, pre, post)
if buf == nil {
t.Fatalf("shot at t=%.1f produced no frame", trigTime)
}
first, last, n := decodeCaptureSpan(t, buf, key)
t0, t1 := trigTime-pre, trigTime+post
_, ringSpan := h.rings[key].stats()
shots++
t.Logf("shot %d fired t=%.1f window [%.1f,%.1f] got [%.1f,%.1f] "+
"= %.0f%% (%d pts, bucket %d, ring span %.1f s)",
shots, trigTime, t0, t1, first, last,
100*(last-first)/(t1-t0), n, h.rings[key].bucketSize(), ringSpan)
h.trigger.markTriggered(now + batchSec)
} else if h.trigger.dueRearm(now + batchSec) {
h.trigger.Arm()
}
}
if shots < 3 {
t.Fatalf("only %d shots in %.0f s", shots, simSec)
}
}
+83
View File
@@ -0,0 +1,83 @@
package wshub
import (
"sort"
"sync"
)
// capturedWindow is one delivered trigger capture, held at the resolution the
// rings had when it was taken. Nothing mutates it after publication, so readers
// may sub-slice it without copying.
type capturedWindow struct {
t0, t1 float64
sigs map[string]sigData
}
// captureHold is the read half of the trigger double buffer; the rings are the
// write half.
//
// The rings keep rolling while the trigger re-arms and collects the next shot,
// so within seconds of a capture they no longer hold the window the user is
// looking at — a zoom into it came back with only the newest sliver, or with
// nothing. Publishing the window here at capture time gives the viewer a
// snapshot that the re-arming acquisition cannot overwrite: the swap happens
// only when the *next* capture is complete, which is also the moment the client
// stops displaying this one.
type captureHold struct {
mu sync.RWMutex
cur *capturedWindow
}
// publish swaps in a new capture, retiring the previous one. Readers that
// already hold a pointer to the retired window keep reading it safely.
func (ch *captureHold) publish(t0, t1 float64, sigs map[string]sigData) {
if len(sigs) == 0 {
return
}
w := &capturedWindow{t0: t0, t1: t1, sigs: sigs}
ch.mu.Lock()
ch.cur = w
ch.mu.Unlock()
}
// clear drops the held capture, releasing its memory.
func (ch *captureHold) clear() {
ch.mu.Lock()
ch.cur = nil
ch.mu.Unlock()
}
// slice answers [a, b] for one signal out of the held capture, reporting
// whether it could.
//
// It declines any range reaching outside the captured window: that is a live
// zoom or a pan off the capture, and only the rings still track the stream.
// Inside the window the hold is never worse than the rings — retuning does not
// rewrite stored samples, so a ring that still covers the range holds the very
// same points — which is why no trigger-state gating is needed here.
func (ch *captureHold) slice(key string, a, b float64) ([]float64, []float64, bool) {
ch.mu.RLock()
w := ch.cur
ch.mu.RUnlock()
if w == nil || a < w.t0 || b > w.t1 {
return nil, nil, false
}
sd, ok := w.sigs[key]
if !ok || len(sd.T) == 0 {
return nil, nil, false
}
// The window is what was asked for; this signal's samples are what could be
// found. A capture whose front was never recoverable must not answer for the
// stretch it is missing — the client would redraw the same hole on every
// zoom and every "fit" instead of falling back to the archive.
tol := shortCaptureTol * (w.t1 - w.t0)
if sd.T[0] > a+tol || sd.T[len(sd.T)-1] < b-tol {
return nil, nil, false
}
lo := sort.SearchFloat64s(sd.T, a)
hi := lo + sort.Search(len(sd.T)-lo, func(i int) bool { return sd.T[lo+i] > b })
if hi <= lo {
return nil, nil, false
}
return sd.T[lo:hi], sd.V[lo:hi], true
}
+128
View File
@@ -0,0 +1,128 @@
package wshub
import "testing"
func heldRamp(t0, dt float64, n int) sigData {
sd := sigData{T: make([]float64, n), V: make([]float64, n)}
for i := range sd.T {
sd.T[i] = t0 + float64(i)*dt
sd.V[i] = float64(i)
}
return sd
}
func TestCaptureHoldServesRangesInsideTheWindow(t *testing.T) {
var ch captureHold
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
gt, gv, ok := ch.slice("s1:sig", 2, 3)
if !ok {
t.Fatal("held capture declined a range inside its window")
}
if gt[0] < 2 || gt[len(gt)-1] > 3 {
t.Fatalf("range %v..%v escapes the request 2..3", gt[0], gt[len(gt)-1])
}
if len(gt) != len(gv) {
t.Fatalf("t/v length mismatch: %d vs %d", len(gt), len(gv))
}
if gv[0] != 20 {
t.Fatalf("first value %v, want the sample at t=2", gv[0])
}
}
// A range poking outside the capture is a live zoom: only the rings still track
// the stream, so the hold must stand aside rather than answer a clipped range.
func TestCaptureHoldDeclinesRangesOutsideTheWindow(t *testing.T) {
var ch captureHold
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
for _, r := range [][2]float64{{-1, 5}, {5, 11}, {20, 30}, {-5, -1}} {
if _, _, ok := ch.slice("s1:sig", r[0], r[1]); ok {
t.Fatalf("held capture answered %v..%v, which is not inside 0..10", r[0], r[1])
}
}
if _, _, ok := ch.slice("other:sig", 2, 3); ok {
t.Fatal("held capture answered for a signal it does not hold")
}
}
func TestCaptureHoldZeroValueAndClearDecline(t *testing.T) {
var ch captureHold
if _, _, ok := ch.slice("s1:sig", 0, 1); ok {
t.Fatal("empty hold answered a request")
}
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
ch.clear()
if _, _, ok := ch.slice("s1:sig", 2, 3); ok {
t.Fatal("cleared hold still answered a request")
}
}
// The point of the double buffer: the window a client is exploring survives the
// re-armed acquisition rolling the rings past it, and is replaced only when the
// next shot completes.
func TestZoomIntoACaptureSurvivesTheRingRollingPast(t *testing.T) {
h := NewHub()
rb := newSigRing(4000)
h.rings["s1:sig"] = rb
// 2 s of 1 kSps, then fire a trigger over [0.5, 1.5].
ts, vs := make([]float64, 2000), make([]float64, 2000)
for i := range ts {
ts[i], vs[i] = float64(i)*1e-3, float64(i)
}
rb.write(ts, vs)
if msg := h.buildTriggerCapture(1.0, 0.5, 0.5); msg == nil {
t.Fatal("buildTriggerCapture produced no frame")
}
// The trigger re-arms and the stream runs on until the captured window has
// been overwritten several times over.
for pass := 0; pass < 5; pass++ {
for i := range ts {
ts[i] += 2.0
}
rb.write(ts, vs)
}
if rt, _ := rb.slice(0.5, 1.5); len(rt) != 0 {
t.Fatalf("ring still holds %d points of the captured window; the test is not exercising the hold", len(rt))
}
got := h.zoomSlice(0.8, 0.9, []string{"s1:sig"}, 1<<30)
sd, ok := got["s1:sig"]
if !ok {
t.Fatal("zoom into the held capture returned nothing")
}
if len(sd.T) != 101 {
t.Fatalf("zoom returned %d points, want the 101 samples in 0.8..0.9", len(sd.T))
}
if sd.V[0] != 800 || sd.V[len(sd.V)-1] != 900 {
t.Fatalf("zoom returned values %v..%v, want 800..900", sd.V[0], sd.V[len(sd.V)-1])
}
// A live zoom outside the held window still reaches the rings.
if live := h.zoomSlice(11.0, 11.1, []string{"s1:sig"}, 1<<30); len(live["s1:sig"].T) == 0 {
t.Fatal("live zoom outside the capture was swallowed by the hold")
}
}
// A shot that yields nothing must not blank the window already on screen.
func TestEmptyCaptureKeepsThePreviousHold(t *testing.T) {
h := NewHub()
rb := newSigRing(4000)
h.rings["s1:sig"] = rb
ts, vs := make([]float64, 2000), make([]float64, 2000)
for i := range ts {
ts[i], vs[i] = float64(i)*1e-3, float64(i)
}
rb.write(ts, vs)
h.buildTriggerCapture(1.0, 0.5, 0.5)
// A window the rings have no samples for at all.
if msg := h.buildTriggerCapture(500.0, 0.5, 0.5); msg != nil {
t.Fatal("capture of an empty window produced a frame")
}
if _, _, ok := h.capture.slice("s1:sig", 0.8, 0.9); !ok {
t.Fatal("empty capture dropped the previously held window")
}
}
File diff suppressed because it is too large Load Diff
+949
View File
@@ -0,0 +1,949 @@
package wshub
import (
"encoding/binary"
"math"
"os"
"path/filepath"
"testing"
"marte2/common/udpsprotocol"
)
// newTestHistory opens a writer in a temp dir with one signal file of the given
// declared rate, and returns the writer plus that signal's key.
func newTestHistory(t *testing.T, cfg HistoryConfig, rate float64) (*historyWriter, string) {
t.Helper()
if cfg.Directory == "" {
cfg.Directory = t.TempDir()
}
hw, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: rate},
})
t.Cleanup(hw.close)
return hw, "src:sig"
}
func ramp(t0 float64, dt float64, n int) ([]float64, []float64) {
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = t0 + float64(i)*dt
vs[i] = float64(i)
}
return ts, vs
}
// A budget that cannot hold the window at full rate must buy the window by
// widening the min/max bucket, not by archiving a shorter stretch: a user
// looking at 600 s wants 600 s of it archived, coarser if need be.
func TestHistCapacityKeepsWindowByBucketing(t *testing.T) {
const mega = 1 << 20
cases := []struct {
name string
window float64
rate float64
maxPts int
wantBucket int
}{
// 60 s of 1 kSps is 60 k samples — well inside 1 MPt, so stored verbatim.
{"slow signal keeps full resolution", 60, 1000, mega, 1},
// 600 s of 1 MSps is 600 M samples against 16 Mi points: at 2 points per
// bucket and the headroom, ceil(2 × 1.25 × 600e6 / 16Mi) = 90 per bucket.
{"fast signal is enveloped", 600, 1e6, 16 * mega, 90},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
capacity, bucket := histCapacityFor(c.window, c.rate, 1, c.maxPts)
if bucket != c.wantBucket {
t.Errorf("bucket = %d, want %d", bucket, c.wantBucket)
}
if capacity > uint32(c.maxPts) {
t.Errorf("capacity %d exceeds the %d-point budget", capacity, c.maxPts)
}
// The whole window has to fit, which is the entire point.
if covered := histCoverageSec(capacity, bucket, 1, c.rate); covered < c.window {
t.Errorf("archive covers %.1f s, want the %.1f s window", covered, c.window)
}
})
}
}
// The file exists to serve the window, so it must track it: a client that widens
// what it displays must not be left reading an archive sized for the old span.
func TestHistorySetWindowResizesFiles(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 10}, 1000)
before := hw.files[key]
if before.bucket != 1 || histCoverageSec(before.capacity, 1, 1, 1000) < 10 {
t.Fatalf("initial geometry = cap %d bucket %d, want 10 s verbatim",
before.capacity, before.bucket)
}
if !hw.setWindow(600) {
t.Fatal("setWindow reported no change for a 60× wider window")
}
after := hw.files[key]
if after == before {
t.Fatal("the file was not re-created")
}
if cov := histCoverageSec(after.capacity, after.bucket, 1, 1000); cov < 600 {
t.Fatalf("archive covers %.1f s, want the new 600 s window", cov)
}
// Same window again: nothing to do, and re-creating the file would throw the
// archive away for nothing.
if hw.setWindow(600) {
t.Fatal("setWindow re-sized for an unchanged window")
}
// A nudge inside the hysteresis band must not either.
if hw.setWindow(610) {
t.Fatal("setWindow re-sized for a 2 % window change")
}
if hw.files[key] != after {
t.Fatal("the file was re-created despite the hysteresis")
}
}
// The archive is what a zoom beyond the rings reads, so a spike that only the
// archive still holds must survive being written to it.
func TestHistoryBucketedWriteKeepsPeaks(t *testing.T) {
// 1 kSps for 1 s = 1000 samples, plus headroom, into a 100-point budget →
// buckets of ceil(2 × 1.25 × 1000 / 100) = 25.
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 1, MinDiskFreeMB: -1, MaxPointsPerSignal: 100,
}, 1000)
hf := hw.files[key]
if hf.bucket != 25 {
t.Fatalf("bucket = %d, want 25", hf.bucket)
}
ts := make([]float64, 1000)
vs := make([]float64, 1000)
for i := range ts {
ts[i] = float64(i) * 0.001
}
vs[137] = 7.5 // a one-sample positive spike
vs[500] = -3.5 // and a negative one
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 0, 1, 1000)
if len(rt) == 0 {
t.Fatal("nothing archived")
}
hi, lo := false, false
for i := range rv {
if rv[i] == 7.5 && rt[i] == ts[137] {
hi = true
}
if rv[i] == -3.5 && rt[i] == ts[500] {
lo = true
}
}
if !hi || !lo {
t.Errorf("archive lost a spike (positive kept=%v, negative kept=%v)", hi, lo)
}
// A partial bucket is not written until it completes, so the last few
// samples may be missing; everything before them must be there.
if hf.count == 0 || hf.count > hf.capacity {
t.Errorf("archived %d points into a %d-point file", hf.count, hf.capacity)
}
}
func TestHistoryDisabledWithoutDirectory(t *testing.T) {
hw, err := newHistoryWriter(HistoryConfig{})
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
if hw != nil {
t.Fatal("empty Directory must disable history")
}
// Every method must stay usable on the nil writer, which is how the hub
// avoids guarding each call site.
if hw.enabled() {
t.Fatal("nil writer reports enabled")
}
hw.write("src:sig", []float64{1}, []float64{1})
hw.flushHeaders()
hw.close()
if rt, _ := hw.readRange("src:sig", 0, 1, 10); rt != nil {
t.Fatal("nil writer returned data")
}
if len(hw.info()) != 0 {
t.Fatal("nil writer returned info entries")
}
}
func TestHistoryWriteReadRoundTrip(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
ts, vs := ramp(10, 0.01, 500)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 10.5, 11.0, 10000)
if len(rt) != 51 { // inclusive both ends, 0.01 s spacing
t.Fatalf("read %d points, want 51", len(rt))
}
if rt[0] < 10.5-1e-9 || rt[len(rt)-1] > 11.0+1e-9 {
t.Fatalf("range [%v, %v] escapes the request", rt[0], rt[len(rt)-1])
}
for i := range rt {
wantV := math.Round((rt[i] - 10) / 0.01)
if math.Abs(rv[i]-wantV) > 1e-6 {
t.Fatalf("point %d: value %v, want %v", i, rv[i], wantV)
}
}
}
func TestHistoryReadRangeOutsideDataIsEmpty(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
ts, vs := ramp(10, 0.01, 100)
hw.write(key, ts, vs)
if rt, _ := hw.readRange(key, 100, 200, 1000); len(rt) != 0 {
t.Fatalf("read %d points past the newest sample", len(rt))
}
if rt, _ := hw.readRange(key, 0, 5, 1000); len(rt) != 0 {
t.Fatalf("read %d points before the oldest sample", len(rt))
}
if rt, _ := hw.readRange("src:missing", 10, 11, 1000); rt != nil {
t.Fatal("unknown key returned data")
}
if rt, _ := hw.readRange(key, 11, 10, 1000); rt != nil {
t.Fatal("inverted range returned data")
}
}
// Once the file has wrapped, the oldest samples must be gone and the retained
// window must still read back contiguously across the wrap point.
func TestHistoryWrapAround(t *testing.T) {
// A sub-second window at 1 Sps sizes below the 1000-pair floor, which is a
// cheap capacity to wrap.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
hf := hw.files[key]
if hf.capacity != histMinCapacity {
t.Fatalf("capacity = %d, want the %d floor", hf.capacity, histMinCapacity)
}
// 2.5 fills, in batches that do not align with the capacity so the wrap
// lands mid-batch.
total := 2500
ts, vs := ramp(0, 1, total)
for i := 0; i < total; i += 333 {
end := i + 333
if end > total {
end = total
}
hw.write(key, ts[i:end], vs[i:end])
}
if hf.count != histMinCapacity {
t.Fatalf("count = %d, want a full %d", hf.count, histMinCapacity)
}
wantOldest := float64(total - histMinCapacity)
if hf.tOldest != wantOldest {
t.Fatalf("tOldest = %v, want %v", hf.tOldest, wantOldest)
}
if hf.tNewest != float64(total-1) {
t.Fatalf("tNewest = %v, want %v", hf.tNewest, float64(total-1))
}
rt, rv := hw.readRange(key, wantOldest, float64(total-1), 10000)
if len(rt) != histMinCapacity {
t.Fatalf("read %d points, want the full %d", len(rt), histMinCapacity)
}
for i := range rt {
want := wantOldest + float64(i)
if rt[i] != want || rv[i] != want {
t.Fatalf("point %d = (%v, %v), want (%v, %v)", i, rt[i], rv[i], want, want)
}
}
// The evicted samples must not come back.
if et, _ := hw.readRange(key, 0, wantOldest-1, 10000); len(et) != 0 {
t.Fatalf("read %d evicted points", len(et))
}
}
// A single batch larger than the file keeps its tail, not its head.
func TestHistoryOversizedBatchKeepsTail(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
ts, vs := ramp(0, 1, 3000)
hw.write(key, ts, vs)
hf := hw.files[key]
if hf.count != histMinCapacity {
t.Fatalf("count = %d, want %d", hf.count, histMinCapacity)
}
if hf.tNewest != 2999 {
t.Fatalf("tNewest = %v, want 2999", hf.tNewest)
}
rt, _ := hw.readRange(key, 2000, 2999, 10000)
if len(rt) != histMinCapacity || rt[0] != 2000 {
t.Fatalf("retained window starts at %v with %d points, want 2000 / %d",
rt[0], len(rt), histMinCapacity)
}
}
func TestHistoryDecimation(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{Decimation: 4}, 100)
// Two batches, so the decimation phase must carry across the call boundary
// rather than restarting.
ts, vs := ramp(0, 0.01, 100)
hw.write(key, ts[:37], vs[:37])
hw.write(key, ts[37:], vs[37:])
rt, _ := hw.readRange(key, -1, 1e9, 10000)
if len(rt) != 25 {
t.Fatalf("kept %d of 100 points at decimation 4, want 25", len(rt))
}
for i := 1; i < len(rt); i++ {
if d := rt[i] - rt[i-1]; math.Abs(d-0.04) > 1e-9 {
t.Fatalf("spacing at %d = %v, want 0.04", i, d)
}
}
}
// The input slices are shared with the zoom ring and the trigger, so decimation
// must not touch them.
func TestHistoryWriteDoesNotMutateInput(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{Decimation: 3}, 100)
ts, vs := ramp(0, 0.01, 30)
tCopy := append([]float64(nil), ts...)
vCopy := append([]float64(nil), vs...)
hw.write(key, ts, vs)
for i := range ts {
if ts[i] != tCopy[i] || vs[i] != vCopy[i] {
t.Fatalf("write mutated input at %d", i)
}
}
}
// Reopening the same directory must pick the file back up with its contents,
// which is the whole point of persisting the header.
func TestHistoryReopenPreservesData(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 0.36}
sigs := []udpsprotocol.SignalInfo{{Name: "sig", TypeCode: 8, SamplingRate: 1}}
hw, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
hw.onSourceConfigured("src", sigs)
ts, vs := ramp(0, 1, 400)
hw.write("src:sig", ts, vs)
hw.close()
hw2, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer hw2.close()
hw2.onSourceConfigured("src", sigs)
hf := hw2.files["src:sig"]
if hf.count != 400 || hf.head != 400 {
t.Fatalf("reopened count=%d head=%d, want 400/400", hf.count, hf.head)
}
rt, rv := hw2.readRange("src:sig", 100, 199, 10000)
if len(rt) != 100 || rt[0] != 100 || rv[0] != 100 {
t.Fatalf("reopened read = %d points starting (%v, %v)", len(rt), rt[0], rv[0])
}
// Appending after the reopen must continue where the file left off.
ts2, vs2 := ramp(400, 1, 50)
hw2.write("src:sig", ts2, vs2)
if hf.tNewest != 449 {
t.Fatalf("tNewest after append = %v, want 449", hf.tNewest)
}
}
// A file sized for a different rate cannot be reused, so it must be recreated
// rather than reopened with a mismatched capacity.
func TestHistoryReopenWithDifferentCapacityRecreates(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 3600}
hw, _ := newHistoryWriter(cfg)
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 10},
})
firstCap := hw.files["src:sig"].capacity
hw.write("src:sig", []float64{1, 2}, []float64{1, 2})
hw.close()
hw2, _ := newHistoryWriter(cfg)
defer hw2.close()
hw2.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 100}, // 10× the rate
})
hf := hw2.files["src:sig"]
if hf.capacity == firstCap {
t.Fatalf("capacity unchanged at %d despite a 10x rate change", firstCap)
}
if hf.count != 0 {
t.Fatalf("recreated file kept %d samples", hf.count)
}
}
// A corrupt header must not be trusted: the file gets rebuilt instead.
func TestHistoryCorruptHeaderRecreates(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 0.36}
sigs := []udpsprotocol.SignalInfo{{Name: "sig", TypeCode: 8, SamplingRate: 1}}
hw, _ := newHistoryWriter(cfg)
hw.onSourceConfigured("src", sigs)
hw.write("src:sig", []float64{1, 2, 3}, []float64{1, 2, 3})
hw.close()
path := filepath.Join(dir, "src", "sig.shist")
f, err := os.OpenFile(path, os.O_RDWR, 0o644)
if err != nil {
t.Fatalf("open: %v", err)
}
if _, err := f.WriteAt([]byte("XXXX"), 0); err != nil { // clobber the magic
t.Fatalf("clobber: %v", err)
}
f.Close()
hw2, _ := newHistoryWriter(cfg)
defer hw2.close()
hw2.onSourceConfigured("src", sigs)
if got := hw2.files["src:sig"].count; got != 0 {
t.Fatalf("count = %d, want a recreated empty file", got)
}
}
// Raising the budget from the UI has to buy resolution: same duration, a
// narrower min/max bucket. Lowering it again must not overrun the new budget.
func TestSetBudgetRebucketsAtTheSameDuration(t *testing.T) {
// 100 s of 100 kSps is 10 M samples, well past either budget.
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 100, MaxPointsPerSignal: 100_000,
}, 1e5)
before := hw.files[key]
if before.bucket <= 1 {
t.Fatalf("bucket = %d, want the signal enveloped to fit the budget", before.bucket)
}
if got := hw.setBudget(1_000_000); got != 1_000_000 {
t.Fatalf("setBudget = %d, want 1000000", got)
}
after := hw.files[key]
if after == before {
t.Fatal("the file was not re-created")
}
if after.bucket >= before.bucket {
t.Fatalf("bucket %d → %d, want a finer envelope for a 10× budget",
before.bucket, after.bucket)
}
if after.capacity > 1_000_000 {
t.Fatalf("capacity = %d, over the 1 MPts budget", after.capacity)
}
// The point of the envelope: the duration is covered whatever the budget.
if cov := float64(after.capacity) * float64(after.bucket) / 2 / 1e5; cov < 99 {
t.Fatalf("coverage = %.1f s, want ~100 s", cov)
}
if got := hw.setBudget(100_000); got != 100_000 {
t.Fatalf("setBudget back = %d, want 100000", got)
}
if c := hw.files[key].capacity; c > 100_000 {
t.Fatalf("capacity = %d, over the restored 100 kPts budget", c)
}
}
// A budget that leaves a signal's geometry alone must leave its archive alone
// too — re-creating files nobody asked to resize would throw away history.
func TestSetBudgetKeepsUnaffectedFiles(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 1, MaxPointsPerSignal: 16 << 20,
}, 1000)
ts, vs := ramp(0, 0.001, 100)
hw.write(key, ts, vs)
hw.setBudget(8 << 20) // still far more than the 1000 points this signal needs
hf := hw.files[key]
if hf.bucket != 1 {
t.Fatalf("bucket = %d, want the slow signal still archived verbatim", hf.bucket)
}
if hf.count != 100 {
t.Fatalf("count = %d, want the 100 archived samples kept", hf.count)
}
}
// Time-reference signals are the clock for the others, so archiving them would
// just waste disk.
func TestHistorySkipsTimeSignals(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "TimeArray", TypeCode: histTypeCodeUint64, SamplingRate: 1000},
{Name: "data", TypeCode: 8, SamplingRate: 1000},
})
if _, ok := hw.files["src:TimeArray"]; ok {
t.Fatal("uint64 time signal was archived")
}
if _, ok := hw.files["src:data"]; !ok {
t.Fatal("data signal was not archived")
}
}
// A second CONFIG for the same source must not throw away the history already
// collected for signals it re-declares.
func TestHistoryReconfigureKeepsExistingFile(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
before := hw.files[key]
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 1},
{Name: "sig2", TypeCode: 8, SamplingRate: 1},
})
if hw.files[key] != before {
t.Fatal("re-CONFIG replaced the existing signal file")
}
if before.count != 3 {
t.Fatalf("count = %d, want the 3 already written", before.count)
}
if _, ok := hw.files["src:sig2"]; !ok {
t.Fatal("newly declared signal was not opened")
}
}
// The C++ UDPStreamer declares samplingRate=0, so sizing the file on the spot
// would use a guess that is three orders of magnitude out at 1 MSps.
func TestHistoryDefersSignalsWithoutDeclaredRate(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "fast", TypeCode: 8, SamplingRate: 0},
{Name: "known", TypeCode: 8, SamplingRate: 100},
})
if _, ok := hw.files["src:fast"]; ok {
t.Fatal("undeclared-rate signal was sized before its rate was measured")
}
if got := hw.pendingKeys(); len(got) != 1 || got[0] != "src:fast" {
t.Fatalf("pendingKeys = %v, want [src:fast]", got)
}
if _, ok := hw.files["src:known"]; !ok {
t.Fatal("declared-rate signal was deferred")
}
// Data for a deferred signal is dropped, not misfiled.
hw.write("src:fast", []float64{1}, []float64{1})
// A repeated CONFIG must not queue it twice.
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "fast", TypeCode: 8, SamplingRate: 0},
})
if got := hw.pendingKeys(); len(got) != 1 {
t.Fatalf("pendingKeys = %v after re-CONFIG, want one entry", got)
}
if !hw.openPending("src:fast", 100000) {
t.Fatal("openPending refused a measured rate")
}
hf, ok := hw.files["src:fast"]
if !ok {
t.Fatal("file not opened after the rate was measured")
}
// The default window × 100 kSps, enveloped if it does not fit the budget.
wantCap, wantBucket := histCapacityFor(defaultLiveWindowSec, 100000, 1, histDefaultMaxPoints)
if hf.capacity != wantCap || hf.bucket != wantBucket {
t.Fatalf("capacity/bucket = %d/%d, want %d/%d", hf.capacity, hf.bucket, wantCap, wantBucket)
}
if len(hw.pendingKeys()) != 0 {
t.Fatal("signal still pending after being opened")
}
if hw.openPending("src:fast", 100000) {
t.Fatal("openPending reopened an already-open signal")
}
}
func TestOpenPendingHistoryFilesUsesMeasuredRate(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{Directory: t.TempDir(), WindowSec: 3.6}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
defer h.CloseHistory()
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 0},
})
rb := newSigRing(200000)
h.rings["s1:sig"] = rb
// Too little data to measure a rate from: the sweep must wait rather than
// size the file from a burst.
fillRing(rb, 0, 100000, 100) // 1 ms of data
h.openPendingHistoryFiles(100)
if len(h.hist.pendingKeys()) != 1 {
t.Fatal("sweep sized the file from a sub-millisecond sample")
}
fillRing(rb, 0, 100000, 100000) // 1 s at 100 kSps
h.openPendingHistoryFiles(200)
hf, ok := h.hist.files["s1:sig"]
if !ok {
t.Fatal("file not opened once the rate was measurable")
}
// 3.6 s at ~100 kSps, plus headroom, ≈ 450 000 pairs; a fixed 1 kHz guess
// would have produced the 1000-sample floor instead.
if hf.capacity < 400_000 || hf.capacity > 500_000 {
t.Fatalf("capacity = %d, want ~450000 from the measured 100 kSps", hf.capacity)
}
}
func TestOpenPendingHistoryFilesIsThrottled(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{Directory: t.TempDir()}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
defer h.CloseHistory()
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 0},
})
h.openPendingHistoryFiles(100) // no ring yet: nothing to measure
rb := newSigRing(20000)
fillRing(rb, 0, 1000, 20000)
h.rings["s1:sig"] = rb
h.openPendingHistoryFiles(100.5)
if len(h.hist.files) != 0 {
t.Fatal("sweep ran inside the throttle window")
}
h.openPendingHistoryFiles(200)
if len(h.hist.files) != 1 {
t.Fatal("sweep did not run after the throttle window elapsed")
}
}
// A hub without history must tolerate the sweep, since Run() calls it every tick.
func TestOpenPendingHistoryFilesNoopWithoutHistory(t *testing.T) {
h := NewHub()
h.openPendingHistoryFiles(100)
}
func TestHistoryInfoShape(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
// Reported before any data arrives, so clients can enable their history UI.
inf := hw.info()
if e, ok := inf[key]; !ok || e.Count != 0 || e.Capacity != histMinCapacity {
t.Fatalf("pre-data info = %+v (present=%v)", inf[key], ok)
}
ts, vs := ramp(5, 1, 10)
hw.write(key, ts, vs)
e := hw.info()[key]
if e.Count != 10 || e.T0 != 5 || e.T1 != 14 {
t.Fatalf("info = %+v, want count=10 t0=5 t1=14", e)
}
}
func TestHistoryHeaderIsPersistedOnFlush(t *testing.T) {
dir := t.TempDir()
hw, key := newTestHistory(t, HistoryConfig{Directory: dir, WindowSec: 0.36, Decimation: 2}, 1)
ts, vs := ramp(0, 1, 20)
hw.write(key, ts, vs)
hw.flushHeaders()
hdr, err := os.ReadFile(filepath.Join(dir, "src", "sig.shist"))
if err != nil {
t.Fatalf("read: %v", err)
}
if string(hdr[0:4]) != "SHR1" {
t.Fatalf("magic = %q", hdr[0:4])
}
if v := binary.LittleEndian.Uint32(hdr[4:]); v != histVersion {
t.Fatalf("version = %d, want %d", v, histVersion)
}
if c := binary.LittleEndian.Uint32(hdr[8:]); c != histMinCapacity {
t.Fatalf("capacity = %d, want %d", c, histMinCapacity)
}
if h := binary.LittleEndian.Uint32(hdr[12:]); h != 10 {
t.Fatalf("head = %d, want 10 (20 samples, decimation 2)", h)
}
if n := binary.LittleEndian.Uint32(hdr[16:]); n != 10 {
t.Fatalf("count = %d, want 10", n)
}
if d := binary.LittleEndian.Uint32(hdr[20:]); d != 2 {
t.Fatalf("decimation = %d, want 2", d)
}
if got := math.Float64frombits(binary.LittleEndian.Uint64(hdr[32:])); got != 19 {
t.Fatalf("tNewest = %v, want 19", got)
}
// The data region must be pre-allocated in full, not grown as it fills.
if want := int64(histHeaderSize) + histMinCapacity*histPairSize; int64(len(hdr)) != want {
t.Fatalf("file size = %d, want the pre-allocated %d", len(hdr), want)
}
}
func TestSanitizeHistName(t *testing.T) {
cases := map[string]string{
"Signal_1": "Signal_1",
"GAM.Out[0]": "GAM.Out[0]",
"a/b": "a_b",
"../../etc/pass": ".._.._etc_pass",
"": "_",
".": "_",
"..": "_",
"with space": "with_space",
"nul\x00byte": "nul_byte",
}
for in, want := range cases {
if got := sanitizeHistName(in); got != want {
t.Errorf("sanitizeHistName(%q) = %q, want %q", in, got, want)
}
}
}
// A producer-supplied name must never place a file outside the history dir.
func TestHistoryNameCannotEscapeDirectory(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("../evil", []udpsprotocol.SignalInfo{
{Name: "../../pwned", TypeCode: 8, SamplingRate: 1},
})
found := false
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
found = true
}
return err
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if !found {
t.Fatal("no file created inside the history directory")
}
if _, err := os.Stat(filepath.Join(dir, "..", "..", "pwned.shist")); err == nil {
t.Fatal("a file escaped the history directory")
}
}
func TestHistCapacityFor(t *testing.T) {
cases := []struct {
window float64
rate float64
decim int
maxPts int
want uint32
wantBucket int
}{
// window × rate / decimation, plus the 1.25 headroom.
{600, 1000, 1, 0, 750_000, 1},
{600, 1000, 10, 0, 75_000, 1},
{10, 100, 1, 0, 1250, 1},
{600, 0.001, 1, 0, histMinCapacity, 1}, // absurdly slow → the floor
// Absurdly fast: bounded by histMaxCapacity, and the window is bought with
// a correspondingly absurd bucket rather than by storing less of it.
{600, 1e9, 1, 0, 1_073_729_421, 1397},
{math.NaN(), 1000, 1, 0, histMinCapacity, 1},
{600, math.NaN(), 1, 0, histMinCapacity, 1},
// A budget envelopes a fast signal without touching a slow one, and the
// window is kept either way.
{600, 1e6, 1, 16 << 20, 16_666_667, 90},
{600, 1000, 1, 16 << 20, 750_000, 1},
}
for _, c := range cases {
got, bucket := histCapacityFor(c.window, c.rate, c.decim, c.maxPts)
if got != c.want || bucket != c.wantBucket {
t.Errorf("histCapacityFor(%v, %v, %d, %d) = %d/%d, want %d/%d",
c.window, c.rate, c.decim, c.maxPts, got, bucket, c.want, c.wantBucket)
}
}
}
func TestHistoryConfigDefaults(t *testing.T) {
c := HistoryConfig{}.withDefaults()
if c.WindowSec != defaultLiveWindowSec || c.Decimation != 1 || c.FlushIntervalSec != 5 || c.MinDiskFreeMB != 500 {
t.Fatalf("defaults = %+v", c)
}
// A negative value is the explicit "no disk guard", so it must survive
// defaulting rather than being turned back into 500.
if got := (HistoryConfig{MinDiskFreeMB: -1}).withDefaults().MinDiskFreeMB; got != -1 {
t.Fatalf("MinDiskFreeMB = %d, want the -1 that disables the guard", got)
}
}
func TestHistoryWritePausedWhenDiskLow(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
hw.diskLow = true
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
if hw.files[key].count != 0 {
t.Fatalf("count = %d, want 0 while the disk guard is tripped", hw.files[key].count)
}
hw.diskLow = false
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
if hw.files[key].count != 3 {
t.Fatalf("count = %d, want 3 once writing resumes", hw.files[key].count)
}
}
func TestHistoryReadRangeRespectsMaxOut(t *testing.T) {
// A window wide enough that the whole ramp is still on disk when it is read.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, -1, 1e9, 100)
if len(rt) != 100 || len(rv) != 100 {
t.Fatalf("read %d/%d points, want the 100 cap", len(rt), len(rv))
}
}
func TestHistoryReadRangeSpansWholeRange(t *testing.T) {
// A capped read must thin the range out, not return its first maxOut
// samples: a client asking for 100 points over 50 s and getting the first
// second of it draws a flat line and falls back to its coarse copy.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, _ := hw.readRange(key, 0, 49.99, 100)
if len(rt) == 0 {
t.Fatal("no points read")
}
if got := rt[len(rt)-1] - rt[0]; got < 0.95*49.99 {
t.Fatalf("read spans %.2f s of the 49.99 s asked; a capped read must "+
"cover the whole range", got)
}
}
func TestHistoryReadRangeUncappedIsExact(t *testing.T) {
// Below the cap every sample in the range comes back, so a zoom deep enough
// to fit is served at full resolution.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 1, 1.99, 1000)
if len(rt) != 100 {
t.Fatalf("read %d points, want the 100 samples in [1, 1.99]", len(rt))
}
if rv[0] != 100 || rv[len(rv)-1] != 199 {
t.Fatalf("values %.0f..%.0f, want 100..199", rv[0], rv[len(rv)-1])
}
}
// The capture copy is what makes a trigger window zoomable long after the
// circular archive has wrapped over it.
func TestCaptureRangeOutlivesTheArchive(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 0.001) // floor capacity: 1000
hf := hw.files[key]
if hf.capacity != histMinCapacity {
t.Fatalf("capacity = %d, want the %d floor", hf.capacity, histMinCapacity)
}
ts, vs := ramp(0, 1, 1000) // t = 0..999, exactly full
hw.write(key, ts, vs)
hw.captureRange(500, 600)
// Wrap the archive right over the captured window.
ts2, vs2 := ramp(1000, 1, 1000)
hw.write(key, ts2, vs2)
if hf.tOldest != 1000 || hf.tNewest != 1999 {
t.Fatalf("archive holds [%v, %v], want [1000, 1999]: capturing must not "+
"stop or divert the archive", hf.tOldest, hf.tNewest)
}
rt, rv := hw.readRange(key, 500, 600, 1000)
if len(rt) != 101 {
t.Fatalf("read %d captured samples in [500, 600], want 101", len(rt))
}
if rv[0] != 500 || rv[len(rv)-1] != 600 {
t.Fatalf("captured values %.0f..%.0f, want 500..600", rv[0], rv[len(rv)-1])
}
// A range the capture does not hold is still answered by the archive.
if at, _ := hw.readRange(key, 1500, 1600, 1000); len(at) != 101 {
t.Fatalf("read %d archived samples in [1500, 1600], want 101", len(at))
}
// The next capture replaces the last one, and only then.
hw.captureRange(1500, 1600)
if ct, _ := hw.readRange(key, 500, 600, 1000); len(ct) != 0 {
t.Fatalf("read %d samples of a replaced capture, want 0", len(ct))
}
}
// Delivering a capture copies its window out of the archive, and the archive
// keeps rolling so the next capture's pre-trigger window is there when it fires.
func TestTriggerCaptureCopiesWindowToDisk(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{
Directory: t.TempDir(), WindowSec: 36, MinDiskFreeMB: -1,
}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
t.Cleanup(h.CloseHistory)
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 1000},
})
h.rings["s1:sig"] = newSigRing(10000)
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 1, prePercent: 20, mode: "single"})
h.trigger.Arm()
// Cross the threshold, then cover the post-trigger window so the capture
// comes due on the next tick.
h.ingest("s1:sig", 1, []float64{5.0, 5.001}, []float64{-1, 1})
h.ingest("s1:sig", 1, []float64{6.0}, []float64{1})
h.triggerTick()
if h.trigger.State() != trigTriggered {
t.Fatalf("state = %q, want triggered", h.trigger.State())
}
cf := h.hist.captures["s1:sig"]
if cf == nil {
t.Fatal("capture delivered but its window was not copied to disk")
}
// The window is [trigTime-0.2, trigTime+0.8] around the 5.001 crossing, so
// the sample at 6.0 falls outside it.
if cf.count != 2 || cf.tOldest != 5.0 || cf.tNewest != 5.001 {
t.Fatalf("capture holds %d samples in [%v, %v], want 2 in [5, 5.001]",
cf.count, cf.tOldest, cf.tNewest)
}
// Copying the window leaves the archive rolling, so the next capture's
// pre-trigger window — written before its trigger fires — is there for it.
h.ingest("s1:sig", 1, []float64{7.0}, []float64{1})
if got := h.hist.files["s1:sig"].count; got != 4 {
t.Fatalf("archived %d samples, want 4: capturing must not stop writing", got)
}
// Rearming does not discard the capture: it stays on screen until the next
// trigger replaces it.
h.trigger.Arm()
h.triggerTick()
if h.hist.captures["s1:sig"] != cf {
t.Fatal("rearming discarded the capture the client is still showing")
}
}
func TestHistSearch(t *testing.T) {
vals := []float64{0, 1, 2, 3, 4, 5}
at := func(i uint32) float64 { return vals[i] }
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < 3 }); got != 3 {
t.Fatalf("lower bound = %d, want 3", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) <= 3 }); got != 4 {
t.Fatalf("upper bound = %d, want 4", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < -1 }); got != 0 {
t.Fatalf("all-false = %d, want 0", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < 100 }); got != 6 {
t.Fatalf("all-true = %d, want 6", got)
}
}
+325 -79
View File
@@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"unsafe" "unsafe"
@@ -27,6 +28,29 @@ type wsClient struct {
hub *Hub hub *Hub
conn *websocket.Conn conn *websocket.Conn
send chan wsMessage send chan wsMessage
// window is the timespan this client is displaying, in seconds, held as
// float64 bits. The retune sweep sizes the rings from the widest window in
// use, so it must be readable from the hub goroutine while readPump writes
// it. Zero means the client has not said, and the default applies.
window atomic.Uint64
}
func (c *wsClient) setDisplayWindowSec(s float64) {
c.window.Store(math.Float64bits(s))
}
func (c *wsClient) displayWindowSec() float64 {
return math.Float64frombits(c.window.Load())
}
// sendText enqueues one JSON frame for this client, dropping it if the client
// is not draining its queue.
func (c *wsClient) sendText(msg []byte) {
select {
case c.send <- wsMessage{websocket.TextMessage, msg}:
default:
}
} }
func (c *wsClient) writePump() { func (c *wsClient) writePump() {
@@ -107,6 +131,34 @@ func (c *wsClient) readPump() {
case c.hub.commandCh <- hubCmd{op: "wsSaveSources"}: case c.hub.commandCh <- hubCmd{op: "wsSaveSources"}:
default: default:
} }
case "setCalibration":
source, _ := env["source"].(string)
signal, _ := env["signal"].(string)
scale, hasScale := env["scale"].(float64)
if !hasScale {
scale = 1
}
offset, _ := env["offset"].(float64)
unit, _ := env["unit"].(string)
select {
case c.hub.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
Source: source, Signal: signal,
Scale: scale, Offset: offset, Unit: unit,
}}:
default:
}
case "reloadConfig":
select {
case c.hub.commandCh <- hubCmd{op: "wsReloadConfig"}:
default:
}
case "setWindow":
// Sizes the zoom rings: the hub cannot know how far back a
// client is plotting, and a window it has not been told
// about is a window the buffers may not reach.
if sec, ok := env["seconds"].(float64); ok && sec > 0 && !math.IsInf(sec, 0) {
c.setDisplayWindowSec(sec)
}
case "setMonotonic": case "setMonotonic":
enabled, _ := env["enabled"].(bool) enabled, _ := env["enabled"].(bool)
select { select {
@@ -119,6 +171,9 @@ func (c *wsClient) readPump() {
if c.hub.handleTriggerCommand(t, env) { if c.hub.handleTriggerCommand(t, env) {
break break
} }
if c.hub.handleHistoryCommand(c, t, env) {
break
}
// Unrecognized message type — forward to DebugCh // Unrecognized message type — forward to DebugCh
select { select {
case c.hub.DebugCh <- msg: case c.hub.DebugCh <- msg:
@@ -212,7 +267,8 @@ type taggedSample struct {
// hubCmd carries a command to the Run() goroutine. // hubCmd carries a command to the Run() goroutine.
type hubCmd struct { type hubCmd struct {
op string // "addSource","removeSource","setSourceState","updateConfig", op string // "addSource","removeSource","setSourceState","updateConfig",
// "wsAddSource","wsRemoveSource","wsSaveSources" // "wsAddSource","wsRemoveSource","wsSaveSources",
// "wsSetCalibration","wsReloadConfig"
sourceID string sourceID string
label string label string
addr string addr string
@@ -220,7 +276,8 @@ type hubCmd struct {
sigs []udpsprotocol.SignalInfo sigs []udpsprotocol.SignalInfo
multicastGroup string multicastGroup string
dataPort int dataPort int
enabled bool // "setMonotonic" toggle enabled bool // "setMonotonic" toggle
cal CalConfig // "wsSetCalibration" payload
} }
// Hub is the central broker between UDP clients and WebSocket clients. // Hub is the central broker between UDP clients and WebSocket clients.
@@ -239,16 +296,36 @@ type Hub struct {
sm *SourceManager // set after construction; used for WS-initiated source changes sm *SourceManager // set after construction; used for WS-initiated source changes
// cal holds the per-signal calibration table. It is metadata only: the
// rings, the history and the trigger comparator all keep raw samples.
cal *calTable
// Ring buffers for hi-res zoom data. // Ring buffers for hi-res zoom data.
// ringsMu protects the map structure; each sigRing has its own RWMutex for data. // ringsMu protects the map structure; each sigRing has its own RWMutex for data.
ringsMu sync.RWMutex ringsMu sync.RWMutex
rings map[string]*sigRing // "sourceId:signalKey" → ring rings map[string]*sigRing // "sourceId:signalKey" → ring
// hist is the disk-backed archive behind long time windows, which hold far
// more samples than the in-memory rings can. nil when history is disabled.
// histOpenAt throttles the sweep that opens the files of signals whose
// producer declared no sampling rate; both are touched only from Run().
hist *historyWriter
histOpenAt float64
statsMu sync.RWMutex statsMu sync.RWMutex
statsMap map[string]*SourceStat statsMap map[string]*SourceStat
// trigger is the hub-side trigger FSM driving the oscilloscope capture mode. // trigger is the hub-side trigger FSM driving the oscilloscope capture mode.
trigger *triggerEngine // ringTuneAt throttles the sweep that keeps each ring's depth and min/max
// bucket matched to the window being displayed; both are touched only from
// Run(). ringBudgetPts is that sweep's per-signal budget; set before Run().
trigger *triggerEngine
ringTuneAt float64
// capture is the trigger double buffer's read half: the last delivered
// capture window, kept out of the rings' way so the shot being viewed
// survives the re-arm that immediately follows it.
capture captureHold
ringBudgetPts int
onClientConnectMu sync.RWMutex onClientConnectMu sync.RWMutex
onClientConnect func(send func([]byte)) onClientConnect func(send func([]byte))
@@ -270,9 +347,48 @@ func NewHub() *Hub {
rings: make(map[string]*sigRing), rings: make(map[string]*sigRing),
statsMap: make(map[string]*SourceStat), statsMap: make(map[string]*SourceStat),
trigger: newTriggerEngine(), trigger: newTriggerEngine(),
cal: newCalTable(),
} }
} }
// SetRingBudget overrides the per-signal in-memory buffer budget, in points.
// Non-positive values restore the default. It must be called before Run().
// Each point costs 16 bytes, so the budget is the memory bound per temporal
// signal. It does not limit how long a window can be held: a window too long
// to fit at full rate is stored as min/max pairs instead (see retuneRings).
func (h *Hub) SetRingBudget(n int) {
if n <= 0 {
n = defaultRingPts
}
if n < ringCapInitial {
n = ringCapInitial
}
h.ringBudgetPts = n
}
func (h *Hub) ringBudget() int {
if h.ringBudgetPts <= 0 {
return defaultRingPts
}
return h.ringBudgetPts
}
// EnableHistory turns on the disk-backed history archive. It must be called
// before Run(). A HistoryConfig with an empty Directory leaves history off.
func (h *Hub) EnableHistory(cfg HistoryConfig) error {
hw, err := newHistoryWriter(cfg)
if err != nil {
return err
}
h.hist = hw
return nil
}
// CloseHistory flushes and closes the history files. Without it the samples
// written since the last periodic flush are on disk but unaccounted for in the
// file headers, so a restart would not see them.
func (h *Hub) CloseHistory() { h.hist.close() }
// SetOnClientConnect registers a callback invoked synchronously (from Run()) // SetOnClientConnect registers a callback invoked synchronously (from Run())
// each time a new WebSocket client connects. The callback receives a send // each time a new WebSocket client connects. The callback receives a send
// function that enqueues one message to that specific client. // function that enqueues one message to that specific client.
@@ -287,6 +403,22 @@ func (h *Hub) SetSourceManager(sm *SourceManager) {
h.sm = sm h.sm = sm
} }
// ingest routes one batch of full-resolution samples for a signal to every
// consumer that needs them at full rate: the in-memory zoom ring, the disk
// history and the trigger comparator. The live push is decimated separately by
// the caller. The ring and the archive may reduce what they store to fit their
// budget, but they are handed every sample so the reduction sees the extrema.
func (h *Hub) ingest(key string, nElem int, t, v []float64) {
if len(t) == 0 {
return
}
if rb := h.getRing(key); rb != nil {
rb.write(t, v)
}
h.hist.write(key, t, v)
h.trigger.feed(key, nElem, t, v)
}
// getRing returns the ring buffer for a fully-prefixed signal key, or nil. // getRing returns the ring buffer for a fully-prefixed signal key, or nil.
func (h *Hub) getRing(key string) *sigRing { func (h *Hub) getRing(key string) *sigRing {
h.ringsMu.RLock() h.ringsMu.RLock()
@@ -295,8 +427,10 @@ func (h *Hub) getRing(key string) *sigRing {
return rb return rb
} }
// zoomSlice extracts [t0, t1] from the full-resolution rings for the named // zoomSlice extracts [t0, t1] for the named signals, decimating each to at most
// signals, decimating each to at most n points. // n points. A range inside the last trigger capture is served from the held
// copy of it, which the re-arming acquisition cannot overwrite; everything else
// comes from the live rings.
func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData { func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData {
h.ringsMu.RLock() h.ringsMu.RLock()
refs := make(map[string]*sigRing, len(keys)) refs := make(map[string]*sigRing, len(keys))
@@ -313,11 +447,14 @@ func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData
result := make(map[string]sigData, len(refs)) result := make(map[string]sigData, len(refs))
for k, rb := range refs { for k, rb := range refs {
rt, rv := rb.slice(t0, t1) rt, rv, ok := h.capture.slice(k, t0, t1)
if !ok {
rt, rv = rb.slice(t0, t1)
}
if len(rt) == 0 { if len(rt) == 0 {
continue continue
} }
dt, dv := lttbDecimate(rt, rv, n) dt, dv := minMaxDecimate(rt, rv, n)
result[k] = sigData{T: dt, V: dv} result[k] = sigData{T: dt, V: dv}
} }
return result return result
@@ -360,10 +497,7 @@ func (h *Hub) handleWSZoom(c *wsClient, env map[string]interface{}) {
log.Printf("hub: ws zoom encode: %v", err) log.Printf("hub: ws zoom encode: %v", err)
return return
} }
select { c.sendText(reply)
case c.send <- wsMessage{websocket.TextMessage, reply}:
default:
}
} }
// HandleZoom serves GET /api/zoom?... // HandleZoom serves GET /api/zoom?...
@@ -470,6 +604,26 @@ func buildSourcesMsg(sm map[string]*sourceHubState) []byte {
return msg return msg
} }
// buildCalibrationMsg serialises the calibration table as a "calibration"
// message. It is its own frame rather than a field on "sources" because the
// C++ BroadcastSources serialises into a fixed 4096-byte buffer that a
// calibration table would overflow.
func buildCalibrationMsg(t *calTable) []byte {
list := t.List() // never nil: the SPA replaces its table wholesale on receipt
msg, _ := json.Marshal(map[string]any{"type": "calibration", "cal": list})
return msg
}
// buildConfigAckMsg serialises a configSaved / configReloaded acknowledgement.
func buildConfigAckMsg(msgType, path string, err error) []byte {
m := map[string]any{"type": msgType, "ok": err == nil, "path": path}
if err != nil {
m["error"] = err.Error()
}
msg, _ := json.Marshal(m)
return msg
}
// Run is the hub's main goroutine. Must be started with go hub.Run(). // Run is the hub's main goroutine. Must be started with go hub.Run().
func (h *Hub) Run() { func (h *Hub) Run() {
ticker := time.NewTicker(time.Second / 30) ticker := time.NewTicker(time.Second / 30)
@@ -478,6 +632,16 @@ func (h *Hub) Run() {
statsTicker := time.NewTicker(time.Second) statsTicker := time.NewTicker(time.Second)
defer statsTicker.Stop() defer statsTicker.Stop()
// Header flushes are what make the archived samples findable again; the
// data region is written as it arrives. Ticks are ignored when history is
// off, so a disabled writer costs one no-op call per period.
flushPeriod := time.Duration(5) * time.Second
if h.hist.enabled() {
flushPeriod = time.Duration(h.hist.cfg.FlushIntervalSec) * time.Second
}
flushTicker := time.NewTicker(flushPeriod)
defer flushTicker.Stop()
sourcesMap := make(map[string]*sourceHubState) sourcesMap := make(map[string]*sourceHubState)
var sourcesMsg []byte var sourcesMsg []byte
@@ -517,6 +681,16 @@ func (h *Hub) Run() {
case c.send <- wsMessage{websocket.TextMessage, monoMsg}: case c.send <- wsMessage{websocket.TextMessage, monoMsg}:
default: default:
} }
calMsg := buildCalibrationMsg(h.cal)
select {
case c.send <- wsMessage{websocket.TextMessage, calMsg}:
default:
}
if h.hist.enabled() {
if msg := h.buildHistoryInfoMsg(); msg != nil {
c.sendText(msg)
}
}
// Notify the application layer so it can replay any persistent state // Notify the application layer so it can replay any persistent state
// (e.g., MARTe2 connection status, forced/traced signals). // (e.g., MARTe2 connection status, forced/traced signals).
h.onClientConnectMu.RLock() h.onClientConnectMu.RLock()
@@ -617,16 +791,29 @@ func (h *Hub) Run() {
ne := sig.NumElements() ne := sig.NumElements()
isTemporal := ne > 1 && sig.TimeMode != udpsprotocol.TimeModePacket isTemporal := ne > 1 && sig.TimeMode != udpsprotocol.TimeModePacket
if isTemporal { if isTemporal {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal) h.rings[pfxUpd+sig.Name] = newSigRing(ringCapInitial)
} else if ne == 1 { } else if ne == 1 {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar) h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar)
} else { } else {
// n>1, TimeModePacket snapshot-waveform: each packet contributes n // n>1, TimeModePacket snapshot-waveform: each packet contributes n
// elements, so use the temporal capacity to hold enough history. // elements, so this is a fast stream too and gets the same budget.
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal) h.rings[pfxUpd+sig.Name] = newSigRing(ringCapInitial)
} }
} }
h.ringsMu.Unlock() h.ringsMu.Unlock()
// The held capture describes rings that no longer exist. A
// restarted producer can even replay the same timestamps, so
// keeping it would answer zooms with the old run's samples.
h.capture.clear()
// Opening the archive files touches the filesystem, so keep it
// off the Run() goroutine; the write path simply drops samples
// for a key whose file is not open yet.
if h.hist.enabled() {
go func(id string, sigs []udpsprotocol.SignalInfo) {
h.hist.onSourceConfigured(id, sigs)
h.broadcast(h.buildHistoryInfoMsg())
}(cmd.sourceID, cmd.sigs)
}
case "wsAddSource": case "wsAddSource":
if h.sm != nil { if h.sm != nil {
@@ -642,10 +829,44 @@ func (h *Hub) Run() {
case "wsSaveSources": case "wsSaveSources":
if h.sm != nil { if h.sm != nil {
if err := h.sm.Save(); err != nil { // Save writes to disk; run it off the Run() goroutine so a
log.Printf("hub: save sources: %v", err) // slow filesystem can never stall the hub loop.
} go func(sm *SourceManager) {
err := sm.Save()
if err != nil {
log.Printf("hub: save config: %v", err)
}
h.broadcast(buildConfigAckMsg("configSaved", sm.Path(), err))
}(h.sm)
} }
case "wsSetCalibration":
if h.cal.Set(cmd.cal) {
h.broadcast(buildCalibrationMsg(h.cal))
} else {
// No broadcast: the offending client reverts to the last
// value it was sent.
log.Printf("hub: rejected calibration %q/%q (scale=%v offset=%v)",
cmd.cal.Source, cmd.cal.Signal, cmd.cal.Scale, cmd.cal.Offset)
}
case "wsReloadConfig":
if h.sm != nil {
// Reload calls sm.Add(), which sends on commandCh; from the
// Run() goroutine that send would hit the non-blocking
// default and be dropped, so it must run elsewhere.
go func(sm *SourceManager) {
err := sm.Reload()
if err != nil {
log.Printf("hub: reload config: %v", err)
}
h.broadcast(buildConfigAckMsg("configReloaded", sm.Path(), err))
if err == nil {
h.broadcast(buildCalibrationMsg(h.cal))
}
}(h.sm)
}
case "setMonotonic": case "setMonotonic":
h.monotonicTS = cmd.enabled h.monotonicTS = cmd.enabled
monoMsg, _ := json.Marshal(map[string]any{"type": "monotonicState", "enabled": h.monotonicTS}) monoMsg, _ := json.Marshal(map[string]any{"type": "monotonicState", "enabled": h.monotonicTS})
@@ -661,10 +882,15 @@ func (h *Hub) Run() {
continue continue
} }
src, ok := sourcesMap[srcID] src, ok := sourcesMap[srcID]
if !ok || len(src.signals) == 0 || len(h.clients) == 0 { if !ok || len(src.signals) == 0 {
pending[srcID] = pending[srcID][:0] pending[srcID] = pending[srcID][:0]
continue continue
} }
// Built even with no clients connected: this is also what feeds
// the rings, the disk history and the trigger, none of which may
// stop just because nobody is watching. It also keeps the push
// cursors advancing, so the first client to connect does not get
// a backlog burst. Matches the C++ StreamHub.
msg := h.buildBinaryDataMessageForSource(src, samples) msg := h.buildBinaryDataMessageForSource(src, samples)
pending[srcID] = pending[srcID][:0] pending[srcID] = pending[srcID][:0]
if msg != nil { if msg != nil {
@@ -678,6 +904,9 @@ func (h *Hub) Run() {
} }
h.triggerTick() h.triggerTick()
case <-flushTicker.C:
h.hist.flushHeaders()
case <-statsTicker.C: case <-statsTicker.C:
h.statsMu.RLock() h.statsMu.RLock()
snap := make(map[string]StatInfo, len(h.statsMap)) snap := make(map[string]StatInfo, len(h.statsMap))
@@ -715,9 +944,21 @@ func writeFloat64s(buf []byte, off int, f []float64) int {
// ever recover, and the browser already decimates for display. // ever recover, and the browser already decimates for display.
const maxPushPoints = 50 const maxPushPoints = 50
// Zoom ring depth, in samples per signal (16 bytes each). ringCapTemporal // Ring geometry, in samples per signal (16 bytes each).
// holds 6 s of a 1 MSps waveform; ringCapScalar holds 100 000 packets. //
const ringCapTemporal = 6_000_000 // defaultRingPts is the per-signal memory budget for temporal (array) signals:
// what the hub may spend keeping one signal available for zoom and for trigger
// captures. 10 M points is 160 MB. The budget buys resolution, not span —
// retuneRings buckets the input so the display window fits whatever the source
// rate is.
//
// ringCapInitial is where a ring starts, so a source that is configured but
// never sends costs nothing; the first retune sweep grows it to the budget.
//
// ringCapScalar sizes scalar signals, which arrive at the packet rate and would
// squander a budget meant for megasample streams.
const defaultRingPts = 10_000_000
const ringCapInitial = 250_000
const ringCapScalar = 100_000 const ringCapScalar = 100_000
// monotonicTolerance is the maximum inter-frame timestamp deviation (seconds) // monotonicTolerance is the maximum inter-frame timestamp deviation (seconds)
@@ -730,52 +971,59 @@ const monotonicTolerance = 0.005 // 5 ms
// track real rate changes, slow enough to average out per-frame jitter. // track real rate changes, slow enough to average out per-frame jitter.
const monotonicEMAAlpha = 0.01 const monotonicEMAAlpha = 0.01
// lttbDecimate reduces (tIn, vIn) to at most threshold representative points // minMaxDecimate reduces (tIn, vIn) to at most threshold points the way an
// using the Largest-Triangle-Three-Buckets algorithm. // oscilloscope draws a trace it cannot show pixel-for-pixel: the range is split
func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) { // into threshold/2 equal buckets and each contributes its smallest and largest
// sample, in the order the two occurred.
//
// This is what replaced LTTB on every path here. LTTB picks the sample that
// makes the largest triangle with its neighbours, which reads as a plausible
// shape but silently drops a one-sample spike whenever a smoother neighbour
// scores higher — precisely the sample the user is looking for. The envelope
// cannot drop it: a spike is by definition its bucket's min or max. The cost is
// that a flat trace is drawn as a band rather than a line, which is how a scope
// behaves too.
//
// Both output arrays hold real samples with their real timestamps; nothing is
// interpolated or averaged.
func minMaxDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
n := len(tIn) n := len(tIn)
if n <= threshold || threshold < 3 { // Below four there is no room for a single min/max pair plus endpoints.
if n <= threshold || threshold < 4 {
return tIn, vIn return tIn, vIn
} }
outT := make([]float64, threshold) buckets := threshold / 2
outV := make([]float64, threshold) outT := make([]float64, 0, threshold)
outT[0], outV[0] = tIn[0], vIn[0] outV := make([]float64, 0, threshold)
outT[threshold-1], outV[threshold-1] = tIn[n-1], vIn[n-1] for b := 0; b < buckets; b++ {
lo := b * n / buckets
every := float64(n-2) / float64(threshold-2) hi := (b + 1) * n / buckets
a := 0 if b == buckets-1 {
for i := 0; i < threshold-2; i++ { hi = n
avgS := int(float64(i+1)*every) + 1
avgE := int(float64(i+2)*every) + 1
if avgE > n {
avgE = n
} }
avgT, avgV, cnt := 0.0, 0.0, 0 if lo >= hi {
for j := avgS; j < avgE; j++ { continue
avgT += tIn[j]
avgV += vIn[j]
cnt++
} }
if cnt > 0 { iMin, iMax := lo, lo
avgT /= float64(cnt) for j := lo + 1; j < hi; j++ {
avgV /= float64(cnt) if vIn[j] < vIn[iMin] {
} iMin = j
rS := int(float64(i)*every) + 1 }
rE := int(float64(i+1)*every) + 1 if vIn[j] > vIn[iMax] {
if rE > n { iMax = j
rE = n
}
maxArea, next := -1.0, rS
aT, aV := tIn[a], vIn[a]
for j := rS; j < rE; j++ {
area := math.Abs((aT-avgT)*(vIn[j]-aV) - (aT-tIn[j])*(avgV-aV))
if area > maxArea {
maxArea = area
next = j
} }
} }
outT[i+1], outV[i+1] = tIn[next], vIn[next] // Emit in time order so the result plots as one ascending trace.
a = next if iMin > iMax {
iMin, iMax = iMax, iMin
}
outT = append(outT, tIn[iMin])
outV = append(outV, vIn[iMin])
// A bucket whose samples are all equal has one extreme, not two.
if iMax != iMin {
outT = append(outT, tIn[iMax])
outV = append(outV, vIn[iMax])
}
} }
return outT, outV return outT, outV
} }
@@ -887,11 +1135,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
allV = append(allV, vals[k]) allV = append(allV, vals[k])
} }
} }
if rb := h.getRing(pfx + sig.Name); rb != nil { h.ingest(pfx+sig.Name, n, allT, allV)
rb.write(allT, allV) decimT, decimV := minMaxDecimate(allT, allV, maxPushPoints)
}
h.trigger.feed(pfx+sig.Name, n, allT, allV)
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
pairs[sig.Name] = pairBuf{t: decimT, v: decimV} pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
case sig.TimeMode == udpsprotocol.TimeModeFullArray: case sig.TimeMode == udpsprotocol.TimeModeFullArray:
@@ -933,11 +1178,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
allV = append(allV, vals[k]) allV = append(allV, vals[k])
} }
} }
if rb := h.getRing(pfx + sig.Name); rb != nil { h.ingest(pfx+sig.Name, n, allT, allV)
rb.write(allT, allV) decimT, decimV := minMaxDecimate(allT, allV, maxPushPoints)
}
h.trigger.feed(pfx+sig.Name, n, allT, allV)
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
pairs[sig.Name] = pairBuf{t: decimT, v: decimV} pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
case n == 1: case n == 1:
@@ -951,10 +1193,7 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
ts = append(ts, float64(s.WallTime.UnixNano())/1e9) ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
vs = append(vs, vals[0]) vs = append(vs, vals[0])
} }
if rb := h.getRing(pfx + sig.Name); rb != nil { h.ingest(pfx+sig.Name, 1, ts, vs)
rb.write(ts, vs)
}
h.trigger.feed(pfx+sig.Name, 1, ts, vs)
pairs[sig.Name] = pairBuf{t: ts, v: vs} pairs[sig.Name] = pairBuf{t: ts, v: vs}
default: default:
@@ -1020,12 +1259,19 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
src.lastPktNs[sig.Name] = batch[len(batch)-1].WallTime.UnixNano() src.lastPktNs[sig.Name] = batch[len(batch)-1].WallTime.UnixNano()
} }
if len(allT) > 0 { if len(allT) > 0 {
if rb := h.getRing(pfx + sig.Name); rb != nil { h.ingest(pfx+sig.Name, n, allT, allV)
rb.write(allT, allV) // Live push: never below one packet's worth of elements, or LTTB
// would flatten the snapshot waveform itself; never above it
// either, since anything more is just packets that piled up
// during the tick. Pushing every point unconditionally does not
// survive a fast producer: a 5 kHz x 1000-element array is 5M
// points/s on the wire and the client queue never drains.
thr := maxPushPoints
if n > thr {
thr = n
} }
h.trigger.feed(pfx+sig.Name, n, allT, allV) decimT, decimV := minMaxDecimate(allT, allV, thr)
// Live push: send all points without LTTB (fix 2). pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
pairs[sig.Name] = pairBuf{t: allT, v: allV}
} }
} }
} }
@@ -0,0 +1,132 @@
package wshub
import (
"encoding/json"
"errors"
"testing"
"time"
)
func TestBuildCalibrationMsg(t *testing.T) {
tab := newCalTable()
tab.Set(CalConfig{Source: "wave", Signal: "Adc", Scale: 0.5, Offset: -1.25, Unit: "V"})
var got struct {
Type string `json:"type"`
Cal []CalConfig `json:"cal"`
}
raw := buildCalibrationMsg(tab)
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if got.Type != "calibration" {
t.Errorf("type = %q, want calibration", got.Type)
}
if len(got.Cal) != 1 || got.Cal[0] != (CalConfig{
Source: "wave", Signal: "Adc", Scale: 0.5, Offset: -1.25, Unit: "V"}) {
t.Errorf("cal = %+v", got.Cal)
}
}
func TestBuildCalibrationMsgEmptyTableIsEmptyArray(t *testing.T) {
// The SPA replaces its table wholesale on every calibration message, so an
// empty table must serialise as [] and not as null.
raw := buildCalibrationMsg(newCalTable())
var got struct {
Cal []CalConfig `json:"cal"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if got.Cal == nil {
t.Errorf("cal = null, want []; raw = %s", raw)
}
}
func TestBuildConfigAckMsg(t *testing.T) {
ok := buildConfigAckMsg("configSaved", "/tmp/x.json", nil)
var m map[string]any
if err := json.Unmarshal(ok, &m); err != nil {
t.Fatal(err)
}
if m["type"] != "configSaved" || m["ok"] != true || m["path"] != "/tmp/x.json" {
t.Errorf("success ack = %s", ok)
}
if _, has := m["error"]; has {
t.Errorf("success ack carries an error field: %s", ok)
}
bad := buildConfigAckMsg("configReloaded", "", errors.New("boom"))
m = nil
if err := json.Unmarshal(bad, &m); err != nil {
t.Fatal(err)
}
if m["type"] != "configReloaded" || m["ok"] != false || m["error"] != "boom" {
t.Errorf("failure ack = %s", bad)
}
}
func TestHubSetCalibrationCommand(t *testing.T) {
h := NewHub()
go h.Run()
// Register a client before sending commands so broadcasts are observable.
sendCh := make(chan wsMessage, 64)
c := &wsClient{hub: h, send: sendCh}
h.register <- c
sleepMillis(20) // let Run() process the register and flush initial state msgs
drainSendCh(sendCh) // discard state-sync messages (sources, trigger, cal, ...)
h.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
Source: "wave", Signal: "Adc", Scale: 4, Offset: 1, Unit: "V"}}
if raw := waitMsg(t, sendCh, "calibration"); raw == nil {
t.Fatal("no calibration broadcast after a valid setCalibration")
}
if got := h.cal.List(); len(got) != 1 || got[0].Scale != 4 {
t.Fatalf("table = %+v, want one entry with scale 4", got)
}
// An invalid entry is rejected and emits no broadcast at all.
h.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
Source: "wave", Signal: "Adc", Scale: 0}}
if raw := waitMsg(t, sendCh, "calibration"); raw != nil {
t.Errorf("invalid setCalibration broadcast %s", raw)
}
if got := h.cal.List(); len(got) != 1 || got[0].Scale != 4 {
t.Errorf("table changed after a rejected setCalibration: %+v", got)
}
h.unregister <- c
}
// drainSendCh reads all currently buffered messages from the channel.
func drainSendCh(ch chan wsMessage) {
for {
select {
case <-ch:
default:
return
}
}
}
// waitMsg waits up to ~250 ms for a message of the given type on sendCh.
func waitMsg(t *testing.T, sendCh chan wsMessage, msgType string) []byte {
t.Helper()
deadline := time.After(250 * time.Millisecond)
for {
select {
case msg := <-sendCh:
var env struct {
Type string `json:"type"`
}
if json.Unmarshal(msg.data, &env) == nil && env.Type == msgType {
return msg.data
}
case <-deadline:
return nil
}
}
}
func sleepMillis(n int) { time.Sleep(time.Duration(n) * time.Millisecond) }
@@ -0,0 +1,116 @@
//go:build linux
package wshub
import (
"net"
"syscall"
"testing"
"time"
)
// setMulticastIf pins a socket's outgoing multicast interface (IP_MULTICAST_IF),
// which is exactly what UDPStreamer/UDPSServer does with its `Interface` key.
func setMulticastIf(t *testing.T, conn *net.UDPConn, ip [4]byte) {
t.Helper()
rc, err := conn.SyscallConn()
if err != nil {
t.Fatalf("SyscallConn: %v", err)
}
var sockErr error
if err := rc.Control(func(fd uintptr) {
sockErr = syscall.SetsockoptInet4Addr(int(fd), syscall.IPPROTO_IP, syscall.IP_MULTICAST_IF, ip)
}); err != nil {
t.Fatalf("Control: %v", err)
}
if sockErr != nil {
t.Fatalf("IP_MULTICAST_IF: %v", sockErr)
}
}
func TestInterfaceForIPResolvesLoopback(t *testing.T) {
ifi := interfaceForIP(net.ParseIP("127.0.0.1"))
if ifi == nil {
t.Fatal("no interface resolved for 127.0.0.1")
}
if ifi.Flags&net.FlagLoopback == 0 {
t.Fatalf("resolved %q for 127.0.0.1, which is not a loopback interface", ifi.Name)
}
}
func TestInterfaceForIPUnknownAddressIsNil(t *testing.T) {
// Unspecified and unassigned addresses must fall back to "let the kernel
// choose" rather than resolving to an arbitrary interface.
if ifi := interfaceForIP(net.IPv4zero); ifi != nil {
t.Fatalf("0.0.0.0 resolved to %q, want nil", ifi.Name)
}
if ifi := interfaceForIP(nil); ifi != nil {
t.Fatalf("nil IP resolved to %q, want nil", ifi.Name)
}
if ifi := interfaceForIP(net.ParseIP("203.0.113.42")); ifi != nil {
t.Fatalf("unassigned address resolved to %q, want nil", ifi.Name)
}
}
// TestMulticastJoinOnControlInterfaceReceivesData is the regression test for the
// bug that left the web UI permanently blank: the hub joined the group with a
// nil interface, so imr_interface stayed INADDR_ANY and the kernel picked the
// default-route interface. A UDPStreamer configured with Interface = "127.0.0.1"
// sends out the loopback instead, and every datagram was silently dropped.
//
// The sender here mimics that server exactly (IP_MULTICAST_IF = 127.0.0.1); the
// receiver joins the way runMulticastSession now does, via the interface that
// owns the control connection's local address.
func TestMulticastJoinOnControlInterfaceReceivesData(t *testing.T) {
const group = "239.255.13.37"
ifi := interfaceForIP(net.ParseIP("127.0.0.1"))
if ifi == nil {
t.Skip("no loopback interface available")
}
rx, err := net.ListenMulticastUDP("udp4", ifi, &net.UDPAddr{IP: net.ParseIP(group), Port: 0})
if err != nil {
t.Fatalf("join %s on %s: %v", group, ifi.Name, err)
}
defer rx.Close()
port := rx.LocalAddr().(*net.UDPAddr).Port
tx, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
t.Fatalf("sender socket: %v", err)
}
defer tx.Close()
setMulticastIf(t, tx, [4]byte{127, 0, 0, 1})
payload := []byte("UDPS-multicast-probe")
dst := &net.UDPAddr{IP: net.ParseIP(group), Port: port}
// Datagrams are lossy even on loopback if the join has not settled, so send
// a few and accept the first that lands.
done := make(chan struct{})
defer close(done)
go func() {
for {
select {
case <-done:
return
default:
}
tx.WriteToUDP(payload, dst)
time.Sleep(20 * time.Millisecond)
}
}()
buf := make([]byte, 128)
if err := rx.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
t.Fatal(err)
}
n, _, err := rx.ReadFromUDP(buf)
if err != nil {
t.Fatalf("no multicast received on %s within 3s: %v", ifi.Name, err)
}
if got := string(buf[:n]); got != string(payload) {
t.Fatalf("payload = %q, want %q", got, payload)
}
}
+317 -9
View File
@@ -1,6 +1,10 @@
package wshub package wshub
import "sync" import (
"log"
"math"
"sync"
)
// sigRing is a fixed-capacity circular buffer storing (time, value) pairs. // sigRing is a fixed-capacity circular buffer storing (time, value) pairs.
// Writes come from the Hub.Run() goroutine; reads come from HTTP handler goroutines. // Writes come from the Hub.Run() goroutine; reads come from HTTP handler goroutines.
@@ -10,30 +14,334 @@ type sigRing struct {
t, v []float64 t, v []float64
cap int cap int
head, size int // next write position; current fill head, size int // next write position; current fill
// bucket is how many source samples collapse into one min/max pair on the
// way in. 1 stores the stream verbatim. Raising it trades resolution for
// the timespan a fixed capacity covers, which is what lets a long display
// window fit in a fixed per-signal memory budget.
bucket int
// In-progress bucket. accN counts source samples seen since the last pair
// was emitted; the four acc fields are the extrema and when they occurred.
accN int
accTMin, accVMin float64
accTMax, accVMax float64
// Source-sample accounting, kept because size and the stored timespan no
// longer give the source rate once bucket > 1. Reset every
// srcRateWindowSec so a producer restart or a rate change is not averaged
// against the whole run.
srcCount int64
srcT0, srcT1 float64
haveSrc bool
} }
// srcRateWindowSec bounds how long a source-rate measurement accumulates before
// starting over. Long enough to average out per-frame jitter, short enough that
// a rate change is reflected within a few seconds.
const srcRateWindowSec = 10.0
func newSigRing(capacity int) *sigRing { func newSigRing(capacity int) *sigRing {
return &sigRing{ return &sigRing{
t: make([]float64, capacity), t: make([]float64, capacity),
v: make([]float64, capacity), v: make([]float64, capacity),
cap: capacity, cap: capacity,
bucket: 1,
} }
} }
// write appends (tArr[i], vArr[i]) pairs, overwriting oldest entries when full. // write appends (tArr[i], vArr[i]) pairs, overwriting oldest entries when full.
// With bucket > 1 each group of bucket samples contributes only its minimum and
// its maximum, in the order the two occurred.
func (rb *sigRing) write(tArr, vArr []float64) { func (rb *sigRing) write(tArr, vArr []float64) {
rb.mu.Lock() rb.mu.Lock()
defer rb.mu.Unlock() defer rb.mu.Unlock()
if n := len(tArr); n > 0 {
if !rb.haveSrc || tArr[n-1]-rb.srcT0 > srcRateWindowSec || tArr[0] < rb.srcT0 {
rb.srcT0, rb.srcCount, rb.haveSrc = tArr[0], 0, true
}
rb.srcT1 = tArr[n-1]
rb.srcCount += int64(n)
}
if rb.bucket <= 1 {
for i := 0; i < len(tArr); i++ {
rb.pushLocked(tArr[i], vArr[i])
}
return
}
for i := 0; i < len(tArr); i++ { for i := 0; i < len(tArr); i++ {
rb.t[rb.head] = tArr[i] t, v := tArr[i], vArr[i]
rb.v[rb.head] = vArr[i] if rb.accN == 0 {
rb.head = (rb.head + 1) % rb.cap rb.accTMin, rb.accVMin, rb.accTMax, rb.accVMax = t, v, t, v
if rb.size < rb.cap { } else {
rb.size++ if v < rb.accVMin {
rb.accTMin, rb.accVMin = t, v
}
if v > rb.accVMax {
rb.accTMax, rb.accVMax = t, v
}
}
rb.accN++
if rb.accN >= rb.bucket {
rb.flushBucketLocked()
} }
} }
} }
func (rb *sigRing) pushLocked(t, v float64) {
rb.t[rb.head] = t
rb.v[rb.head] = v
rb.head = (rb.head + 1) % rb.cap
if rb.size < rb.cap {
rb.size++
}
}
// flushBucketLocked emits the accumulated extrema oldest-first. Time order
// matters: every read binary-searches rb.t, so the stored timestamps must stay
// non-decreasing.
func (rb *sigRing) flushBucketLocked() {
if rb.accN == 0 {
return
}
if rb.accTMin <= rb.accTMax {
rb.pushLocked(rb.accTMin, rb.accVMin)
rb.pushLocked(rb.accTMax, rb.accVMax)
} else {
rb.pushLocked(rb.accTMax, rb.accVMax)
rb.pushLocked(rb.accTMin, rb.accVMin)
}
rb.accN = 0
}
// setBucket changes the min/max reduction applied to incoming samples and
// reports whether it changed. Samples already stored keep the resolution they
// were written at; the ring converges on the new one as it rolls.
func (rb *sigRing) setBucket(n int) bool {
if n < 1 {
n = 1
}
rb.mu.Lock()
defer rb.mu.Unlock()
if n == rb.bucket {
return false
}
// Emit what the old bucket had collected rather than dropping it.
rb.flushBucketLocked()
rb.bucket = n
return true
}
func (rb *sigRing) bucketSize() int {
rb.mu.RLock()
defer rb.mu.RUnlock()
return rb.bucket
}
// sourceRate is the measured rate of the incoming stream in samples per second,
// or 0 while there is too little to extrapolate from. Unlike stats() it counts
// source samples, so it is unaffected by bucketing.
func (rb *sigRing) sourceRate() float64 {
rb.mu.RLock()
defer rb.mu.RUnlock()
if rb.srcCount < 2 || rb.srcT1 <= rb.srcT0 {
return 0
}
return float64(rb.srcCount-1) / (rb.srcT1 - rb.srcT0)
}
// stats reports the current fill and the timespan it covers, so callers can
// estimate the stream's sample rate without copying the data out.
func (rb *sigRing) stats() (count int, span float64) {
rb.mu.RLock()
defer rb.mu.RUnlock()
if rb.size < 2 {
return rb.size, 0
}
start := 0
if rb.size == rb.cap {
start = rb.head
}
oldest := rb.t[start]
newest := rb.t[(start+rb.size-1)%rb.cap]
return rb.size, newest - oldest
}
func (rb *sigRing) capacity() int {
rb.mu.RLock()
defer rb.mu.RUnlock()
return rb.cap
}
// ─── Ring tuning ─────────────────────────────────────────────────────────────
// ringHeadroom oversizes a reduced ring's span. It absorbs rate jitter and
// keeps the tail of a trigger window in the buffer long enough for the capture
// to read it. It applies only once the window no longer fits verbatim: at the
// boundary, spending a whole extra bucket step to buy 25 % more span would cost
// half the resolution.
const ringHeadroom = 1.25
// ringTuneIntervalSec throttles the retune sweep. The source rate only settles
// once data flows, so the sweep repeats rather than running once.
const ringTuneIntervalSec = 1.0
// defaultLiveWindowSec is the window assumed when no client has said what it is
// displaying — the native clients never do, and a browser has not yet at the
// moment the first samples land.
const defaultLiveWindowSec = 10.0
// ringBucketFor is how many source samples must collapse into one min/max pair
// for `window` seconds at `rate` samples/s to fit in `capacity` points.
//
// rate*window <= capacity → 1, the buffer stays verbatim and reaches further
// back than the window, which is free zoom headroom
// rate*window > capacity → >1, so the whole window fits at reduced resolution
//
// A bucket costs two points (its minimum and its maximum), hence the factor 2.
func ringBucketFor(rate, window float64, capacity int) int {
if capacity <= 0 || rate <= 0 || window <= 0 {
return 1
}
need := rate * window
if need <= float64(capacity) {
return 1
}
return int(math.Ceil(2 * need * ringHeadroom / float64(capacity)))
}
// ringCoverage is how many source samples a ring of `capacity` points holds at
// the given bucket. A bucket of 2 stores both of its samples, so it covers no
// more ground than a bucket of 1.
func ringCoverage(bucket, capacity int) int {
if bucket <= 2 {
return capacity
}
return capacity / 2 * bucket
}
// activeWindowSec is the timespan the buffers must cover. An armed trigger owns
// it: its pre-window has to already be in the ring when the trigger fires or
// there is nothing to back-fill the capture from. Otherwise it is the widest
// window any connected client is displaying.
func (h *Hub) activeWindowSec() float64 {
if h.trigger != nil && h.trigger.Active() {
if cfg := h.trigger.Config(); cfg.windowSec > 0 {
return cfg.windowSec
}
}
widest := 0.0
for c := range h.clients {
if w := c.displayWindowSec(); w > widest {
widest = w
}
}
if widest <= 0 {
return defaultLiveWindowSec
}
return widest
}
// retuneRings keeps every ring matched to the window being displayed: grown
// towards the per-signal budget, and bucketed so the window fits inside it.
//
// A fixed sample-count ring covers a fraction of a second at a megasample rate,
// which is why long windows used to come back with only their tail populated —
// in live mode as much as under a trigger. Spending the budget on min/max pairs
// rather than on a bigger allocation is what makes an arbitrarily long window
// work within a fixed memory bound.
//
// Called from Hub.Run() only, so reading h.clients here needs no lock.
func (h *Hub) retuneRings(nowSec float64) {
if nowSec < h.ringTuneAt {
return
}
h.ringTuneAt = nowSec + ringTuneIntervalSec
window := h.activeWindowSec()
if window <= 0 {
return
}
// The archive answers for the same window as the rings — it is what a zoom
// or a capture falls back on once they have rolled past it — so it is sized
// from the same number.
if h.hist.setWindow(window) {
if msg := h.buildHistoryInfoMsg(); msg != nil {
h.broadcast(msg)
}
}
budget := h.ringBudget()
h.ringsMu.RLock()
keys := make([]string, 0, len(h.rings))
rings := make([]*sigRing, 0, len(h.rings))
for k, rb := range h.rings {
keys = append(keys, k)
rings = append(rings, rb)
}
h.ringsMu.RUnlock()
for i, rb := range rings {
rate := rb.sourceRate()
if rate <= 0 {
continue
}
// Claim the whole budget before deciding on a bucket: memory is what
// buys resolution, so it is spent first and reduced from only if the
// window still does not fit.
if rb.capacity() < budget && rate*window > float64(rb.capacity()) {
rb.grow(budget)
}
cur := rb.bucketSize()
need := rate * window
covered := float64(ringCoverage(cur, rb.capacity()))
// Hysteresis. Retuning up and retuning down must not share a threshold:
// a bucket step doubles or halves the span, so a rate jittering across
// the boundary would otherwise flip the resolution every second. Hold
// the current bucket while it covers the window without covering more
// than twice it.
if covered >= need && covered <= 2*need {
continue
}
want := ringBucketFor(rate, window, rb.capacity())
if !rb.setBucket(want) {
continue
}
if want > 1 {
log.Printf("hub: ring %s stores min/max over %d samples: %.0f s at %.0f kSps does not fit in %d points",
keys[i], want, window, rate/1e3, rb.capacity())
} else {
log.Printf("hub: ring %s back to full resolution: %.0f s at %.0f kSps fits in %d points",
keys[i], window, rate/1e3, rb.capacity())
}
}
}
// grow enlarges the buffer to newCap, keeping every sample it currently holds.
// Shrinking is refused: it would discard history a pending capture may need.
func (rb *sigRing) grow(newCap int) bool {
rb.mu.Lock()
defer rb.mu.Unlock()
if newCap <= rb.cap {
return false
}
nt := make([]float64, newCap)
nv := make([]float64, newCap)
start := 0
if rb.size == rb.cap {
start = rb.head
}
for i := 0; i < rb.size; i++ {
p := (start + i) % rb.cap
nt[i], nv[i] = rb.t[p], rb.v[p]
}
rb.t, rb.v = nt, nv
rb.cap = newCap
rb.head = rb.size // size < newCap, so no wrap
return true
}
// slice returns copies of all (t, v) pairs whose timestamp falls in [t0, t1]. // slice returns copies of all (t, v) pairs whose timestamp falls in [t0, t1].
// The returned slices are safe to use after the call without holding any lock. // The returned slices are safe to use after the call without holding any lock.
func (rb *sigRing) slice(t0, t1 float64) ([]float64, []float64) { func (rb *sigRing) slice(t0, t1 float64) ([]float64, []float64) {
+147
View File
@@ -0,0 +1,147 @@
package wshub
import (
"math"
"testing"
)
// dump returns the ring's contents oldest-first, which is what every reader
// sees through slice() but is easier to assert on directly.
func dump(rb *sigRing) ([]float64, []float64) {
return rb.slice(math.Inf(-1), math.Inf(1))
}
func TestRingBucketStoresMinMaxPairsInTimeOrder(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(4)
// Two buckets. In the first the minimum comes before the maximum, in the
// second the order is reversed, so the emitted pairs must not be sorted by
// value — a ring whose timestamps are not monotonic breaks slice()'s
// binary search.
ts := []float64{0, 1, 2, 3, 4, 5, 6, 7}
vs := []float64{-5, 0, 0, 9, 9, 0, 0, -5}
rb.write(ts, vs)
gotT, gotV := dump(rb)
wantT := []float64{0, 3, 4, 7}
wantV := []float64{-5, 9, 9, -5}
if len(gotT) != len(wantT) {
t.Fatalf("stored %d points, want %d", len(gotT), len(wantT))
}
for i := range wantT {
if gotT[i] != wantT[i] || gotV[i] != wantV[i] {
t.Fatalf("point %d = (%v,%v), want (%v,%v)", i, gotT[i], gotV[i], wantT[i], wantV[i])
}
}
}
func TestRingBucketExtendsTheSpanAFixedCapacityCovers(t *testing.T) {
const cap = 200
// 2000 samples at 1 kHz is 2 s, ten times what the capacity holds verbatim.
ts := make([]float64, 2000)
vs := make([]float64, 2000)
for i := range ts {
ts[i] = float64(i) * 1e-3
vs[i] = math.Sin(float64(i))
}
full := newSigRing(cap)
full.write(ts, vs)
if _, span := full.stats(); span > 0.25 {
t.Fatalf("full-rate ring spans %.3f s, expected ~0.2 s", span)
}
// bucket 20 turns 20 samples into 2 points, so the same capacity reaches
// 10x further: 200/2*20 = 2000 samples = 2 s.
bucketed := newSigRing(cap)
bucketed.setBucket(20)
bucketed.write(ts, vs)
count, span := bucketed.stats()
if count != cap {
t.Fatalf("bucketed ring holds %d points, want the full %d", count, cap)
}
if span < 1.9 {
t.Fatalf("bucketed ring spans %.3f s, want the whole ~2 s", span)
}
}
func TestRingSourceRateIsUnaffectedByBucketing(t *testing.T) {
rb := newSigRing(1000)
rb.setBucket(50)
ts := make([]float64, 5000)
vs := make([]float64, 5000)
for i := range ts {
ts[i] = float64(i) * 1e-4 // 10 kHz
}
rb.write(ts, vs)
got := rb.sourceRate()
if math.Abs(got-10000) > 10 {
t.Fatalf("sourceRate = %.1f, want ~10000", got)
}
}
func TestSetBucketFlushesThePartialBucket(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(10)
// Three samples: not enough to close a bucket of 10, so nothing is stored
// yet and they would be silently dropped by a re-bucket that just reset the
// accumulator.
rb.write([]float64{0, 1, 2}, []float64{7, -7, 0})
if n, _ := rb.stats(); n != 0 {
t.Fatalf("partial bucket already emitted %d points", n)
}
rb.setBucket(2)
gotT, gotV := dump(rb)
if len(gotT) != 2 || gotT[0] != 0 || gotV[0] != 7 || gotT[1] != 1 || gotV[1] != -7 {
t.Fatalf("flushed pair = %v/%v, want t=[0 1] v=[7 -7]", gotT, gotV)
}
}
func TestActiveWindowSecFallsBackToTheDefault(t *testing.T) {
h := NewHub()
if got := h.activeWindowSec(); got != defaultLiveWindowSec {
t.Fatalf("activeWindowSec with no clients = %v, want %v", got, defaultLiveWindowSec)
}
}
// Clients disagree about how far back they are plotting, and a buffer sized for
// the narrowest one leaves the others with nothing to zoom into.
func TestActiveWindowSecTakesTheWidestClientWindow(t *testing.T) {
h := NewHub()
narrow, wide, silent := &wsClient{}, &wsClient{}, &wsClient{}
narrow.setDisplayWindowSec(1)
wide.setDisplayWindowSec(120)
h.clients[narrow], h.clients[wide], h.clients[silent] = true, true, true
if got := h.activeWindowSec(); got != 120 {
t.Fatalf("activeWindowSec = %v, want the widest 120", got)
}
}
// An armed trigger owns the window: its pre-window has to be in the buffer
// before the trigger fires or the capture has nothing to back-fill from.
func TestActiveWindowSecPrefersTheArmedTrigger(t *testing.T) {
h := NewHub()
c := &wsClient{}
c.setDisplayWindowSec(1)
h.clients[c] = true
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 45, mode: "normal"})
if got := h.activeWindowSec(); got != 45 {
t.Fatalf("activeWindowSec = %v, want the trigger's 45", got)
}
}
func TestSetBucketToOneRestoresVerbatimStorage(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(4)
rb.setBucket(1)
ts := []float64{0, 1, 2, 3}
vs := []float64{1, 2, 3, 4}
rb.write(ts, vs)
gotT, _ := dump(rb)
if len(gotT) != 4 {
t.Fatalf("stored %d points, want all 4", len(gotT))
}
}
+138 -14
View File
@@ -1,12 +1,12 @@
package wshub package wshub
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net" "net"
"os" "os"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -95,11 +95,16 @@ func (sm *SourceManager) Remove(id string) {
} }
} }
// Save writes the current source list to filePath. // Path returns the configured config-file path ("" when none).
func (sm *SourceManager) Save() error { func (sm *SourceManager) Path() string {
if sm.filePath == "" { sm.mu.RLock()
return fmt.Errorf("no sources-file configured") defer sm.mu.RUnlock()
} return sm.filePath
}
// snapshotSources returns the current sources sorted by label, so the written
// file is byte-stable across runs (the map iteration order is not).
func (sm *SourceManager) snapshotSources() []SourceConfig {
sm.mu.RLock() sm.mu.RLock()
cfgs := make([]SourceConfig, 0, len(sm.sources)) cfgs := make([]SourceConfig, 0, len(sm.sources))
for _, ms := range sm.sources { for _, ms := range sm.sources {
@@ -111,26 +116,86 @@ func (sm *SourceManager) Save() error {
}) })
} }
sm.mu.RUnlock() sm.mu.RUnlock()
sort.Slice(cfgs, func(i, j int) bool {
if cfgs[i].Label != cfgs[j].Label {
return cfgs[i].Label < cfgs[j].Label
}
return cfgs[i].Addr < cfgs[j].Addr
})
return cfgs
}
data, err := json.MarshalIndent(cfgs, "", " ") // Save writes the current source list and calibration table to filePath as one
// flat JSON array.
func (sm *SourceManager) Save() error {
path := sm.Path()
if path == "" {
return fmt.Errorf("no sources-file configured")
}
data, err := encodeConfigFile(sm.snapshotSources(), sm.hub.cal.List())
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(sm.filePath, data, 0644) return os.WriteFile(path, data, 0644)
} }
// Load reads sources from path and adds them. // Load reads the config file at path, replaces the calibration table with its
// contents and starts every source it lists.
func (sm *SourceManager) Load(path string) error { func (sm *SourceManager) Load(path string) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return err
} }
var cfgs []SourceConfig srcs, cals, err := parseConfigFile(data)
if err := json.Unmarshal(data, &cfgs); err != nil { if err != nil {
return err return err
} }
sm.mu.Lock()
sm.filePath = path sm.filePath = path
for _, cfg := range cfgs { sm.mu.Unlock()
sm.hub.cal.Replace(cals)
for _, cfg := range srcs {
sm.Add(cfg.Label, cfg.Addr, cfg.MulticastGroup, cfg.DataPort)
}
return nil
}
// Reload re-reads the config file. The calibration table is replaced wholesale
// and sources listed in the file that are not already running are started; no
// live source is ever stopped, restarted or reconnected, because a reload must
// not interrupt streaming. The asymmetry is deliberate: calibration is cheap
// to reapply, a source is a live UDP session.
func (sm *SourceManager) Reload() error {
path := sm.Path()
if path == "" {
return fmt.Errorf("no sources-file configured")
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
srcs, cals, err := parseConfigFile(data)
if err != nil {
return err
}
sm.hub.cal.Replace(cals)
sm.mu.RLock()
live := make(map[string]bool, len(sm.sources))
for _, ms := range sm.sources {
live[ms.label+"\x00"+ms.addr] = true
}
sm.mu.RUnlock()
for _, cfg := range srcs {
label := cfg.Label
if label == "" {
label = cfg.Addr // Add() applies the same default
}
if live[label+"\x00"+cfg.Addr] {
continue
}
sm.Add(cfg.Label, cfg.Addr, cfg.MulticastGroup, cfg.DataPort) sm.Add(cfg.Label, cfg.Addr, cfg.MulticastGroup, cfg.DataPort)
} }
return nil return nil
@@ -382,6 +447,52 @@ func (u *UDPClient) runSession() error {
} }
} }
// interfaceForIP returns the interface that owns the given local address, or
// nil if no interface matches (in which case callers fall back to letting the
// kernel choose).
func interfaceForIP(ip net.IP) *net.Interface {
if ip == nil || ip.IsUnspecified() {
return nil
}
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for i := range ifaces {
addrs, err := ifaces[i].Addrs()
if err != nil {
continue
}
for _, a := range addrs {
var aIP net.IP
switch v := a.(type) {
case *net.IPNet:
aIP = v.IP
case *net.IPAddr:
aIP = v.IP
}
if aIP != nil && aIP.Equal(ip) {
return &ifaces[i]
}
}
}
return nil
}
// interfaceForConn returns the interface a connection's local endpoint sits on.
func interfaceForConn(c net.Conn) *net.Interface {
if c == nil {
return nil
}
switch a := c.LocalAddr().(type) {
case *net.TCPAddr:
return interfaceForIP(a.IP)
case *net.UDPAddr:
return interfaceForIP(a.IP)
}
return nil
}
// runMulticastSession handles the multicast mode session. // runMulticastSession handles the multicast mode session.
func (u *UDPClient) runMulticastSession() error { func (u *UDPClient) runMulticastSession() error {
tcpAddr, err := net.ResolveTCPAddr("tcp4", u.serverAddr) tcpAddr, err := net.ResolveTCPAddr("tcp4", u.serverAddr)
@@ -435,7 +546,15 @@ func (u *UDPClient) runMulticastSession() error {
return &net.AddrError{Err: "invalid multicast group IP", Addr: u.multicastGroup} return &net.AddrError{Err: "invalid multicast group IP", Addr: u.multicastGroup}
} }
mcastAddr := &net.UDPAddr{IP: mcastIP, Port: mcastPort} mcastAddr := &net.UDPAddr{IP: mcastIP, Port: mcastPort}
mcastConn, err := net.ListenMulticastUDP("udp4", nil, mcastAddr) // Join on the interface that reaches the control connection. The UDPStreamer
// pins its multicast sends to its configured Interface (IP_MULTICAST_IF), so
// a join with a nil interface — which leaves imr_interface at INADDR_ANY and
// lets the kernel pick the default-route interface — silently receives
// nothing whenever that is not the sending interface. The local address of
// the control connection is the interface the server is reachable on, which
// is the sending interface in every single-homed and same-host deployment.
ifi := interfaceForConn(tcpConn)
mcastConn, err := net.ListenMulticastUDP("udp4", ifi, mcastAddr)
if err != nil { if err != nil {
return err return err
} }
@@ -443,7 +562,12 @@ func (u *UDPClient) runMulticastSession() error {
if err := mcastConn.SetReadBuffer(udpRcvBufSize); err != nil { if err := mcastConn.SetReadBuffer(udpRcvBufSize); err != nil {
log.Printf("[%s] multicast SetReadBuffer: %v", u.sourceID, err) log.Printf("[%s] multicast SetReadBuffer: %v", u.sourceID, err)
} }
log.Printf("[%s] joined multicast %s:%s", u.sourceID, u.multicastGroup, strconv.Itoa(mcastPort)) ifName := "default"
if ifi != nil {
ifName = ifi.Name
}
log.Printf("[%s] joined multicast %s:%s on interface %s",
u.sourceID, u.multicastGroup, strconv.Itoa(mcastPort), ifName)
tcpDone := make(chan error, 1) tcpDone := make(chan error, 1)
go func() { go func() {
+131
View File
@@ -0,0 +1,131 @@
package wshub
import (
"os"
"path/filepath"
"testing"
)
// newTestManager builds a hub + manager pair with no goroutines running.
func newTestManager(t *testing.T) (*Hub, *SourceManager, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "sources.json")
h := NewHub()
sm := NewSourceManager(h, path)
h.SetSourceManager(sm)
return h, sm, path
}
func TestSaveWritesSourcesAndCalibration(t *testing.T) {
h, sm, path := newTestManager(t)
// Register two sources without starting any UDP client.
sm.mu.Lock()
sm.sources["s1"] = &managedSource{id: "s1", label: "wave", addr: "127.0.0.1:44500"}
sm.sources["s2"] = &managedSource{
id: "s2", label: "mc", addr: "127.0.0.1:44501",
multicastGroup: "239.0.0.1", dataPort: 44502,
}
sm.mu.Unlock()
if !h.cal.Set(CalConfig{Source: "wave", Signal: "Adc", Scale: 0.5, Offset: -1.25, Unit: "V"}) {
t.Fatal("calibration rejected")
}
if err := sm.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
srcs, cals, err := parseConfigFile(data)
if err != nil {
t.Fatalf("parseConfigFile: %v\n%s", err, data)
}
if len(srcs) != 2 {
t.Fatalf("got %d sources, want 2\n%s", len(srcs), data)
}
// Save sorts by label so the file is byte-stable across runs.
if srcs[0].Label != "mc" || srcs[1].Label != "wave" {
t.Errorf("source order = %q,%q, want mc,wave", srcs[0].Label, srcs[1].Label)
}
if len(cals) != 1 || cals[0].Signal != "Adc" || cals[0].Scale != 0.5 {
t.Fatalf("calibration round-trip failed: %+v\n%s", cals, data)
}
}
func TestSaveWithoutFilePathFails(t *testing.T) {
h := NewHub()
sm := NewSourceManager(h, "")
h.SetSourceManager(sm)
if err := sm.Save(); err == nil {
t.Error("Save() with no path = nil error, want error")
}
}
func TestLoadSeedsCalibrationTable(t *testing.T) {
h, sm, path := newTestManager(t)
// No "addr" blocks: Load must not start any UDP client during the test.
if err := os.WriteFile(path, []byte(`[
{"source":"wave","signal":"Adc","scale":0.25,"offset":2,"unit":"mV"},
{"source":"wave","signal":"Dac","scale":2}
]`), 0o644); err != nil {
t.Fatal(err)
}
if err := sm.Load(path); err != nil {
t.Fatalf("Load: %v", err)
}
got := h.cal.List()
if len(got) != 2 {
t.Fatalf("List() = %d entries, want 2", len(got))
}
if got[0].Signal != "Adc" || got[0].Unit != "mV" || got[0].Offset != 2 {
t.Errorf("Adc = %+v", got[0])
}
if sm.Path() != path {
t.Errorf("Path() = %q, want %q", sm.Path(), path)
}
}
func TestReloadReplacesCalibrationAndKeepsLiveSources(t *testing.T) {
h, sm, path := newTestManager(t)
// A live source that the file does not mention must survive the reload.
sm.mu.Lock()
sm.sources["s1"] = &managedSource{id: "s1", label: "live", addr: "127.0.0.1:44999"}
sm.mu.Unlock()
// A stale calibration that the file does not mention must be dropped.
h.cal.Set(CalConfig{Source: "stale", Signal: "Old", Scale: 9})
if err := os.WriteFile(path, []byte(`[
{"source":"wave","signal":"Adc","scale":0.5}
]`), 0o644); err != nil {
t.Fatal(err)
}
if err := sm.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
got := h.cal.List()
if len(got) != 1 || got[0].Source != "wave" {
t.Fatalf("after Reload, calibration = %+v, want only wave/Adc", got)
}
sm.mu.RLock()
_, alive := sm.sources["s1"]
n := len(sm.sources)
sm.mu.RUnlock()
if !alive || n != 1 {
t.Errorf("live source count = %d (s1 alive=%v), want 1 / true", n, alive)
}
}
func TestReloadWithoutFilePathFails(t *testing.T) {
h := NewHub()
sm := NewSourceManager(h, "")
h.SetSourceManager(sm)
if err := sm.Reload(); err == nil {
t.Error("Reload() with no path = nil error, want error")
}
}
+3 -2
View File
@@ -32,8 +32,9 @@ type SourceStat struct {
} }
// RecordFragment is called for every UDP datagram of a DATA packet. // RecordFragment is called for every UDP datagram of a DATA packet.
// complete: this fragment completed the DATA reassembly. //
// nBytes: raw datagram size (header+payload). // complete: this fragment completed the DATA reassembly.
// nBytes: raw datagram size (header+payload).
func (s *SourceStat) RecordFragment(counter uint32, nBytes int, arrivalNs int64, complete bool) { func (s *SourceStat) RecordFragment(counter uint32, nBytes int, arrivalNs int64, complete bool) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
+355 -9
View File
@@ -3,7 +3,9 @@ package wshub
import ( import (
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"log"
"math" "math"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -25,10 +27,30 @@ const (
// capture is extracted, so the rings have received the last samples. // capture is extracted, so the rings have received the last samples.
const captureMarginSec = 0.15 const captureMarginSec = 0.15
// captureStallSec is how long the stream may be silent before a collecting
// trigger gives up waiting for the rest of its window and delivers what it has.
const captureStallSec = 2.0
// autoRearmDelaySec is the pause between a completed capture and the automatic // autoRearmDelaySec is the pause between a completed capture and the automatic
// rearm in "normal" mode. // rearm in "normal" mode.
const autoRearmDelaySec = 0.2 const autoRearmDelaySec = 0.2
// trigCapturePts caps the points sent per signal in a capture frame. A window
// of 60 s at 1 MSps is 60 M raw samples — ~960 MB per signal on the wire, which
// no client can take and which the send path would simply drop. Matches the C++
// StreamHub's kTrigCapturePts.
const trigCapturePts = 20000
// shortCaptureTol is the fraction of the window a capture may miss at its front
// before it is reported. One min/max bucket of slack, not a quality target.
const shortCaptureTol = 0.01
// maxTriggerWindowSec bounds the capture window, matching the longest option
// the web UI offers. It is not a resolution limit: retuneRings buckets the
// rings so any window fits the per-signal memory budget, at the cost of storing
// min/max pairs rather than every sample.
const maxTriggerWindowSec = 600.0
// trigConfig is the client-settable part of the trigger. // trigConfig is the client-settable part of the trigger.
type trigConfig struct { type trigConfig struct {
signalKey string // "src:sig" or "src:sig[i]" signalKey string // "src:sig" or "src:sig[i]"
@@ -36,7 +58,8 @@ type trigConfig struct {
threshold float64 threshold float64
windowSec float64 windowSec float64
prePercent float64 prePercent float64
mode string // "normal" | "single" mode string // "normal" | "single"
holdoffSec float64 // rearm delay after a capture (double-trigger guard)
} }
// triggerEngine implements the hub-side trigger FSM. Its methods are safe to // triggerEngine implements the hub-side trigger FSM. Its methods are safe to
@@ -51,11 +74,34 @@ type triggerEngine struct {
state string state string
stopped bool stopped bool
// sentState is the state carried by the last stateMsg handed out. The
// armed→collecting transition happens inside feed(), on the ingest path,
// so the hub cannot see it by sampling State() across a tick — by the time
// the tick runs, ingest has already moved the FSM.
sentState string
// sentFill is the pre-fill fraction carried by the last stateMsg, so a
// trigger that is armed but still filling can report progress.
sentFill float64
// How far back the trigger signal's ring reaches and how fast that is
// growing (seconds of span per second of wall clock), refreshed by the hub.
// bufKnown is false when there is no ring to measure, which disables the
// fill gate rather than blocking the trigger on a measurement that will
// never arrive; bufRateOK is false until two measurements exist.
bufSpan float64
bufGrowth float64
bufKnown bool
bufRateOK bool
// Reference point the growth is measured against.
bufRefSpan, bufRefWall float64
prevValue float64 prevValue float64
prevValid bool prevValid bool
lastT float64 lastT float64
lastTOK bool lastTOK bool
// lastFeedWall is the wall clock at the last feed(), used only to notice a
// stalled stream — the window itself is measured on the sample clock.
lastFeedWall float64
trigTime float64 trigTime float64
firedPre float64 firedPre float64
@@ -67,7 +113,7 @@ type triggerEngine struct {
func newTriggerEngine() *triggerEngine { func newTriggerEngine() *triggerEngine {
return &triggerEngine{ return &triggerEngine{
cfg: trigConfig{edge: "rising", windowSec: 1, prePercent: 20, mode: "normal"}, cfg: trigConfig{edge: "rising", windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: autoRearmDelaySec},
elemIdx: -1, elemIdx: -1,
state: trigIdle, state: trigIdle,
} }
@@ -97,8 +143,8 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
if cfg.windowSec < 1e-4 { if cfg.windowSec < 1e-4 {
cfg.windowSec = 1e-4 cfg.windowSec = 1e-4
} }
if cfg.windowSec > 10 { if cfg.windowSec > maxTriggerWindowSec {
cfg.windowSec = 10 cfg.windowSec = maxTriggerWindowSec
} }
if cfg.prePercent < 0 { if cfg.prePercent < 0 {
cfg.prePercent = 0 cfg.prePercent = 0
@@ -106,8 +152,19 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
if cfg.prePercent > 100 { if cfg.prePercent > 100 {
cfg.prePercent = 100 cfg.prePercent = 100
} }
if cfg.holdoffSec < 0 {
cfg.holdoffSec = 0
}
if cfg.holdoffSec > 60 {
cfg.holdoffSec = 60
}
te.cfg = cfg te.cfg = cfg
te.baseKey, te.elemIdx = parseSignalKey(cfg.signalKey) base, idx := parseSignalKey(cfg.signalKey)
if base != te.baseKey {
// The buffer measurement belongs to the old signal's ring.
te.bufKnown, te.bufRateOK = false, false
}
te.baseKey, te.elemIdx = base, idx
te.prevValid = false te.prevValid = false
te.prevValue = 0 te.prevValue = 0
} }
@@ -168,6 +225,105 @@ func (te *triggerEngine) Active() bool {
return te.baseKey != "" return te.baseKey != ""
} }
// baseSignalKey is the configured trigger signal without its "[i]" suffix, or
// "" when no trigger signal is set.
func (te *triggerEngine) baseSignalKey() string {
te.mu.Lock()
defer te.mu.Unlock()
return te.baseKey
}
// bufGrowthIntervalSec is the shortest baseline the span growth is measured
// over. The hub refreshes 30 times a second and the span moves in steps as
// batches land, so a shorter baseline measures the batching, not the trend.
const bufGrowthIntervalSec = 0.5
// bufGrowthSmooth is the weight of a new growth measurement in the running
// estimate.
const bufGrowthSmooth = 0.5
// setBuffered records how far back the trigger signal's ring reaches, at wall
// clock now, and derives how fast that is growing. Pass known=false when there
// is no such ring.
func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
te.mu.Lock()
defer te.mu.Unlock()
if !known {
te.bufKnown, te.bufRateOK = false, false
return
}
if !te.bufKnown {
te.bufKnown = true
te.bufRefSpan, te.bufRefWall = span, now
}
te.bufSpan = span
dt := now - te.bufRefWall
if dt < bufGrowthIntervalSec {
return
}
g := (span - te.bufRefSpan) / dt
// A ring that is not full grows one second of span per second; one that is
// full grows by whatever its incoming samples free up. Neither can exceed 1,
// and a shrinking ring is simply not growing.
if g < 0 {
g = 0
} else if g > 1 {
g = 1
}
if te.bufRateOK {
g = te.bufGrowth + bufGrowthSmooth*(g-te.bufGrowth)
}
te.bufGrowth, te.bufRateOK = g, true
te.bufRefSpan, te.bufRefWall = span, now
}
// fillNeedLocked is how far back the buffer must reach before an edge may be
// accepted, so that the capture is still whole when it is harvested a
// post-window later.
//
// What has to hold at harvest time is that the buffer spans the whole window:
// its newest sample is then trigTime+post, so anything less has lost the front
// of the capture. The buffer keeps filling while the post-window is collected,
// though, so the shortfall it may start with is exactly what it will make up in
// that time — measured, not assumed:
//
// need = windowSec growth × postSec, floored at the pre-trigger window
//
// A ring that is still filling grows a second per second, which reduces this to
// the pre-trigger window: everything after the trigger is yet to be recorded
// anyway. A full one grows only as fast as its incoming samples free space —
// re-bucketing to a longer window replaces dense old samples with sparse new
// ones — and it is that case, growth well below 1, where firing on the
// pre-window alone delivers a capture whose front has been overwritten by the
// time it is read. In the steady state growth is 0 and need is the whole
// window, which a ring tuned for that window already exceeds, so nothing waits.
func (te *triggerEngine) fillNeedLocked() float64 {
pre := te.cfg.windowSec * te.cfg.prePercent / 100
growth := 0.0 // until measured, assume the buffer will not fill on its own
if te.bufRateOK {
growth = te.bufGrowth
}
need := te.cfg.windowSec - growth*(te.cfg.windowSec-pre)
if need < pre {
need = pre
}
return need
}
// fillLocked is how much of that requirement is met, as a fraction in [0, 1].
// It is 1 whenever the gate does not apply: nothing needed, or no ring to
// measure.
func (te *triggerEngine) fillLocked() float64 {
need := te.fillNeedLocked()
if need <= 0 || !te.bufKnown || te.bufSpan >= need*(1-shortCaptureTol) {
return 1
}
if te.bufSpan <= 0 {
return 0
}
return te.bufSpan / need
}
// latchWindowLocked freezes the pre/post split at fire time so later config // latchWindowLocked freezes the pre/post split at fire time so later config
// edits do not change how the capture is rendered. // edits do not change how the capture is rendered.
func (te *triggerEngine) latchWindowLocked(t float64) { func (te *triggerEngine) latchWindowLocked(t float64) {
@@ -209,6 +365,7 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
} }
te.lastT = t[len(t)-1] te.lastT = t[len(t)-1]
te.lastTOK = true te.lastTOK = true
te.lastFeedWall = float64(time.Now().UnixNano()) / 1e9
if te.state != trigArmed { if te.state != trigArmed {
return return
} }
@@ -219,6 +376,17 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
} }
step, start = nElem, te.elemIdx step, start = nElem, te.elemIdx
} }
// Hold off while the buffer does not reach back far enough. Firing now would
// deliver a capture whose front is simply missing — the ring never held it —
// which is what made the first shot after a window change come back short.
// Track the level meanwhile, so the first edge once the buffer is deep
// enough is still measured against the right previous sample.
if te.fillLocked() < 1 {
for i := start; i < len(v); i += step {
te.prevValue, te.prevValid = v[i], true
}
return
}
thr := te.cfg.threshold thr := te.cfg.threshold
for i := start; i < len(t); i += step { for i := start; i < len(t); i += step {
if !te.prevValid { if !te.prevValid {
@@ -247,13 +415,28 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
// dueCapture reports whether a collecting trigger's post-window has elapsed and // dueCapture reports whether a collecting trigger's post-window has elapsed and
// returns the latched window. // returns the latched window.
//
// The window is measured on the sample clock, not the wall clock: trigTime is a
// sample timestamp, and a stream whose timestamps lag real time (a busy
// producer, a buffered link) would otherwise be cut short by exactly that lag —
// an 8 s lag turned a 60 s window into a 36 s capture. Waiting for the samples
// themselves also means the ring really holds the window by the time it is read.
func (te *triggerEngine) dueCapture(nowSec float64) (trigTime, pre, post float64, ok bool) { func (te *triggerEngine) dueCapture(nowSec float64) (trigTime, pre, post float64, ok bool) {
te.mu.Lock() te.mu.Lock()
defer te.mu.Unlock() defer te.mu.Unlock()
if te.state != trigCollecting || !te.firedValid { if te.state != trigCollecting || !te.firedValid {
return 0, 0, 0, false return 0, 0, 0, false
} }
if nowSec < te.trigTime+te.firedPost+captureMarginSec { deadline := te.trigTime + te.firedPost + captureMarginSec
switch {
case te.lastTOK && te.lastT >= deadline:
// The samples have covered the window.
case !te.lastTOK && nowSec >= deadline:
// No sample ever seen, so trigTime came from the wall clock (Force).
case te.lastFeedWall > 0 && nowSec-te.lastFeedWall >= captureStallSec:
// The stream has dried up; deliver what was collected rather than
// leaving the client stuck in "collecting" forever.
default:
return 0, 0, 0, false return 0, 0, 0, false
} }
return te.trigTime, te.firedPre, te.firedPost, true return te.trigTime, te.firedPre, te.firedPost, true
@@ -266,7 +449,7 @@ func (te *triggerEngine) markTriggered(nowSec float64) {
if te.state == trigCollecting { if te.state == trigCollecting {
te.state = trigTriggered te.state = trigTriggered
if te.cfg.mode != "single" && !te.stopped { if te.cfg.mode != "single" && !te.stopped {
te.rearmAt = nowSec + autoRearmDelaySec te.rearmAt = nowSec + te.cfg.holdoffSec
} }
} }
te.mu.Unlock() te.mu.Unlock()
@@ -283,17 +466,49 @@ func (te *triggerEngine) dueRearm(nowSec float64) bool {
return !te.stopped return !te.stopped
} }
// stateUnsent reports whether the FSM has moved since the last stateMsg was
// built, i.e. whether clients still have to be told.
func (te *triggerEngine) stateUnsent() bool {
te.mu.Lock()
defer te.mu.Unlock()
if te.state != te.sentState {
return true
}
// An armed trigger waiting for its buffer is otherwise indistinguishable
// from one that is ignoring edges, so the filling itself is news. Coarse
// steps only: this is checked 30 times a second.
if te.state == trigArmed {
f := te.fillLocked()
return math.Abs(f-te.sentFill) >= 0.02 || (f >= 1 && te.sentFill < 1)
}
return false
}
// stateMsg builds the JSON "triggerState" broadcast for the current FSM state. // stateMsg builds the JSON "triggerState" broadcast for the current FSM state.
func (te *triggerEngine) stateMsg() []byte { func (te *triggerEngine) stateMsg() []byte {
te.mu.Lock() te.mu.Lock()
te.sentState = te.state
te.sentFill = te.fillLocked()
m := map[string]any{ m := map[string]any{
"type": "triggerState", "type": "triggerState",
"state": te.state, "state": te.state,
"mode": te.cfg.mode, "mode": te.cfg.mode,
"stopped": te.stopped, "stopped": te.stopped,
} }
if te.state == trigArmed && te.sentFill < 1 {
// Armed but holding off: the buffer does not yet reach back far enough
// to deliver the window, so edges are being ignored on purpose.
m["bufferFill"] = te.sentFill
m["bufferNeedSec"] = te.fillNeedLocked()
}
if te.firedValid { if te.firedValid {
// The window latched at fire time. Clients draw the filling capture on
// this axis before the v2 frame arrives, and config edits between arm
// and fire would otherwise leave them inferring the wrong window from
// their own copy of the config.
m["trigTime"] = te.trigTime m["trigTime"] = te.trigTime
m["preSec"] = te.firedPre
m["postSec"] = te.firedPost
} }
te.mu.Unlock() te.mu.Unlock()
msg, _ := json.Marshal(m) msg, _ := json.Marshal(m)
@@ -331,6 +546,9 @@ func (h *Hub) handleTriggerCommand(t string, env map[string]interface{}) bool {
if f, ok := env["prePercent"].(float64); ok { if f, ok := env["prePercent"].(float64); ok {
cfg.prePercent = f cfg.prePercent = f
} }
if f, ok := env["holdoffSec"].(float64); ok {
cfg.holdoffSec = f
}
h.trigger.SetConfig(cfg) h.trigger.SetConfig(cfg)
case "arm", "rearm": case "arm", "rearm":
h.trigger.Arm() h.trigger.Arm()
@@ -347,34 +565,139 @@ func (h *Hub) handleTriggerCommand(t string, env map[string]interface{}) bool {
default: default:
return false return false
} }
// Measure the buffer now rather than waiting for the next tick: ingest runs
// on the source goroutine and a 1 MSps stream crosses the threshold many
// times within one 33 ms tick, so an arm serviced here would otherwise fire
// on a stale (or missing) measurement before the gate ever saw the new
// configuration.
h.refreshTriggerFill()
h.broadcastTriggerState() h.broadcastTriggerState()
return true return true
} }
// refreshTriggerFill tells the FSM how far back the trigger signal's ring
// reaches, which is what lets an armed trigger hold off until a capture taken
// now would come back whole.
//
// The ring is the right yardstick even though a short capture is back-filled
// from the archive: the archive is sized for the same window and starts over
// whenever that window changes, so it holds no more of the stretch being waited
// for than the ring does. It can only add to what the capture finds.
//
// Called both from the push tick and from the client goroutine handling a
// trigger command; all the state it derives lives in the engine, behind the
// engine's lock.
func (h *Hub) refreshTriggerFill() {
if h.trigger == nil {
return
}
now := float64(time.Now().UnixNano()) / 1e9
var rb *sigRing
if key := h.trigger.baseSignalKey(); key != "" {
rb = h.getRing(key)
}
if rb == nil {
// Nothing to measure. Do not gate on a signal the hub does not carry:
// that would leave the trigger armed forever, which is worse than a
// short capture.
h.trigger.setBuffered(0, false, now)
return
}
_, span := rb.stats()
h.trigger.setBuffered(span, true, now)
}
// triggerTick services the trigger FSM; called from Hub.Run() on every push tick. // triggerTick services the trigger FSM; called from Hub.Run() on every push tick.
func (h *Hub) triggerTick() { func (h *Hub) triggerTick() {
nowSec := float64(time.Now().UnixNano()) / 1e9 nowSec := float64(time.Now().UnixNano()) / 1e9
prev := h.trigger.State()
h.retuneRings(nowSec)
h.openPendingHistoryFiles(nowSec)
h.refreshTriggerFill()
if trigTime, pre, post, ok := h.trigger.dueCapture(nowSec); ok { if trigTime, pre, post, ok := h.trigger.dueCapture(nowSec); ok {
if msg := h.buildTriggerCapture(trigTime, pre, post); msg != nil { if msg := h.buildTriggerCapture(trigTime, pre, post); msg != nil {
dropped := 0
for c := range h.clients { for c := range h.clients {
select { select {
case c.send <- wsMessage{websocket.BinaryMessage, msg}: case c.send <- wsMessage{websocket.BinaryMessage, msg}:
default: default:
dropped++
} }
} }
// A dropped capture is invisible to the user — the trigger fires,
// the state goes to "triggered" and no waveform ever arrives — so
// say so rather than leaving it to be guessed at.
if dropped > 0 {
log.Printf("wshub: trigger capture (%d B) dropped for %d client(s): send queue full",
len(msg), dropped)
}
} }
h.trigger.markTriggered(nowSec) h.trigger.markTriggered(nowSec)
// A capture is only zoomable for as long as its samples still exist at
// full resolution somewhere, and the rings roll past the window within
// seconds of it being taken. Lift the window out of the archive into a
// file of its own, where nothing overwrites it until the next trigger.
h.hist.captureRange(trigTime-pre, trigTime+post)
} else if h.trigger.dueRearm(nowSec) { } else if h.trigger.dueRearm(nowSec) {
h.trigger.Arm() h.trigger.Arm()
} }
if h.trigger.State() != prev { if h.trigger.stateUnsent() {
h.broadcastTriggerState() h.broadcastTriggerState()
} }
} }
// backfillCaptureHead prepends the front of [t0, t1] that the ring no longer
// holds, read from the disk archive. It returns its input unchanged when the
// ring already reaches t0, when history is off, or when the archive has nothing
// for that range.
//
// The rings are sized for the window, but they only have to *become* that long:
// they are min/max buckets that cover the configured window once they have
// rolled over completely at the current bucket, which takes as long as the
// window itself. Widen the window and arm, and the first captures ask for more
// history than the ring has ever stored — the frame then starts late and the
// user sees a blank front half. The archive is written straight through, at the
// geometry its file was created with, so unless that file was re-sized too it
// has kept the stretch the ring is still converging on.
func (h *Hub) backfillCaptureHead(key string, t0, t1 float64, st, sv []float64) ([]float64, []float64) {
window := t1 - t0
if !h.hist.enabled() || window <= 0 {
return st, sv
}
gapEnd := t1
if len(st) > 0 {
gapEnd = st[0]
}
gap := gapEnd - t0
if gap <= shortCaptureTol*window {
return st, sv
}
// Budget the read by the share of the window being back-filled. The frame is
// decimated to trigCapturePts either way, so a bigger read would buy nothing
// but disk seeks — on the hub's own goroutine, between two push ticks.
maxOut := int(float64(trigCapturePts)*gap/window) + 2
ht, hv := h.hist.readRange(key, t0, gapEnd, maxOut)
if len(ht) == 0 {
return st, sv
}
// Drop anything at or past the ring's first sample: the two sources overlap
// around the join, and the frame's timestamps must stay ascending.
n := len(ht)
if len(st) > 0 {
n = sort.SearchFloat64s(ht, st[0])
}
if n == 0 {
return st, sv
}
outT := make([]float64, 0, n+len(st))
outV := make([]float64, 0, n+len(sv))
outT = append(append(outT, ht[:n]...), st...)
outV = append(append(outV, hv[:n]...), sv...)
return outT, outV
}
// buildTriggerCapture extracts [trigTime-pre, trigTime+post] from every ring // buildTriggerCapture extracts [trigTime-pre, trigTime+post] from every ring
// buffer and encodes the version-2 binary capture frame: // buffer and encodes the version-2 binary capture frame:
// //
@@ -397,18 +720,41 @@ func (h *Hub) buildTriggerCapture(trigTime, pre, post float64) []byte {
h.ringsMu.RUnlock() h.ringsMu.RUnlock()
slices := make([]sigSlice, 0, len(keys)) slices := make([]sigSlice, 0, len(keys))
held := make(map[string]sigData, len(keys))
total := 1 + 8 + 8 + 8 + 4 total := 1 + 8 + 8 + 8 + 4
for i, k := range keys { for i, k := range keys {
st, sv := rings[i].slice(t0, t1) st, sv := rings[i].slice(t0, t1)
st, sv = h.backfillCaptureHead(k, t0, t1, st, sv)
if len(st) == 0 { if len(st) == 0 {
continue continue
} }
// Neither the ring nor the archive reached t0. Nothing can recover that
// data, so name it rather than leaving the user to wonder why the front
// of their window is blank.
if lost := st[0] - t0; lost > shortCaptureTol*(t1-t0) {
cnt, span := rings[i].stats()
log.Printf("wshub: capture %s is short by %.2f s of %.2f s: ring holds %.2f s (%d pts, min/max over %d)",
k, lost, t1-t0, span, cnt, rings[i].bucketSize())
}
// Take the second half of the double buffer here, before the frame is
// decimated: the client gets 20 000 points to draw, but a zoom into
// them has to come back with the underlying samples, and the rings will
// have rolled past them by the time it is asked for.
held[k] = sigData{T: st, V: sv}
// Decimate before framing: a long window at a high sample rate is
// hundreds of megabytes raw, which the send path would silently drop.
// The min/max envelope keeps every peak in the window, so a glitch is
// still on screen at the zoomed-out view that first shows it.
st, sv = minMaxDecimate(st, sv, trigCapturePts)
slices = append(slices, sigSlice{key: k, t: st, v: sv}) slices = append(slices, sigSlice{key: k, t: st, v: sv})
total += 2 + len(k) + 4 + len(st)*16 total += 2 + len(k) + 4 + len(st)*16
} }
if len(slices) == 0 { if len(slices) == 0 {
return nil return nil
} }
// Swap only now that the capture is known good. A shot that yielded nothing
// must leave the previous window on screen rather than blanking it.
h.capture.publish(t0, t1, held)
buf := make([]byte, total) buf := make([]byte, total)
buf[0] = 2 buf[0] = 2
@@ -0,0 +1,278 @@
package wshub
import (
"encoding/binary"
"math"
"testing"
)
// fillRing writes n samples at the given rate starting at t0.
func fillRing(rb *sigRing, t0 float64, rate float64, n int) {
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = t0 + float64(i)/rate
vs[i] = math.Sin(float64(i))
}
rb.write(ts, vs)
}
func TestRingGrowPreservesSamples(t *testing.T) {
rb := newSigRing(100)
// Overflow the ring so the retained window starts mid-buffer.
fillRing(rb, 0, 1000, 250)
beforeT, beforeV := rb.slice(-1e9, 1e9)
if len(beforeT) != 100 {
t.Fatalf("pre-grow fill = %d, want 100", len(beforeT))
}
if !rb.grow(1000) {
t.Fatal("grow(1000) returned false")
}
if rb.capacity() != 1000 {
t.Fatalf("capacity = %d, want 1000", rb.capacity())
}
afterT, afterV := rb.slice(-1e9, 1e9)
if len(afterT) != len(beforeT) {
t.Fatalf("post-grow fill = %d, want %d", len(afterT), len(beforeT))
}
for i := range beforeT {
if afterT[i] != beforeT[i] || afterV[i] != beforeV[i] {
t.Fatalf("sample %d changed across grow", i)
}
}
// Further writes must keep landing in order rather than wrapping early.
fillRing(rb, 1.0, 1000, 500)
if n, _ := rb.stats(); n != 600 {
t.Fatalf("fill after grow = %d, want 600", n)
}
// Shrinking is refused.
if rb.grow(10) {
t.Fatal("grow(10) shrank the ring")
}
}
func TestRingStatsMeasuresRate(t *testing.T) {
rb := newSigRing(10000)
fillRing(rb, 0, 1000, 1000) // 1 kHz
n, span := rb.stats()
if n != 1000 {
t.Fatalf("count = %d, want 1000", n)
}
rate := float64(n) / span
if math.Abs(rate-1001) > 5 { // n samples span (n-1) intervals
t.Fatalf("rate = %v, want ~1000", rate)
}
}
// A long trigger window must grow the rings to hold it: a fixed sample-count
// ring covers a fraction of a second at a high rate, which is what made 60 s
// captures come back with only their tail populated.
func TestRetuneRingsCoversTriggerWindow(t *testing.T) {
h := NewHub()
rb := newSigRing(6000) // 6 s at 1 kHz — far short of a 60 s window
fillRing(rb, 0, 1000, 6000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising",
windowSec: 60, prePercent: 20, mode: "normal"})
h.retuneRings(1000)
// 60 s at 1 kHz is 60 k samples: growing to the budget holds them verbatim.
if got := rb.capacity(); got < 60000 {
t.Fatalf("capacity = %d, want >= 60000 to hold a 60 s window", got)
}
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1: the window fits at full rate", got)
}
}
// Past the budget the window is kept by reducing resolution, not by dropping
// its head — the whole point of the min/max buckets.
func TestRetuneRingsBucketsWhenTheWindowExceedsTheBudget(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 60, mode: "normal"})
h.retuneRings(1000)
if got := rb.capacity(); got != defaultRingPts {
t.Fatalf("capacity = %d, want the budget %d", got, defaultRingPts)
}
// 60 s at 1 MSps is 60 M samples in a 10 M-point buffer, so each stored
// pair must cover at least 12 source samples.
bucket := rb.bucketSize()
if bucket < 12 {
t.Fatalf("bucket = %d, too fine to fit 60 M samples in %d points", bucket, rb.capacity())
}
if covered := float64(rb.capacity()) / 2 * float64(bucket) / 1e6; covered < 60 {
t.Fatalf("buffer covers %.1f s, want the whole 60 s window", covered)
}
}
// A raised budget buys resolution back: the same window is held verbatim.
func TestRetuneRingsHonoursRaisedBudget(t *testing.T) {
h := NewHub()
h.SetRingBudget(80_000_000)
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 60, mode: "normal"})
h.retuneRings(1000)
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1: 60 M samples fit in an 80 M-point buffer", got)
}
}
func TestSetRingBudgetBounds(t *testing.T) {
h := NewHub()
h.SetRingBudget(0)
if got := h.ringBudget(); got != defaultRingPts {
t.Fatalf("ringBudget after 0 = %d, want the default %d", got, defaultRingPts)
}
// Never below the depth a freshly configured ring already has, or the
// budget would ask for a shrink the ring refuses anyway.
h.SetRingBudget(10)
if got := h.ringBudget(); got != ringCapInitial {
t.Fatalf("ringBudget after 10 = %d, want the floor %d", got, ringCapInitial)
}
}
func TestRetuneRingsIsThrottled(t *testing.T) {
h := NewHub()
h.SetRingBudget(250_000)
rb := newSigRing(250_000)
fillRing(rb, 0, 1e6, 100_000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 10, mode: "normal"})
h.retuneRings(100)
first := rb.bucketSize()
if first <= 1 {
t.Fatalf("bucket = %d, expected a reduction for 10 s at 1 MSps in 250 k points", first)
}
// Same second: the sweep must not run again even though a bigger window
// is now configured.
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 600, mode: "normal"})
h.retuneRings(100.5)
if rb.bucketSize() != first {
t.Fatalf("sweep ran inside the throttle window")
}
h.retuneRings(200)
if rb.bucketSize() <= first {
t.Fatalf("sweep did not run after the throttle window elapsed")
}
}
// With no trigger armed and no client saying otherwise, the rings are sized for
// the default live window — live mode needs the buffers just as much as a
// capture does.
func TestRetuneRingsSizesForTheLiveWindow(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps: 10 s does not fit in 1000 points
h.rings["s1:sig"] = rb
// No signal configured → trigger inactive, so the live window governs.
h.trigger.SetConfig(trigConfig{windowSec: 600, mode: "normal"})
h.retuneRings(100)
if got := rb.capacity(); got != defaultRingPts {
t.Fatalf("capacity = %d, want the budget %d", got, defaultRingPts)
}
// defaultLiveWindowSec at 1 MSps is exactly the budget, so no reduction.
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1 for the default live window", got)
}
}
func TestRingBucketForCoversTheWindow(t *testing.T) {
cases := []struct {
rate, window float64
capacity int
want int
}{
{1000, 10, 1_000_000, 1}, // 10 k samples in 1 M points: verbatim
{1e6, 10, 10_000_000, 1}, // exactly the budget: still verbatim
{1e6, 60, 10_000_000, 15}, // 60 M samples, 1.25x headroom
{1e6, 600, 10_000_000, 150}, // 600 s still fits, at 1/150 resolution
{0, 10, 1_000_000, 1}, // no rate measured yet
{1000, 0, 1_000_000, 1}, // no window
}
for _, c := range cases {
if got := ringBucketFor(c.rate, c.window, c.capacity); got != c.want {
t.Errorf("ringBucketFor(%v, %v, %d) = %d, want %d",
c.rate, c.window, c.capacity, got, c.want)
}
}
}
// decodeCapture pulls the per-signal point counts out of a v2 capture frame.
func decodeCapture(t *testing.T, buf []byte) map[string]int {
t.Helper()
if buf[0] != 2 {
t.Fatalf("frame version = %d, want 2", buf[0])
}
off := 1 + 8 + 8 + 8
nSig := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
out := make(map[string]int, nSig)
for i := 0; i < nSig; i++ {
kl := int(binary.LittleEndian.Uint16(buf[off:]))
off += 2
key := string(buf[off : off+kl])
off += kl
n := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
off += n * 16
out[key] = n
}
if off != len(buf) {
t.Fatalf("decoded %d of %d bytes", off, len(buf))
}
return out
}
// A 60 s window at a high rate is hundreds of megabytes raw; the capture frame
// must be decimated so it can actually reach a client.
func TestBuildTriggerCaptureDecimates(t *testing.T) {
h := NewHub()
rb := newSigRing(200000)
fillRing(rb, 0, 100000, 200000) // 2 s at 100 kSps
h.rings["s1:sig"] = rb
buf := h.buildTriggerCapture(1.0, 1.0, 1.0)
if buf == nil {
t.Fatal("no capture frame built")
}
counts := decodeCapture(t, buf)
n := counts["s1:sig"]
if n != trigCapturePts {
t.Fatalf("captured %d points, want the %d-point cap", n, trigCapturePts)
}
}
// Short captures must stay full resolution — decimation only kicks in above
// the cap.
func TestBuildTriggerCaptureKeepsSmallWindowsIntact(t *testing.T) {
h := NewHub()
rb := newSigRing(10000)
fillRing(rb, 0, 1000, 10000) // 10 s at 1 kHz
h.rings["s1:sig"] = rb
buf := h.buildTriggerCapture(1.0, 0.5, 0.5)
if buf == nil {
t.Fatal("no capture frame built")
}
counts := decodeCapture(t, buf)
if n := counts["s1:sig"]; n < 990 || n > 1010 {
t.Fatalf("captured %d points, want ~1000 undecimated", n)
}
}
+324 -11
View File
@@ -1,6 +1,11 @@
package wshub package wshub
import "testing" import (
"encoding/json"
"math"
"testing"
"time"
)
func TestParseSignalKey(t *testing.T) { func TestParseSignalKey(t *testing.T) {
cases := []struct { cases := []struct {
@@ -26,7 +31,7 @@ func TestParseSignalKey(t *testing.T) {
func armed(key, edge string, thr float64) *triggerEngine { func armed(key, edge string, thr float64) *triggerEngine {
te := newTriggerEngine() te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: key, edge: edge, threshold: thr, te.SetConfig(trigConfig{signalKey: key, edge: edge, threshold: thr,
windowSec: 1, prePercent: 20, mode: "normal"}) windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: autoRearmDelaySec})
te.Arm() te.Arm()
return te return te
} }
@@ -96,6 +101,8 @@ func TestForceUsesLastSampleTime(t *testing.T) {
t.Fatalf("state = %q, want armed (threshold unreachable)", te.State()) t.Fatalf("state = %q, want armed (threshold unreachable)", te.State())
} }
te.Force() te.Force()
// post = 1 s, so the capture waits for samples past t = 12 + 1 + 0.15.
te.feed("src:sig", 1, []float64{13.2}, []float64{0})
trigTime, pre, post, ok := te.dueCapture(1e9) trigTime, pre, post, ok := te.dueCapture(1e9)
if !ok || trigTime != 12 || pre != 1 || post != 1 { if !ok || trigTime != 12 || pre != 1 || post != 1 {
t.Fatalf("dueCapture = (%v,%v,%v,%v), want (12,1,1,true)", t.Fatalf("dueCapture = (%v,%v,%v,%v), want (12,1,1,true)",
@@ -116,15 +123,50 @@ func TestForceFromIdle(t *testing.T) {
func TestCaptureMarginDelaysExtraction(t *testing.T) { func TestCaptureMarginDelaysExtraction(t *testing.T) {
te := armed("src:sig", "rising", 0.5) te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1 te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1
// post = 0.8 s; capture is due at 1 + 0.8 + 0.15. // post = 0.8 s; capture is due once the samples reach 1 + 0.8 + 0.15.
if _, _, _, ok := te.dueCapture(1.9); ok { te.feed("src:sig", 1, []float64{1.9}, []float64{0})
if _, _, _, ok := te.dueCapture(1e9); ok {
t.Error("capture extracted before the margin elapsed") t.Error("capture extracted before the margin elapsed")
} }
if _, _, _, ok := te.dueCapture(1.96); !ok { te.feed("src:sig", 1, []float64{1.96}, []float64{0})
if _, _, _, ok := te.dueCapture(1e9); !ok {
t.Error("capture not extracted after the margin elapsed") t.Error("capture not extracted after the margin elapsed")
} }
} }
// A stream whose timestamps run behind real time must still yield the whole
// window: measuring the post-window on the wall clock cut the capture short by
// exactly the lag (an 8 s lag turned a 60 s window into a 36 s one).
func TestCaptureWaitsForLaggingStream(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1
wallNow := float64(time.Now().UnixNano()) / 1e9
// Wall clock is far past the post-window, but the samples are not.
te.feed("src:sig", 1, []float64{1.5}, []float64{0})
if _, _, _, ok := te.dueCapture(wallNow); ok {
t.Error("capture extracted while the stream was still short of the window")
}
te.feed("src:sig", 1, []float64{2.0}, []float64{0})
if _, _, _, ok := te.dueCapture(wallNow); !ok {
t.Error("capture not extracted once the samples covered the window")
}
}
// A dead stream must not leave the client stuck in "collecting" forever.
func TestCaptureCompletesWhenStreamStalls(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1
wallNow := float64(time.Now().UnixNano()) / 1e9
if _, _, _, ok := te.dueCapture(wallNow + captureStallSec/2); ok {
t.Error("capture extracted before the stall timeout")
}
if _, _, _, ok := te.dueCapture(wallNow + captureStallSec + 0.1); !ok {
t.Error("capture not extracted after the stream stalled")
}
}
func TestAutoRearmNormalMode(t *testing.T) { func TestAutoRearmNormalMode(t *testing.T) {
te := armed("src:sig", "rising", 0.5) te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
@@ -167,13 +209,34 @@ func TestStoppedSuppressesRearm(t *testing.T) {
func TestSetConfigClamps(t *testing.T) { func TestSetConfigClamps(t *testing.T) {
te := newTriggerEngine() te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 100, prePercent: 500}) te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 1000, prePercent: 500, holdoffSec: 120})
if cfg := te.Config(); cfg.windowSec != 10 || cfg.prePercent != 100 { if cfg := te.Config(); cfg.windowSec != 600 || cfg.prePercent != 100 || cfg.holdoffSec != 60 {
t.Errorf("upper clamp = %v/%v, want 10/100", cfg.windowSec, cfg.prePercent) t.Errorf("upper clamp = %v/%v/%v, want 600/100/60", cfg.windowSec, cfg.prePercent, cfg.holdoffSec)
} }
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 0, prePercent: -5}) // The web UI's longest option must survive intact — it used to be clamped
if cfg := te.Config(); cfg.windowSec != 1e-4 || cfg.prePercent != 0 { // to 60 s, so a 10 min capture silently came back one minute long.
t.Errorf("lower clamp = %v/%v, want 1e-4/0", cfg.windowSec, cfg.prePercent) te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 600, prePercent: 20, holdoffSec: 1})
if cfg := te.Config(); cfg.windowSec != 600 {
t.Errorf("windowSec = %v, want the requested 600", cfg.windowSec)
}
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 0, prePercent: -5, holdoffSec: -1})
if cfg := te.Config(); cfg.windowSec != 1e-4 || cfg.prePercent != 0 || cfg.holdoffSec != 0 {
t.Errorf("lower clamp = %v/%v/%v, want 1e-4/0/0", cfg.windowSec, cfg.prePercent, cfg.holdoffSec)
}
}
func TestHoldoffControlsRearmDelay(t *testing.T) {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising", threshold: 0.5,
windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: 5})
te.Arm()
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
te.markTriggered(100)
if te.dueRearm(104.9) {
t.Error("rearmed before the configured holdoff elapsed")
}
if !te.dueRearm(105.1) {
t.Error("did not rearm after the configured holdoff elapsed")
} }
} }
@@ -192,3 +255,253 @@ func TestActiveTracksConfiguredSignal(t *testing.T) {
t.Error("engine must stay active after disarm while a signal is set") t.Error("engine must stay active after disarm while a signal is set")
} }
} }
// The armed→collecting transition happens inside feed(), on the ingest path,
// which the hub runs before triggerTick in the same loop iteration. Clients need
// that state — it carries trigTime and the latched window, without which they
// cannot draw the window filling and sit frozen until the capture arrives.
func TestCollectingIsBroadcast(t *testing.T) {
h := NewHub()
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 10, prePercent: 20, mode: "single"})
h.trigger.Arm()
h.triggerTick()
drainStates(t, h)
// Fire, but stay well inside the post-trigger window: the capture is still
// seconds away and this is exactly when the client has nothing to draw.
h.ingest("s1:sig", 1, []float64{5.0, 5.001}, []float64{-1, 1})
h.triggerTick()
states := drainStates(t, h)
found := false
for _, m := range states {
if m["state"] == trigCollecting {
found = true
if m["trigTime"] != 5.001 {
t.Errorf("collecting broadcast has trigTime %v, want 5.001", m["trigTime"])
}
if m["preSec"] != 2.0 || m["postSec"] != 8.0 {
t.Errorf("collecting broadcast has pre=%v post=%v, want 2 and 8",
m["preSec"], m["postSec"])
}
}
}
if !found {
t.Fatalf("no collecting broadcast after the trigger fired, got %v", states)
}
}
// setFill hands the engine a buffer span and a growth rate, as the hub's
// per-tick measurements would: a reference point and a second one a second
// later. It forgets any earlier measurement first, so the rate is the one
// asked for rather than a blend with it.
func setFill(te *triggerEngine, span, growth, now float64) {
te.setBuffered(0, false, now)
te.setBuffered(span-growth, true, now)
te.setBuffered(span, true, now+1)
}
// What has to hold is that the buffer spans the whole window by the time the
// capture is read, one post-window after the trigger fires — so whatever it
// will fill in on its own during that time need not be there yet.
func TestFillNeed(t *testing.T) {
cases := []struct {
window, prePercent, growth, want float64
}{
{100, 20, 1, 20}, // still filling: only the pre-window has to exist
{100, 20, 0.5, 60}, // half speed: 40 s of the 80 s post-window fills in
{100, 20, 0, 100}, // not growing at all: it must already be all there
{100, 0, 0.9, 10}, // no pre-window, but the buffer still has to keep up
{100, 100, 1, 100}, // all pre-window: nothing fills in after the trigger
}
for _, c := range cases {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", windowSec: c.window, prePercent: c.prePercent})
setFill(te, 1e6, c.growth, 100) // span large enough not to matter
te.mu.Lock()
got := te.fillNeedLocked()
te.mu.Unlock()
if math.Abs(got-c.want) > 1e-6 {
t.Errorf("fillNeed(window %v, pre %v%%, growth %v) = %v, want %v",
c.window, c.prePercent, c.growth, got, c.want)
}
}
}
// A trigger that fires before its pre-window has been buffered can only produce
// a capture whose front half never existed. It must wait instead.
func TestFillGateHoldsFire(t *testing.T) {
te := armed("src:sig", "rising", 0.5) // window 1 s, pre 20 % → 0.2 s needed
setFill(te, 0.05, 1, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: only 0.05 s of the 0.2 s pre-window is buffered", te.State())
}
// The level was still tracked, so the next crossing is a real edge and not a
// re-detection of the one that was held off.
setFill(te, 0.25, 1, 200)
te.feed("src:sig", 1, []float64{3, 4}, []float64{1, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: no crossing, the signal stayed high", te.State())
}
te.feed("src:sig", 1, []float64{5, 6}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the pre-window is buffered", te.State())
}
te.feed("src:sig", 1, []float64{7, 8}, []float64{1, 1}) // carry the sample clock past the window
if trigTime, _, _, ok := te.dueCapture(1e9); !ok || trigTime != 6 {
t.Errorf("dueCapture = (%v,%v), want trigTime 6", trigTime, ok)
}
}
// A ring that is full and re-bucketing for a longer window fills slower than
// real time — it drops dense old samples to take sparse new ones — so more of
// the window has to be there before an edge may be accepted.
func TestFillGateAccountsForSlowGrowth(t *testing.T) {
te := armed("src:sig", "rising", 0.5) // window 1 s, pre 20 % → post 0.8 s
// At half speed only 0.4 s of the post-window fills in, so 0.6 s is needed.
setFill(te, 0.5, 0.5, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: 0.5 s buffered of the 0.6 s needed", te.State())
}
// The same 0.5 s in a ring still filling at full speed is plenty: everything
// after the trigger is yet to be recorded anyway.
te2 := armed("src:sig", "rising", 0.5)
setFill(te2, 0.5, 1, 100)
te2.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te2.State() != trigCollecting {
t.Fatalf("state = %q, want collecting: the buffer keeps up with the stream", te2.State())
}
setFill(te, 0.65, 0.5, 200)
te.feed("src:sig", 1, []float64{3, 4}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the buffer will span the window", te.State())
}
}
func TestFillGateInactiveWithoutMeasurement(t *testing.T) {
// No ring for the configured signal: gating would leave the trigger armed
// forever, which is worse than a short capture.
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting: nothing measured, so nothing to gate on", te.State())
}
// Nor is there anything to wait for when the buffer keeps up and the whole
// window is still to come.
te = newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising", threshold: 0.5,
windowSec: 1, prePercent: 0, mode: "normal"})
te.Arm()
setFill(te, 0, 1, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting with a 0 %% pre-window", te.State())
}
}
// Force is the user overriding the trigger, so it overrides the gate too.
func TestForceIgnoresFillGate(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
setFill(te, 0, 0, 100)
te.Force()
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting", te.State())
}
}
// seedFillNow is setFill against the real clock, for tests that then let the
// hub take its own measurements: its ticks land inside the growth measurement
// interval, so they refresh the span and leave the seeded rate alone.
func seedFillNow(te *triggerEngine, span, growth float64) {
now := float64(time.Now().UnixNano()) / 1e9
te.setBuffered(0, false, now-1)
te.setBuffered(span-growth, true, now-1)
te.setBuffered(span, true, now)
}
// While it holds off, the trigger looks identical to one that is ignoring
// edges. The state broadcast has to say it is filling, and keep saying so.
func TestFillProgressIsBroadcast(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 10, prePercent: 50, mode: "single"}) // 5 s of pre-window
h.trigger.Arm()
rb.write([]float64{0, 1}, []float64{-1, -1})
// Filling at the rate of the stream, so only the pre-window is needed.
seedFillNow(h.trigger, 1, 1)
h.triggerTick()
states := drainStates(t, h)
if len(states) == 0 {
t.Fatal("no state broadcast while the trigger was filling")
}
last := states[len(states)-1]
if last["state"] != trigArmed {
t.Fatalf("state = %v, want armed", last["state"])
}
if f, _ := last["bufferFill"].(float64); f < 0.19 || f > 0.21 {
t.Errorf("bufferFill = %v, want ~0.2 (1 s of 5 s)", last["bufferFill"])
}
if last["bufferNeedSec"] != 5.0 {
t.Errorf("bufferNeedSec = %v, want 5", last["bufferNeedSec"])
}
// An edge now is ignored: there is no 5 s of history to capture.
h.ingest("s1:sig", 1, []float64{1.5, 2.0}, []float64{-1, 1})
if h.trigger.State() != trigArmed {
t.Fatalf("state = %q, want armed: the pre-window is only 20 %% buffered", h.trigger.State())
}
// Progress is news even though the state has not moved.
rb.write([]float64{2, 3}, []float64{-1, -1})
h.triggerTick()
if states = drainStates(t, h); len(states) == 0 {
t.Fatal("no state broadcast as the pre-window filled further")
}
if f, _ := states[len(states)-1]["bufferFill"].(float64); f < 0.59 || f > 0.61 {
t.Errorf("bufferFill = %v, want ~0.6 (3 s of 5 s)", states[len(states)-1]["bufferFill"])
}
// Full: the gate opens, the fill disappears from the message and the next
// edge fires.
rb.write([]float64{4, 5.2}, []float64{-1, -1})
h.triggerTick()
states = drainStates(t, h)
if len(states) == 0 {
t.Fatal("no state broadcast when the pre-window filled")
}
if _, ok := states[len(states)-1]["bufferFill"]; ok {
t.Errorf("bufferFill still reported once the pre-window is buffered: %v", states[len(states)-1])
}
h.ingest("s1:sig", 1, []float64{5.3, 5.4}, []float64{-1, 1})
if h.trigger.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the pre-window is buffered", h.trigger.State())
}
}
// drainStates decodes every triggerState frame the hub has queued for
// broadcast. Hub.Run is what normally drains this queue, and it is not running
// in these tests.
func drainStates(t *testing.T, h *Hub) []map[string]any {
t.Helper()
var out []map[string]any
for {
select {
case msg := <-h.broadcastCh:
var m map[string]any
if err := json.Unmarshal(msg, &m); err != nil {
continue
}
if m["type"] == "triggerState" {
out = append(out, m)
}
default:
return out
}
}
}
+60 -1
View File
@@ -1,6 +1,65 @@
package wshub package wshub
import "testing" import (
"math"
"testing"
)
// A scope's envelope must not lose a spike, however narrow, and must stay in
// time order so it can be plotted as a single trace.
func TestMinMaxDecimateKeepsExtremes(t *testing.T) {
const n = 10000
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = float64(i) * 1e-6
vs[i] = math.Sin(float64(i) * 0.01)
}
// A one-sample spike in each direction: exactly what plain decimation drops.
vs[4321] = 12.5
vs[6789] = -9.75
dt, dv := minMaxDecimate(ts, vs, 200)
if len(dt) > 200 || len(dt) != len(dv) {
t.Fatalf("got %d t / %d v points, want <= 200 of each", len(dt), len(dv))
}
hiSeen, loSeen := false, false
for i := range dv {
switch dv[i] {
case 12.5:
hiSeen = true
if dt[i] != ts[4321] {
t.Errorf("spike kept at t=%v, want %v: timestamps must be the real ones", dt[i], ts[4321])
}
case -9.75:
loSeen = true
}
if i > 0 && dt[i] < dt[i-1] {
t.Fatalf("output is not time-ordered at %d: %v after %v", i, dt[i], dt[i-1])
}
}
if !hiSeen || !loSeen {
t.Errorf("envelope lost a spike (max kept=%v, min kept=%v)", hiSeen, loSeen)
}
}
func TestMinMaxDecimatePassesShortInputThrough(t *testing.T) {
ts := []float64{1, 2, 3}
vs := []float64{4, 5, 6}
dt, dv := minMaxDecimate(ts, vs, 200)
if len(dt) != 3 || dv[2] != 6 {
t.Errorf("input below the budget was altered: %v / %v", dt, dv)
}
// A flat bucket contributes one point, not two: nothing is invented.
flatT := make([]float64, 100)
flatV := make([]float64, 100)
for i := range flatT {
flatT[i] = float64(i)
}
if ft, _ := minMaxDecimate(flatT, flatV, 10); len(ft) != 5 {
t.Errorf("flat input decimated to %d points, want 5 (one per bucket)", len(ft))
}
}
func TestZoomPoints(t *testing.T) { func TestZoomPoints(t *testing.T) {
cases := []struct { cases := []struct {
+177 -9
View File
@@ -46,8 +46,61 @@ Reply (unicast): `{"type":"pong"}`.
```json ```json
{"type":"saveSources"} {"type":"saveSources"}
``` ```
Persists the current dynamically-added source list to the hub's `SourcesFile` Writes the hub's `SourcesFile`: the current dynamically-added source list **and**
(JSON array of `{label,addr,multicastGroup,dataPort}`); it is reloaded at startup. the calibration table, as one flat JSON array (see [§4](#4-config-file-format)).
The hub replies with [`configSaved`](#configsaved). Despite the name, this
command persists the whole config, not just the sources.
### `setCalibration`
```json
{"type":"setCalibration","source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"}
```
Records an affine calibration `value = raw × scale + offset` for one signal,
keyed by the source's **label** (not its runtime id) and the **base** signal
name — one entry covers every element of an array signal.
| Field | Type | Default | Validation |
|---|---|---|---|
| `source` | string | — | non-empty after trimming |
| `signal` | string | — | non-empty after trimming; any trailing `[i]` is stripped |
| `scale` | number | `1` | finite and non-zero |
| `offset` | number | `0` | finite |
| `unit` | string | `""` | trimmed, truncated to 16 UTF-8 bytes (a multi-byte character such as `°C` consumes more than one byte; truncation never splits a character); empty = use the streamer's own unit |
Calibration is **metadata only**: the hub stores and redistributes it but never
applies it. Ring buffers, recorded history, the `zoom` reply, both binary frames
and the trigger comparator all stay in raw units — a client that ignores
calibration behaves exactly as before.
An entry that reduces to the identity (`scale = 1`, `offset = 0`, `unit = ""`) is
**deleted** rather than stored, so a reset leaves no residue in the config file.
On acceptance the hub broadcasts [`calibration`](#calibration) to every client. A
rejected entry produces **no** broadcast, so the offending client reverts to the
last value it was told.
### `reloadConfig`
```json
{"type":"reloadConfig"}
```
Re-reads `SourcesFile` and then:
- **replaces** the calibration table wholesale with the file's contents;
- **adds** any source in the file that is not already active;
- **never** removes, restarts or reconnects a live source.
The asymmetry is deliberate: calibration is cheap to reapply, whereas a source is
a live UDP session that must not be interrupted. An unsaved source the user added
keeps streaming.
The hub replies with [`configReloaded`](#configreloaded), followed on success by a
`calibration` broadcast. Whether a `sources` broadcast follows depends on the
hub implementation: the Go hub emits `sources` only when the file adds at least
one new source (each `sm.Add()` call triggers it individually), while the C++
hub always emits `sources` unconditionally after a successful reload. Clients
must therefore tolerate an unsolicited `sources` frame after any reload.
### `getSources` / `getConfig` / `getStats` ### `getSources` / `getConfig` / `getStats`
@@ -67,8 +120,11 @@ Force a broadcast of the corresponding event.
- `signal` — full key `src:sig`, or `src:sig[i]` to trigger on element *i* of a - `signal` — full key `src:sig`, or `src:sig[i]` to trigger on element *i* of a
multi-element PACKET signal. multi-element PACKET signal.
- `edge``"rising"`, `"falling"` or `"both"`. - `edge``"rising"`, `"falling"` or `"both"`.
- `windowSec` — total capture window (clamped to 1e-4 … 10 s). - `windowSec` — total capture window. `preSec = windowSec * prePercent / 100`,
`preSec = windowSec * prePercent / 100`, `postSec = windowSec preSec`. `postSec = windowSec preSec`. Clamped to 1e-4 … 600 s by the Go hub and to
1e-4 … 60 s by the C++ one: the Go rings store min/max pairs once a window
outgrows their memory budget, so a long window costs resolution, while the C++
rings are fixed-capacity and would return the window truncated instead.
- `mode``"normal"` (auto-rearm ~200 ms after capture) or `"single"` - `mode``"normal"` (auto-rearm ~200 ms after capture) or `"single"`
(stays TRIGGERED until `rearm`). (stays TRIGGERED until `rearm`).
@@ -122,6 +178,33 @@ Every transition is broadcast as a `triggerState` event.
Request the hub to send a `historyInfo` event (unicast). Also sent automatically Request the hub to send a `historyInfo` event (unicast). Also sent automatically
on client connect. on client connect.
### `setHistoryBudget` (Go hub only)
```json
{"type":"setHistoryBudget","maxMPtsPerSignal":16.0}
```
Sets the per-signal archive budget in millions of stored points, the runtime
equivalent of `-history-max-mpts`. `0` restores the 16 MPts default; the hub
clamps to its own ceiling. Every archive file is re-created at the new size —
**the archived samples are lost**, because a file's capacity and min/max bucket
width are fixed at creation. Broadcasts `historyInfo` rather than answering the
requester alone: every client's view of what history exists has been invalidated.
### `setWindow` (Go hub only)
```json
{"type":"setWindow","seconds":60}
```
Reports how far back this client is plotting. The hub sizes its in-memory
buffers for the **widest** window any connected client has reported (10 s if
none has), bucketing each ring as min/max pairs when the window is too long to
hold verbatim — see *In-memory buffer policy* in
[StreamHub-Developer.md](StreamHub-Developer.md). While a trigger is armed the
trigger's own window wins. No reply; send it on connect and whenever the
timescale changes. A window the hub is not told about is a window whose start
may already have rolled out of the ring, leaving a `zoom` over it nothing to
answer with.
### `setMaxPoints` ### `setMaxPoints`
```json ```json
@@ -176,6 +259,20 @@ Sent at `StatsRate` Hz (default 1 Hz):
`state``idle | armed | collecting | triggered`; `trigTime` present once a `state``idle | armed | collecting | triggered`; `trigTime` present once a
trigger has fired. trigger has fired.
The Go hub adds `bufferFill` (0…1) and `bufferNeedSec` while `state` is `armed`
**and** its buffers do not yet reach back far enough to deliver a whole window.
Edges are ignored until they do, so that no capture arrives with a front that
was never recorded; the fields are absent once the requirement is met.
`bufferNeedSec` is how far back the hub must reach *now*, which is less than the
window by however much its buffers will fill in on their own while the
post-trigger window is collected: the pre-trigger span while they keep up with
the stream, and up to the whole `windowSec` when they do not (a full ring
re-bucketing for a longer window fills slower than real time, so the front of a
capture recedes while it is being collected).
The event is re-broadcast as the fraction grows, so a client can show the
progress instead of an armed trigger that appears to be ignoring the signal.
`forceTrigger` fires regardless.
### `zoom` (reply) ### `zoom` (reply)
```json ```json
@@ -190,18 +287,26 @@ trigger has fired.
Sent on client connect (if history is enabled) and on `historyInfo` command: Sent on client connect (if history is enabled) and on `historyInfo` command:
```json ```json
{"type":"historyInfo","enabled":true,"durationHours":1.0,"decimation":10, {"type":"historyInfo","enabled":true,"windowSec":600.0,"decimation":10,
"maxMPtsPerSignal":16.777216,
"signals":{ "signals":{
"scalar:Sine1":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000}, "scalar:Sine1":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000,"bucket":1},
"scalar:Sine2":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000}}} "scalar:Sine2":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000,"bucket":1}}}
``` ```
- `enabled``true` if the `+History` config block is present and valid. - `enabled``true` if the `+History` config block is present and valid.
- `durationHours` — configured history duration. - `windowSec` — the timespan the files are sized to hold, i.e. the live or
trigger window the clients are displaying (Go hub). The C++ StreamHub instead
keeps a fixed retention period and reports it as `durationHours`.
- `decimation` — samples-to-disk decimation factor (1 = every sample). - `decimation` — samples-to-disk decimation factor (1 = every sample).
- `maxMPtsPerSignal` — current per-signal budget, in millions of stored points
(Go hub only; see `setHistoryBudget`).
- `signals` — per-signal metadata keyed by `"sourceId:signalName"`: - `signals` — per-signal metadata keyed by `"sourceId:signalName"`:
- `t0`/`t1` — oldest/newest timestamp stored on disk (Unix seconds). - `t0`/`t1` — oldest/newest timestamp stored on disk (Unix seconds).
- `count` — number of valid entries currently in the circular file. - `count` — number of valid entries currently in the circular file.
- `capacity` — total capacity of the circular file. - `capacity` — total capacity of the circular file.
- `bucket` — source samples per stored min/max pair (Go hub only); `1` means
the signal is archived verbatim, higher means it is stored as an envelope
because it is too fast to fit the budget at full resolution.
### `historyZoom` (reply) ### `historyZoom` (reply)
@@ -219,6 +324,41 @@ If history is not enabled: `{"type":"historyZoom","error":"history not enabled"}
{"type":"maxPointsUpdated","maxPoints":50000} {"type":"maxPointsUpdated","maxPoints":50000}
``` ```
### `calibration`
```json
{"type":"calibration","cal":[
{"source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"}
]}
```
The complete calibration table. Broadcast when a client connects (as an empty
array when nothing is calibrated), after every accepted `setCalibration`, and
after a successful `reloadConfig`. It is a separate frame rather than a field on
`sources` because `sources` is serialised into a fixed 4 KiB buffer.
### `configSaved`
```json
{"type":"configSaved","ok":true,"path":"/etc/streamhub/sources.json"}
{"type":"configSaved","ok":false,"path":"","error":"no sources file configured"}
```
Broadcast in reply to `saveSources`. `path` is always present (empty when the hub
has no config file configured); `error` only when `ok` is false. The exact error
text is not part of the protocol contract and differs between hubs (the Go hub
uses `"no sources-file configured"`, the C++ hub `"no sources file configured"`).
### `configReloaded`
```json
{"type":"configReloaded","ok":true,"path":"/etc/streamhub/sources.json"}
{"type":"configReloaded","ok":false,"path":"/etc/streamhub/sources.json","error":"cannot read sources file"}
```
Broadcast in reply to `reloadConfig`; same shape as `configSaved`. On success
it is followed by a `calibration` broadcast. Whether a `sources` broadcast also
follows is hub-specific: the Go hub sends it only if the reload added at least
one new source; the C++ hub sends it unconditionally. Clients must tolerate an
unsolicited `sources` frame after any reload.
--- ---
## 3. Binary frames (hub → client) ## 3. Binary frames (hub → client)
@@ -270,7 +410,33 @@ per signal:
--- ---
## 4. Limits ## 4. Config file format
`SourcesFile` (C++ `SourcesFile` config key, Go `-sources-file` flag) is a flat
JSON array of flat objects. A block containing `addr` is a source; a block
containing `signal` is a calibration entry; anything else is skipped with a
warning.
```json
[
{"label": "wave", "addr": "127.0.0.1:44500"},
{"label": "mc", "addr": "127.0.0.1:44501", "multicastGroup": "239.0.0.1", "dataPort": 44502},
{"source": "wave", "signal": "Adc", "scale": 0.00030518, "offset": -1.25, "unit": "V"}
]
```
**Every object must stay flat.** The C++ `StreamHub::LoadSourcesFile` parser
takes each `{` up to the next `}` as one object, so a nested object anywhere in
the file would truncate the parse at the inner brace. A nested
`"calibration": {…}` inside a source entry is therefore not an option, and this
is why calibration entries are siblings of sources rather than children.
Files written by hub versions predating calibration load unchanged, and a file
written by either hub loads in the other.
---
## 5. Limits
| Limit | Value | | Limit | Value |
|-------|-------| |-------|-------|
@@ -278,3 +444,5 @@ per signal:
| UDPS source sessions | 32 | | UDPS source sessions | 32 |
| Max received WS payload | 64 KiB | | Max received WS payload | 64 KiB |
| Max sent WS payload | 4 MiB | | Max sent WS payload | 4 MiB |
| Calibration entries | 256 (C++ hub, `kMaxCalibration`); unbounded (Go hub) |
| Calibration unit override | 16 UTF-8 bytes |
+253 -10
View File
@@ -60,8 +60,20 @@ Each session calibrates per time-source:
`packetT = pktCalibOffset + hrt/hrtFreq`. `packetT = pktCalibOffset + hrt/hrtFreq`.
- Each referenced time signal gets its own offset on first value; - Each referenced time signal gets its own offset on first value;
`timerToSec = 1e-9` for `uint64` time signals, `1e-6` otherwise. `timerToSec = 1e-9` for `uint64` time signals, `1e-6` otherwise.
- Re-anchoring on reconnect, CONFIG change, or if computed time drifts > 2 s - The time-signal offset is **snapped** only on a genuine discontinuity in the
from wall clock (source restart / remote-vs-local HRT frequency drift). source: reconnect, CONFIG change, or the source clock jumping backward (a
looping/rewinding producer such as a rewinding `FileReader`).
- Plain *drift* — a source free-running on its own clock, or remote-vs-local HRT
frequency error — is **slewed**, not snapped. Past a 2 s threshold the offset
is nudged toward wall clock by at most 10 % of the packet's own duration.
Snapping instead would shift the whole published timeline in one step and so
tear a hole of exactly the drift into a stream that is in fact continuous;
a source drifting past the threshold repeatedly used to produce a train of
2 s holes. Drift is the honest reading, and the trade-off `TimeArrayGAM`'s
`Anchor = Continuous` explicitly asks for: a producer that cannot sustain its
nominal sample rate will fall progressively behind wall clock, and the hub
reports that rather than hiding it. The Go hub anchors once and never
re-anchors, so it never had the hole.
Per `timeMode`: Per `timeMode`:
@@ -94,16 +106,50 @@ Hub-side, web-client semantics (`setTrigger` fields in
[StreamHub-API.md](StreamHub-API.md)): [StreamHub-API.md](StreamHub-API.md)):
``` ```
IDLE --arm--> ARMED --edge crossing--> COLLECTING --wallNow ≥ trigTime+postSec+0.15s--> TRIGGERED IDLE --arm--> ARMED --edge crossing--> COLLECTING --every source past trigTime+postSec+0.15s--> TRIGGERED
TRIGGERED --rearm (single) / auto ~200ms (normal, unless stopped)--> ARMED TRIGGERED --rearm (single) / auto ~200ms (normal, unless stopped)--> ARMED
any --disarm--> IDLE any --disarm--> IDLE
``` ```
`UDPSourceSession` calls `TriggerEngine::CheckSample` for every decoded sample `UDPSourceSession` calls `TriggerEngine::CheckSample` for every decoded sample
of the configured signal (signal index cached per config epoch). On of the configured signal (signal index cached per config epoch). Each source is
finalisation the push loop reads `[trigTimepreSec, trigTime+postSec]` from all read `[trigTimepreSec, trigTime+postSec]`, LTTB-capped to 20 000 pts/signal and
rings, LTTB-caps to 20 000 pts/signal and broadcasts a binary **version 2** appended to a binary **version 2** capture frame; every FSM transition
capture frame; every FSM transition broadcasts a `triggerState` event. broadcasts a `triggerState` event.
Once fired, that event carries `trigTime` **and** the window latched at fire
time (`preSec`/`postSec`). Clients draw the still-filling capture from their own
buffers on that axis long before the v2 frame arrives — for a long window at a
high rate the hub stays silent for seconds — and the trigger bar's window and
pre-% are editable, so without the latched values a client would place the
filling trace on whatever window the operator happened to be typing. Older hubs
omit both fields; clients fall back to their local config.
The COLLECTING deadline is on the **data's** clock, via
`UDPSourceSession::ProducerNewestTime()``trigTime` comes from sample
timestamps, and a source free-running on its own clock sits seconds away from
`clock_gettime()`, so a wall-clock deadline chops exactly that offset off every
capture's tail. Only signals actually timestamped from a time signal count
toward that reading: PACKET-timed ones (including the time array itself) are
stamped on arrival and would just report "now".
Sources are harvested independently — `BeginTriggerCapture`,
`HarvestTriggerCapture` per source as *it* becomes ready, `FinishTriggerCapture`
once all are in — with the frame accumulating in `capBuf_` across push ticks.
Waiting for the slowest source before reading any of them lets the leaders'
rings roll past the pre-trigger region first, losing the head of their traces. A
2 s wall-clock watchdog bounds the wait for a source that stopped advancing: it
is harvested short, with a warning naming the source and how far it got.
`setTrigger` also records the requested window, and each stats tick the push
loop runs `GrowRingsForTrigger()`. A ring whose measured rate
(`Count() / TimeSpan()`, since UDPS sources usually advertise
`samplingRate = 0`) cannot hold `window + 0.5 s` is grown in place to
`rate × (window + 0.5) × 1.2` points, clamped to `RingMaxMB` per signal.
`SignalRingBuffer::Grow()` copies oldest→newest and leaves `count` /
`totalWritten` untouched so the per-client push cursors survive the resize.
Rings never shrink; a hub left with a 5 s window on a 5 MSps source will sit at
the ceiling.
## 6. Configuration ## 6. Configuration
@@ -113,9 +159,16 @@ MaxPoints = 20000 // legacy global cap (overridable with -maxPoints)
PushRate = 30 // Hz PushRate = 30 // Hz
MaxPushPoints = 50 // per signal per push MaxPushPoints = 50 // per signal per push
StatsRate = 1 // Hz StatsRate = 1 // Hz
RingTemporal = 1000000 // ring capacity, temporal signals (pts) RingTemporal = 1000000 // initial ring capacity, temporal signals (pts)
RingScalar = 100000 // ring capacity, scalar/PACKET signals (pts) RingScalar = 100000 // ring capacity, scalar/PACKET signals (pts)
RingMaxMB = 128 // per-signal growth ceiling (MiB) for trigger windows
SourcesFile = "streamhub_sources.json" // saveSources persistence SourcesFile = "streamhub_sources.json" // saveSources persistence
AllowedOrigins = "http://127.0.0.1:8099,http://localhost:8099"
// comma/space-separated WebSocket Origin allowlist (max 8 × 128 chars).
// Without it the handshake only accepts an Origin whose host matches
// the request Host, so a browser serving the SPA from another port
// (run_streamhub.sh: SPA 8099, hub 8090) gets 403. Non-browser
// clients send no Origin and are unaffected.
Sources = { Sources = {
Src1 = { Label = "PSU" Addr = "127.0.0.1" Port = 44500 Src1 = { Label = "PSU" Addr = "127.0.0.1" Port = 44500
MulticastGroup = "239.0.0.1" DataPort = 44503 } // multicast optional MulticastGroup = "239.0.0.1" DataPort = 44503 } // multicast optional
@@ -145,6 +198,54 @@ Per-signal file capacity is computed at source CONFIG time:
`capacity = ceil(DurationHours × 3600 × samplingRate / Decimation)`, minimum `capacity = ceil(DurationHours × 3600 × samplingRate / Decimation)`, minimum
1000 pairs. 1000 pairs.
The Go hub (`Client/udpstreamer`) carries the same archive and the same file
format, configured with flags instead of a config node: `-history-dir`
(defaults to `<tmp>/udpstreamer-history`; empty disables),
`-history-window-sec`, `-history-decimation`, `-history-flush-sec`,
`-history-min-free-mb` (negative disables the check; 0 means the 500 MB default,
where the C++ `MinDiskFreeMB = 0` disables it) and `-history-max-mpts`, a
per-signal budget in millions of stored points, defaulting to 16 MPts (256 MB).
The budget exists because the timespan alone cannot bound the file: 600 s of a
1 MSps signal is 9.6 GB.
**The Go hub sizes its files from the window, not from a retention period.** The
archive exists to answer a zoom or a trigger capture after the in-memory rings
have rolled past it, and neither ever asks for more than the live or trigger
window — so a file holds `windowSec × rate` samples (plus 25 % headroom, since a
capture is read back a window after its first sample was written), and never
hours of them. Retaining an hour instead meant a 1 s live window was archived at
a thousandth of the resolution the same budget could have bought.
The budget is therefore spent on resolution, not on span. A signal too fast to
archive sample-for-sample within it is stored as a **min/max envelope**: `bucket`
source samples collapse to their two extremes, with `bucket` the narrowest that
makes the window fit. The `.shist` header's `decimation` field carries
`bucket × Decimation`, so a reader knows the stored resolution, and a file is
only reopened when it matches.
`Hub.retuneRings` re-sizes the files once a second alongside the rings, from the
same `activeWindowSec()`. A file's capacity and bucket are fixed at creation, so
a re-size discards what it held; two rules keep that rare. A file is only grown
when it no longer covers the window, and only shrunk when it is enveloped
(`bucket > 1`), covers more than twice the window, and a narrower bucket is
actually available — a file already at full resolution is left alone however
short the window becomes, so arming a 1 s trigger does not throw away the
seconds the capture is about to ask for. `historyInfo` is re-broadcast whenever a
re-size happens.
The budget is also settable at runtime from the web UI (the history badge in the
status bar) via the `setHistoryBudget` WS command; `historyInfo` reports it as
`maxMPtsPerSignal` and reports each signal's `bucket`. Changing it re-creates the
files, so the archived samples are lost — a file's capacity and bucket width are
fixed at creation and an existing envelope cannot be re-bucketed into a different
one.
History is on by default in the Go hub because it is what holds a trigger capture
at full resolution — see *Trigger captures* below. Signals whose producer
declares `samplingRate = 0` — every UDPS source — are not sized from a guess: the
file is opened only once the hub has measured the rate off the live stream, which
it retries once a second.
### `.shist` binary file format ### `.shist` binary file format
Each signal gets one file: `<Directory>/<sourceId>/<signalName>.shist`. Each signal gets one file: `<Directory>/<sourceId>/<signalName>.shist`.
@@ -178,13 +279,155 @@ reopened — head/count/time bounds are restored from the on-disk header.
`historyZoom` requests (see [StreamHub-API.md](StreamHub-API.md)) call `historyZoom` requests (see [StreamHub-API.md](StreamHub-API.md)) call
`HistoryWriter::ReadRange` which performs binary search over the circular file `HistoryWriter::ReadRange` which performs binary search over the circular file
using `pread` to locate the `[t0, t1]` window, then copies matching pairs. using `pread` to locate the `[t0, t1]` window, then copies matching pairs.
If the result exceeds the requested `n`, LTTB decimation is applied (same If the result exceeds the requested `n`, decimation is applied (same decimator
`LTTBDecimate` as in-memory zoom). as in-memory zoom: `LTTBDecimate` in the C++ hub, `minMaxDecimate` in the Go
one).
Both the web SPA and ImGui client issue `historyZoom` in parallel with regular Both the web SPA and ImGui client issue `historyZoom` in parallel with regular
`zoom` and merge the results: history covers the older part of the visible `zoom` and merge the results: history covers the older part of the visible
window, the in-memory ring covers the recent part. window, the in-memory ring covers the recent part.
A range wider than the read budget is thinned across its whole width with a
stride, not truncated at the front: answering a 10 s query with its first
few milliseconds reads as an empty plot to a client and sends it back to its
own coarse copy of the data.
### In-memory buffer policy (Go hub)
Each temporal signal gets one ring holding a fixed **budget** of `(t, v)` pairs:
10 M points, 160 MB, settable with `-ring-mpts`. Scalar signals keep a flat
100 000-packet ring, where a megasample budget would be waste. Rings start at
250 k points and are grown to the budget on demand, so a source that is
configured but never sends costs nothing.
Like the disk archive, the budget buys **resolution, not span**. Once a second
`retuneRings` compares the measured source rate against the window being
displayed and picks each ring's min/max `bucket`:
| condition | bucket | effect |
|---|---|---|
| `Sps × window ≤ budget` | 1 | stored verbatim; the ring reaches further back than the window, which is free zoom headroom |
| `Sps × window > budget` | `⌈2 × Sps × window × 1.25 ÷ capacity⌉` | `bucket` samples collapse to their two extremes, so the whole window fits |
A bucket costs two points (its minimum and its maximum), hence the factor 2 —
and why a bucket of 2 covers no more ground than a bucket of 1.
The window is the **trigger's** while a trigger is armed: its pre-window has to
already be in the ring when the trigger fires, or the capture has nothing to
back-fill from. Otherwise it is the widest window any connected client has
reported with the `setWindow` command, defaulting to 10 s for clients that never
send one. Sizing for the live window matters as much as for a capture: a fixed
sample-count ring covers ~6 s at 1 MSps, so a zoom on a 60 s timescale used to
come back with only its tail.
Retuning is hysteretic — a bucket is held while it covers the window without
covering more than twice it. Sharing one threshold for up and down makes a rate
jittering across a bucket boundary halve and double the stored resolution every
second.
Live pushes, the disk archive and the trigger comparator all see every sample:
`ingest` hands the raw batch to each, and only the ring's own copy is reduced.
### Trigger captures (Go hub)
A trigger capture is delivered as a decimated snapshot (20 000 points), so a
zoom into it has to come from full-resolution storage. The rings are tuned to
~1.25× the trigger window, so they roll past a captured window shortly after the
capture — and the trigger rearms and starts refilling them immediately.
**In-memory double buffer.** The rings are the write half; `captureHold`
(`capturehold.go`) is the read half. As `buildTriggerCapture` lifts each signal's
window out of its ring it publishes the *undecimated* slice into the hold, and
`zoomSlice` answers from the hold rather than the ring for any range the held
window fully contains. The swap happens only once the next capture is complete —
which is also the moment the client stops displaying the previous one — so the
shot being explored is never overwritten by the acquisition running behind it. A
capture that came back empty does not swap, so it cannot blank the window on
screen.
**Waiting for the buffer.** An armed trigger ignores edges until its buffers
reach back far enough for a capture taken now to come back whole (`fillLocked`
in `trigger.go`, fed by `refreshTriggerFill` from the trigger signal's own ring
— once per tick, and again on every trigger command so that an `arm` cannot
fire on a stale measurement). Firing earlier can only produce a capture whose
front was never recorded, which is what made the first shot after a widened
window come back short.
What must hold is that the buffer spans the whole window *at harvest time* — its
newest sample is then `trigTime + post`, so anything less has lost the front of
the capture. It keeps filling while the post-window is collected, so the
shortfall it may start with is what it will make up in that time, measured
rather than assumed:
```
need = windowSec growth × postSec (floored at the pre-trigger window)
```
`growth` is the ring's span growth in seconds per second, sampled over at least
`bufGrowthIntervalSec` and smoothed. The three regimes fall out of the one
formula:
| ring | growth | needs |
|---|---|---|
| still filling | 1 | the pre-trigger window — everything after the trigger is yet to be recorded anyway |
| full, re-bucketing for a longer window | 0…1 | in between: it drops dense old samples to take sparse new ones, so it fills slower than real time and the front of the capture recedes while the post-window elapses |
| full, settled | 0 | the whole window — which a ring tuned for that window already exceeds, so nothing actually waits |
Measured at 1 MSps, widening 10 s → 30 s with 50 % pre: growth settles at ~0.65,
so `need` converges on ~20.4 s of the 30 s and the trigger fires ~12 s after
arming with a capture that is 100 % complete. Requiring the whole window instead
would have waited 26 s for the same result.
The gate measures the trigger signal's ring, not the narrowest of all of them: a
signal that never reaches back that far would otherwise stop the trigger from
ever firing. It is disabled outright when there is no ring to measure or nothing
is needed, and `forceTrigger` overrides it. While it holds off, `triggerState`
carries `bufferFill`/`bufferNeedSec` and is re-broadcast as the fraction climbs,
so the UI shows `ARMED 42%` rather than a trigger that looks stuck.
**Back-filling a short capture.** A ring only spans the window once it has
rolled over completely at its current min/max bucket, which takes as long as the
window itself; widen the window, or arm right after setting it, and the first
captures start late and the client draws a blank front half.
`backfillCaptureHead` (`trigger.go`) therefore prepends whatever of
`[t0, ring's first sample)` the archive still holds, budgeting the read by the
share of the window being filled and trimming the overlap so the frame's
timestamps stay ascending. It needs history enabled; without it the capture is
simply short, and the hub logs by how much. The hold declines any range its own
samples do not actually cover, so a stretch neither source could supply falls
through to the archive instead of being redrawn as the same hole on every zoom
and every *fit*.
The hold declines ranges reaching outside its window: those are live zooms, and
only the rings still track the stream. Inside the window it needs no
trigger-state gating, because retuning never rewrites stored samples — a ring
that still covers the range holds the very same points. It is cleared when
`updateConfig` rebuilds the rings, since a restarted producer can replay the same
timestamps.
Budget: the hold costs one window per signal on top of the ring budget, up to a
further ~0.8 × `-ring-mpts`. Nothing is held until the first capture fires.
**On disk.** The archive covers what the hold cannot: ranges wider than the
capture window, and sessions where the hub restarted. It is circular and sized
from that same window, so it too wraps over a captured shot within a window of
delivering it. When a capture is delivered, the hub therefore copies
`[trigTime pre, trigTime + post]` out of each `.shist` into a
`<signalName>.cap` file, laid out as a full non-wrapping `.shist`
(`capacity == count`, `head == 0`) so the same `readRange` reads it. A
`historyZoom` whose range the capture file fully contains is answered from it;
anything wider is answered from the archive. The copy is replaced by the next
trigger and by nothing else — rearming keeps it, because the client is still
showing that capture.
Budget: a capture costs one window's worth of disk per signal on top of
`-history-max-mpts`.
Protecting the window in place instead — pinning the region and refusing to
wrap onto it — does not work, and was tried: a capture held for longer than the
archive covers stops the archive dead, and the resulting hole lands exactly
where the *next* capture's pre-trigger window belongs.
## 7. Build & test ## 7. Build & test
```bash ```bash
+56 -4
View File
@@ -36,7 +36,8 @@ thread.
// Multicast (optional — omit for unicast mode) // Multicast (optional — omit for unicast mode)
MulticastGroup = "239.0.0.1" // IPv4 multicast address (224.0.0.0/4) MulticastGroup = "239.0.0.1" // IPv4 multicast address (224.0.0.0/4)
Interface = "eth0" // Multicast-bound interface (mandatory when MulticastGroup is set) Interface = "192.168.1.10" // Local IPv4 address of the outgoing interface
// (mandatory when MulticastGroup is set)
DataPort = 44501 // UDP port for multicast DATA (default: Port+1) DataPort = 44501 // UDP port for multicast DATA (default: Port+1)
// Publishing mode (optional) // Publishing mode (optional)
@@ -87,7 +88,7 @@ thread.
| `Port` | uint16 | 44500 | UDP server port (unicast) or TCP control port (multicast). Values ≤ 1024 produce a warning. | | `Port` | uint16 | 44500 | UDP server port (unicast) or TCP control port (multicast). Values ≤ 1024 produce a warning. |
| `MaxPayloadSize` | uint32 | 1400 | Max payload bytes per UDP datagram (min 18) | | `MaxPayloadSize` | uint32 | 1400 | Max payload bytes per UDP datagram (min 18) |
| `MulticastGroup` | string | *(absent)* | IPv4 multicast address (e.g. `"239.0.0.1"`). Must be in 224.0.0.0/4. Absent or empty = unicast mode. | | `MulticastGroup` | string | *(absent)* | IPv4 multicast address (e.g. `"239.0.0.1"`). Must be in 224.0.0.0/4. Absent or empty = unicast mode. |
| `Interface` | string | *(absent)* | Network interface for multicast binding (e.g. `"eth0"`). **Mandatory** when `MulticastGroup` is set. | | `Interface` | string | *(absent)* | Local IPv4 address of the interface multicast DATA leaves from, in dotted-quad form (e.g. `"192.168.1.10"`, or `"127.0.0.1"` for loopback-only testing). **Not** an interface name — `"eth0"` is rejected. **Mandatory** when `MulticastGroup` is set. |
| `DataPort` | uint16 | Port+1 | UDP port for multicast DATA datagrams. Ignored in unicast mode. | | `DataPort` | uint16 | Port+1 | UDP port for multicast DATA datagrams. Ignored in unicast mode. |
| `PublishingMode` | string | Strict | `Strict`: send every RT cycle. `Accumulate`: batch until size/time limit. `Decimate`: send every Nth cycle. | | `PublishingMode` | string | Strict | `Strict`: send every RT cycle. `Accumulate`: batch until size/time limit. `Decimate`: send every Nth cycle. |
| `MinRefreshRate` | float64| — | Flush frequency in Hz. **Required** when `PublishingMode` = `Accumulate`. | | `MinRefreshRate` | float64| — | Flush frequency in Hz. **Required** when `PublishingMode` = `Accumulate`. |
@@ -147,7 +148,14 @@ CONNECT evicts the previous client.
### Multicast ### Multicast
Enabled by setting `MulticastGroup` to a valid IPv4 multicast address (224.0.0.0/4). Enabled by setting `MulticastGroup` to a valid IPv4 multicast address (224.0.0.0/4).
The `Interface` parameter is **mandatory** and specifies the network interface to bind. The `Interface` parameter is **mandatory**. It is the local IPv4 address of the
interface DATA datagrams leave from, given in dotted-quad form — it sets
`IP_MULTICAST_IF` on the data socket and is parsed with `inet_addr()`, so an
interface *name* such as `"eth0"` is rejected and `Initialise` fails.
Receivers must join the group on the matching interface. A receiver that joins
with `INADDR_ANY` lets the kernel pick the default-route interface, and it will
silently receive nothing if that is not the interface named by `Interface`.
The server opens a TCP listener on `Port` for control traffic and a UDP socket aimed at The server opens a TCP listener on `Port` for control traffic and a UDP socket aimed at
`MulticastGroup:DataPort` for data traffic. The client: `MulticastGroup:DataPort` for data traffic. The client:
@@ -253,7 +261,7 @@ PrepareNextState() ← opens UDP server socket, starts background threa
Class = UDPStreamer Class = UDPStreamer
Port = 44500 // TCP control port Port = 44500 // TCP control port
MulticastGroup = "239.0.0.1" // Enables multicast mode MulticastGroup = "239.0.0.1" // Enables multicast mode
Interface = "eth0" // Mandatory for multicast Interface = "192.168.1.10" // Local IP of the outgoing interface (mandatory)
DataPort = 44501 // UDP data port (default: Port+1) DataPort = 44501 // UDP data port (default: Port+1)
MaxPayloadSize = 1400 MaxPayloadSize = 1400
PublishingMode = "Accumulate" PublishingMode = "Accumulate"
@@ -290,6 +298,50 @@ PrepareNextState() ← opens UDP server socket, starts background threa
} }
``` ```
---
## UDPStreamerClient DataSource
`UDPStreamerClient` is a MARTe2 **input** DataSource that receives signals from a `UDPStreamer`
server. Transport, fragment reassembly, and auto-reconnect are delegated to `UDPSClient`; the
DataSource only decodes CONFIG/DATA payloads into real-time signal memory.
### Configuration
```
+ClientDS = {
Class = UDPStreamerClient
ServerAddress = "192.168.1.10" // UDPStreamer server IP
Port = 44500 // Server port
// Multicast (optional — omit for unicast)
MulticastGroup = "239.0.0.1"
DataPort = 44501 // UDP data port (default: Port+1)
Interface = "192.168.1.10" // See table below
MaxPayloadSize = 1400
Signals = {
Counter = { Type = uint32 }
}
}
```
### Parameters
| Parameter | Type | Default | Description |
| --------------- | ------- | ---------- | ----------- |
| `ServerAddress` | string | 127.0.0.1 | IPv4 address of the `UDPStreamer` server. |
| `Port` | uint16 | 44500 | Server UDP port (unicast) or TCP control port (multicast). |
| `MulticastGroup`| string | *(absent)* | IPv4 multicast address. Presence enables multicast mode. |
| `DataPort` | uint16 | Port+1 | UDP port for multicast DATA datagrams. |
| `Interface` | string | *(absent)* | Local IPv4 dotted-quad address (e.g. `"127.0.0.1"`) of the interface to join the multicast group on. **Optional**: omitting it uses the default-route interface (INADDR_ANY), which silently receives nothing if the server sends on a different interface. Not an interface name — `"eth0"` is invalid. |
| `MaxPayloadSize`| uint32 | 1400 | Max payload bytes per datagram (must match the server). |
| `SilenceTimeout`| float32 | 1.0 | Seconds of no data before auto-reconnect. 0 disables. |
| `KeepAliveInterval` | uint32 | 15 | Seconds between unicast keepalive ACKs. 0 disables. |
| `CPUMask` | uint32 | 0xFFFFFFFF | CPU affinity for the background receiver thread. |
| `StackSize` | uint32 | default | Stack size in bytes for the receiver thread. |
With `MaxPayloadSize = 1400`, a single 1000-element float32 signal produces: With `MaxPayloadSize = 1400`, a single 1000-element float32 signal produces:
``` ```
+37
View File
@@ -80,6 +80,23 @@ Signals received in the CONFIG packet are listed in the sidebar:
- **Spatial arrays**`TimeMode = PacketTime` arrays are shown as an expandable - **Spatial arrays**`TimeMode = PacketTime` arrays are shown as an expandable
group; individual elements (`Ch1[0]`, `Ch1[1]`, …) can be dragged independently. group; individual elements (`Ch1[0]`, `Ch1[1]`, …) can be dragged independently.
The unit badge next to each signal shows the calibration's unit override when one
is set, and the streamer's own unit otherwise.
At the bottom of the sidebar, the collapsible **Sources & Config** section holds:
- the `host:port`, label, multicast group and data port inputs plus **Connect**,
which adds a source at runtime;
- **Save** — writes the source list and the whole calibration table to the hub's
config file;
- **Reload** — re-reads that file. Calibration is replaced wholesale (so unsaved
edits are discarded), sources present in the file but not running are added,
and no running source is stopped or reconnected. The hub may send an updated
`sources` list even when nothing changed (see the API doc for the per-hub
difference);
- a status line showing the written path on success or the hub's error text on
failure.
Click the sidebar toggle button (☰) to collapse/expand the signal list. Click the sidebar toggle button (☰) to collapse/expand the signal list.
### Adding Plots ### Adding Plots
@@ -143,11 +160,31 @@ plot header showing per-signal vertical scale controls:
| **V/div** | Volts (or units) per division | | **V/div** | Volts (or units) per division |
| **Pos (div)** | Screen position in divisions (draggable offset marker on Y axis) | | **Pos (div)** | Screen position in divisions (draggable offset marker on Y axis) |
| **Type** (Mixed mode only) | Toggle between **Analog** and **Digital** for this signal | | **Type** (Mixed mode only) | Toggle between **Analog** and **Digital** for this signal |
| **Cal · Scale** | Data calibration gain. `value = raw × Scale + Offset` |
| **Cal · Offset** | Data calibration bias, in calibrated units |
| **Cal · Unit** | Overrides the unit reported by the streamer (max 16 UTF-8 bytes; a multi-byte character such as `°C` counts as more than one byte) |
| **Reset** | Clears this signal's calibration (`Scale = 1`, `Offset = 0`, no unit override) |
| **✕** | Close the toolbar and deselect the signal | | **✕** | Close the toolbar and deselect the signal |
Offset markers (small triangles on the Y axis) show each signal's position and can Offset markers (small triangles on the Y axis) show each signal's position and can
be dragged to reposition signals without opening the toolbar. be dragged to reposition signals without opening the toolbar.
**Calibration vs. V/div and Offset.** They are different things. V/div and Offset
are a *display* transform: they move and stretch the trace on screen. Calibration
changes *the value itself* — the plot, the Y-axis tick labels, the cursor and
hover readouts, the CSV export and the trigger threshold all report
`raw × Scale + Offset` in the calibrated unit. V/div is then read as "calibrated
units per division" and Offset as "the calibrated value at screen centre".
The calibration header names the **base** signal and its element count, because
one entry covers every element of an array — opening the toolbar on `Adc[3]` and
editing the calibration moves all of `Adc`.
Calibration is keyed by the source's **label**, is shared with every other
browser connected to the same hub, and is not persisted until you press **Save**
in the Sources & Config section. It is mirrored to `localStorage` so it survives
a page reload even against a hub with no config file.
### Plot Controls ### Plot Controls
| Control | Action | | Control | Action |
+16 -1
View File
@@ -69,10 +69,25 @@ See `Docs/SineArrayGAM.md`.
### TimeArrayGAM ### TimeArrayGAM
Generates a time-reference float64 array. Each element holds the timestamp of the Generates a time-reference uint64 array. Each element holds the timestamp of the
corresponding sample in a packed burst, computed from the RT cycle timestamp and the corresponding sample in a packed burst, computed from the RT cycle timestamp and the
configured `SamplingRate`. configured `SamplingRate`.
`Anchor` selects how the burst is placed in time:
| `Anchor` | `out[k]` |
|---|---|
| `FirstSample` | `input + k · period` |
| `LastSample` | `input (N1k) · period` |
| `Continuous` | `input(first cycle) + (n + k) · period` |
`FirstSample`/`LastSample` re-read the timer each cycle, so a lost RT cycle
(`LinuxTimer` re-phases with `counter += nCycles`) punches a whole-period hole
into the time base even though only one array of samples was produced. Use
`Continuous` when the data signal is itself contiguous (`SineArrayGAM` never
skips phase): it latches the timer once and then advances an internal sample
counter by `N` per cycle, like an acquisition card running off its own clock.
### DebugService Interface ### DebugService Interface
Instruments a running MARTe2 application **without modifying its source code**. On Instruments a running MARTe2 application **without modifying its source code**. On
@@ -78,6 +78,32 @@ public:
/** @return Current number of stored points (≤ capacity). */ /** @return Current number of stored points (≤ capacity). */
uint32 Count() const; uint32 Count() const;
/** @return Allocated capacity in points. */
uint32 Capacity() const;
/**
* @brief Enlarge the buffer to @p newCap points, keeping the stored data
* and the TotalWritten() counter (unlike Allocate(), which resets both so
* every reader cursor and every retained sample is lost).
* @return true if the buffer now holds at least @p newCap points.
*/
bool Grow(uint32 newCap);
/**
* @brief Wall-clock span currently retained, i.e. newest minus oldest
* timestamp. 0 when fewer than two points are stored.
*/
float64 TimeSpan() const;
/**
* @brief Timestamp of the most recently stored point, 0 when empty.
*
* This is the source's own time base, which is *not* the hub's wall clock:
* use it, never clock_gettime(), whenever a decision depends on how far
* the data itself has advanced.
*/
float64 NewestTime() const;
/** @brief Discard all stored points. */ /** @brief Discard all stored points. */
void Clear(); void Clear();
@@ -138,6 +164,69 @@ inline bool SignalRingBuffer::Allocate(uint32 maxPts) {
return true; return true;
} }
inline bool SignalRingBuffer::Grow(uint32 newCap) {
if (newCap <= capacity) { return true; }
/* Allocate outside the lock; readers may be active. */
float64 *newT = new float64[newCap];
float64 *newV = new float64[newCap];
if ((newT == static_cast<float64 *>(0)) ||
(newV == static_cast<float64 *>(0))) {
delete[] newT;
delete[] newV;
return false;
}
(void) mutex.FastLock();
if (newCap > capacity) {
/* Copy oldest-to-newest so the new buffer starts unwrapped. */
const uint32 avail = count;
for (uint32 i = 0u; i < avail; i++) {
const uint32 idx = (head + capacity - avail + i) % capacity;
newT[i] = tBuf[idx];
newV[i] = vBuf[idx];
}
float64 *oldT = tBuf;
float64 *oldV = vBuf;
tBuf = newT;
vBuf = newV;
capacity = newCap;
head = avail;
/* count and totalWritten are unchanged: no sample is gained or lost,
* so push cursors stay valid across the resize. */
mutex.FastUnLock();
delete[] oldT;
delete[] oldV;
return true;
}
mutex.FastUnLock();
delete[] newT;
delete[] newV;
return true;
}
inline float64 SignalRingBuffer::TimeSpan() const {
(void) mutex.FastLock();
float64 span = 0.0;
if ((capacity > 0u) && (count > 1u)) {
const uint32 oldest = (head + capacity - count) % capacity;
const uint32 newest = (head + capacity - 1u) % capacity;
span = tBuf[newest] - tBuf[oldest];
}
mutex.FastUnLock();
return (span > 0.0) ? span : 0.0;
}
inline float64 SignalRingBuffer::NewestTime() const {
(void) mutex.FastLock();
float64 t = 0.0;
if ((capacity > 0u) && (count > 0u)) {
t = tBuf[(head + capacity - 1u) % capacity];
}
mutex.FastUnLock();
return t;
}
inline void SignalRingBuffer::Write(float64 t, float64 v) { inline void SignalRingBuffer::Write(float64 t, float64 v) {
(void) mutex.FastLock(); (void) mutex.FastLock();
if (capacity > 0u) { if (capacity > 0u) {
@@ -288,6 +377,13 @@ inline MARTe::uint64 SignalRingBuffer::TotalWritten() const {
return tw; return tw;
} }
inline uint32 SignalRingBuffer::Capacity() const {
(void) mutex.FastLock();
const uint32 c = capacity;
mutex.FastUnLock();
return c;
}
inline uint32 SignalRingBuffer::Count() const { inline uint32 SignalRingBuffer::Count() const {
(void) mutex.FastLock(); (void) mutex.FastLock();
uint32 c = count; uint32 c = count;
File diff suppressed because it is too large Load Diff
+111 -11
View File
@@ -44,6 +44,32 @@ using MARTe::StructuredDataI;
/** Maximum number of simultaneously connected UDPStreamer sources. */ /** Maximum number of simultaneously connected UDPStreamer sources. */
static const uint32 kMaxSessions = 32u; static const uint32 kMaxSessions = 32u;
/** Maximum number of stored per-signal calibration entries. */
static const uint32 kMaxCalibration = 256u;
/** Maximum length of a calibration unit override (mirrors the Go maxUnitLen). */
static const uint32 kMaxUnitLen = 16u;
/**
* @brief One per-signal affine calibration: y = raw*scale + offset.
*
* Keyed by the source LABEL (not the runtime "sN" id, which is assigned in
* add-order and would rebind if the source list were reordered) and by the
* BASE signal name (no "[i]" suffix: one entry covers a whole array signal).
*
* Fixed-size char arrays are used deliberately: they avoid per-entry heap
* churn (no StreamString allocation per calibration slot), keep the type free
* of STL, and make a CalibrationEntry snapshot trivially copyable under the
* calibration mutex lock.
*/
struct CalibrationEntry {
char source[128]; ///< Source label
char signal[128]; ///< Base signal name (no "[i]" suffix)
char unit[17]; ///< Unit override (max kMaxUnitLen bytes + NUL)
MARTe::float64 scale;
MARTe::float64 offset;
};
/** /**
* @brief Top-level StreamHub orchestrator. * @brief Top-level StreamHub orchestrator.
* *
@@ -65,7 +91,7 @@ public:
* WSPort (uint32, default 8090) * WSPort (uint32, default 8090)
* MaxPoints (uint32, default 20000) ring buffer capacity per signal * MaxPoints (uint32, default 20000) ring buffer capacity per signal
* PushRate (uint32, default 30) push loop rate in Hz * PushRate (uint32, default 30) push loop rate in Hz
* MaxPushPoints (uint32, default 500) LTTB threshold for live push * MaxPushPoints (uint32, default 50) LTTB threshold for live push
* StatsRate (uint32, default 1) stats broadcast rate in Hz * StatsRate (uint32, default 1) stats broadcast rate in Hz
* +Sources { +<id> { Label=...; Addr=...; Port=... } } * +Sources { +<id> { Label=...; Addr=...; Port=... } }
* *
@@ -108,6 +134,12 @@ private:
/** Broadcast {"type":"config","sourceId":...} for one session. */ /** Broadcast {"type":"config","sourceId":...} for one session. */
void BroadcastConfig(uint32 sessionIdx); void BroadcastConfig(uint32 sessionIdx);
/** Broadcast {"type":"calibration","cal":[...]} to all clients. */
void BroadcastCalibration();
/** Broadcast {"type":"configSaved"|"configReloaded","ok":...} to all clients. */
void BroadcastConfigAck(const char *msgType, bool ok, const char *errText);
/* ---- Trigger (push loop side) ----------------------------------------- */ /* ---- Trigger (push loop side) ----------------------------------------- */
/** /**
@@ -120,12 +152,40 @@ private:
void BroadcastTriggerState(); void BroadcastTriggerState();
/** /**
* @brief Build and broadcast the version=2 binary capture frame: * @brief Size every ring so it retains the current trigger window.
* [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig] * Called from the push loop once per stats tick; a no-op once the rings
* {[u16 keyLen][fullKey][u32 N][t f64×N][v f64×N]} * are large enough. Rates are measured from the rings themselves because
* most sources advertise samplingRate = 0.
*/ */
void BroadcastTriggerCapture(float64 trigTime, float64 preSec, void GrowRingsForTrigger();
float64 postSec);
/** @return Largest ring capacity currently allocated across all sessions. */
uint32 CurrentMaxRingCapacity() const;
/**
* @brief How far source @p i has produced, in the trigger's time base;
* @p wallNowS when it publishes no producer clock (its samples are then
* stamped on arrival, so they share the hub's wall clock).
*/
float64 SourceFrontierTime(uint32 i, float64 wallNowS) const;
/* ---- Trigger capture assembly ---------------------------------------
* Sources are harvested one at a time, each as soon as *it* has produced
* past the end of the window, rather than all together once the slowest
* has. Sources free-run on their own clocks and can lag each other by
* seconds; making every source wait for the slowest lets the leaders' ring
* buffers roll past the pre-trigger region before it is ever read. */
/** @brief Start a version=2 capture frame:
* [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig]. */
void BeginTriggerCapture(float64 trigTime, float64 preSec, float64 postSec);
/** @brief Append session @p i's signals to the pending frame, each as
* {[u16 keyLen][fullKey][u32 N][t f64×N][v f64×N]}. */
void HarvestTriggerCapture(uint32 i, float64 t0, float64 t1);
/** @brief Patch nSig, broadcast the pending frame and release it. */
void FinishTriggerCapture();
/* ---- Command handlers (called from OnWSCommand) ---------------------- */ /* ---- Command handlers (called from OnWSCommand) ---------------------- */
@@ -146,6 +206,8 @@ private:
void HandleHistoryInfo(uint32 slotIdx); void HandleHistoryInfo(uint32 slotIdx);
void HandleSetMaxPoints(const char *json); void HandleSetMaxPoints(const char *json);
void HandlePing(uint32 slotIdx); void HandlePing(uint32 slotIdx);
void HandleSetCalibration(const char *json);
void HandleReloadConfig();
/* ---- Binary recorder commands --------------------------------------- */ /* ---- Binary recorder commands --------------------------------------- */
@@ -172,10 +234,32 @@ private:
const char *mcGroup, uint16 dataPort); const char *mcGroup, uint16 dataPort);
/** /**
* @brief Load sources from sourcesFile_ (JSON array of * @brief Load sources and calibration from sourcesFile_ (a flat JSON array
* {"label","addr","multicastGroup","dataPort"}) and start them. * of {"label","addr","multicastGroup","dataPort"} source blocks and
* {"source","signal","scale","offset","unit"} calibration blocks).
* @param skipActive when true, a source whose "host:port" is already
* streaming is left alone instead of being started a second time.
* @param clearCalibration when true, the calibration table is cleared
* after a successful fread (never before), so a transient I/O failure
* does not silently wipe user calibration data.
* @return true if the file was read.
*/ */
void LoadSourcesFile(); bool LoadSourcesFile(bool skipActive, bool clearCalibration = false);
/** @return true if a session for this "host:port" is already active. */
bool SourceIsActive(const char *addrPort);
/**
* @brief Store or replace one calibration entry. An identity entry
* (scale 1, offset 0, empty unit) removes any stored one instead.
* @return true if the entry was valid (and therefore stored or removed).
*/
bool SetCalibrationEntry(const char *source, const char *signal,
MARTe::float64 scale, MARTe::float64 offset,
const char *unit);
/** Drop every calibration entry (used by reload, which replaces wholesale). */
void ClearCalibration();
/* ---- Tiny JSON helpers ----------------------------------------------- */ /* ---- Tiny JSON helpers ----------------------------------------------- */
@@ -215,11 +299,17 @@ private:
uint32 pushRateHz_; uint32 pushRateHz_;
uint32 maxPushPoints_; uint32 maxPushPoints_;
uint32 statsRateHz_; uint32 statsRateHz_;
uint32 ringTemporal_; ///< Ring capacity for multi-element (waveform) signals uint32 ringTemporal_; ///< Initial ring capacity for multi-element (waveform) signals
uint32 ringScalar_; ///< Ring capacity for scalar signals uint32 ringScalar_; ///< Ring capacity for scalar signals
uint32 ringMaxPts_; ///< Ceiling a ring may be grown to for a trigger window
volatile float64 trigRetentionSec_; ///< Retention the current trigger window needs
StreamString sourcesFile_; ///< Persistent dynamic source list (JSON) StreamString sourcesFile_; ///< Persistent dynamic source list (JSON)
uint32 nextSourceId_; ///< Counter for generated session ids ("sN") uint32 nextSourceId_; ///< Counter for generated session ids ("sN")
CalibrationEntry *calibration_; ///< Heap-allocated array[kMaxCalibration]
uint32 numCalibration_;
FastPollingMutexSem calibrationMutex_; ///< Serializes calibration reads/writes
/* Push loop state */ /* Push loop state */
volatile bool running_; volatile bool running_;
uint32 tickCount_; ///< incremented each push tick uint32 tickCount_; ///< incremented each push tick
@@ -232,7 +322,9 @@ private:
static const uint32 kPushBufSize = 8u * 1024u * 1024u; static const uint32 kPushBufSize = 8u * 1024u * 1024u;
uint8 *pushBuf_; uint8 *pushBuf_;
/* Decimated output scratch (LTTB): maxPushPoints × 2 arrays per signal */ /* Decimated output scratch (LTTB). Sized like the read scratch rather
* than maxPushPoints_: a PACKET-timed array raises its own threshold to
* one packet's worth of elements, which can exceed maxPushPoints_. */
float64 *lttbT_; float64 *lttbT_;
float64 *lttbV_; float64 *lttbV_;
@@ -251,6 +343,14 @@ private:
TrigState lastTrigState_; ///< Last broadcast FSM state TrigState lastTrigState_; ///< Last broadcast FSM state
bool rearmPending_; ///< Normal-mode auto-rearm scheduled bool rearmPending_; ///< Normal-mode auto-rearm scheduled
float64 rearmAtWallS_; ///< Wall time of the scheduled auto-rearm float64 rearmAtWallS_; ///< Wall time of the scheduled auto-rearm
float64 collectStartWallS_; ///< Wall time COLLECTING began (watchdog only)
/* Capture frame under assembly across ticks (push thread only) */
MARTe::uint8 *capBuf_; ///< Pending frame, NULL when idle
uint32 capCap_; ///< Allocated size of capBuf_
uint32 capOff_; ///< Bytes written so far
uint32 capNSig_; ///< Signals appended so far
bool capHarvested_[kMaxSessions]; ///< Session already appended
}; };
} /* namespace StreamHub */ } /* namespace StreamHub */
@@ -27,9 +27,16 @@ void TriggerEngine::SetConfig(const TriggerConfig &cfg) {
config_ = cfg; config_ = cfg;
/* Clamp to web UI bounds */ /* Clamp to web UI bounds */
if (config_.windowSec < 1.0e-4) { config_.windowSec = 1.0e-4; } if (config_.windowSec < 1.0e-4) { config_.windowSec = 1.0e-4; }
if (config_.windowSec > 10.0) { config_.windowSec = 10.0; } /* 60 s where the Go hub allows 600. Deliberate: these rings are
* fixed-capacity and store every sample, so a window they cannot hold is
* harvested truncated and silently decimated to kTrigCapturePts. The Go
* hub stores min/max pairs instead once a window outgrows its budget, so
* there a long window costs resolution rather than coverage. */
if (config_.windowSec > 60.0) { config_.windowSec = 60.0; }
if (config_.prePercent < 0.0) { config_.prePercent = 0.0; } if (config_.prePercent < 0.0) { config_.prePercent = 0.0; }
if (config_.prePercent > 100.0) { config_.prePercent = 100.0; } if (config_.prePercent > 100.0) { config_.prePercent = 100.0; }
if (config_.holdoffSec < 0.0) { config_.holdoffSec = 0.0; }
if (config_.holdoffSec > 60.0) { config_.holdoffSec = 60.0; }
epoch_++; epoch_++;
prevValid_ = false; prevValid_ = false;
prevValue_ = 0.0; prevValue_ = 0.0;
@@ -62,9 +62,10 @@ struct TriggerConfig {
StreamString signalKey; ///< Full key: "src:sig" or "src:sig[i]" StreamString signalKey; ///< Full key: "src:sig" or "src:sig[i]"
TrigEdge edge; ///< Rising / falling / both TrigEdge edge; ///< Rising / falling / both
float64 threshold; ///< Trigger threshold (physical units) float64 threshold; ///< Trigger threshold (physical units)
float64 windowSec; ///< Capture window length [1e-4 .. 10] s float64 windowSec; ///< Capture window length [1e-4 .. 60] s
float64 prePercent; ///< Pre-trigger part of the window [0 .. 100] % float64 prePercent; ///< Pre-trigger part of the window [0 .. 100] %
TrigAcqMode mode; ///< Normal (auto-rearm) or single TrigAcqMode mode; ///< Normal (auto-rearm) or single
float64 holdoffSec; ///< Rearm delay after a capture [0 .. 60] s
}; };
/** /**
@@ -149,7 +150,8 @@ inline TriggerConfig::TriggerConfig()
threshold(0.0), threshold(0.0),
windowSec(1.0), windowSec(1.0),
prePercent(20.0), prePercent(20.0),
mode(kTrigNormal) { mode(kTrigNormal),
holdoffSec(0.2) {
} }
} /* namespace StreamHub */ } /* namespace StreamHub */
@@ -295,6 +295,83 @@ void UDPSourceSession::AllocateRingBuffers() {
} }
} }
bool UDPSourceSession::GrowRingsForSeconds(float64 seconds, uint32 maxPts) {
if ((seconds <= 0.0) || (maxPts == 0u)) { return false; }
(void) metaMutex_.FastLock();
const uint32 nSigs = numSignals_;
metaMutex_.FastUnLock();
bool grew = false;
for (uint32 i = 0u; i < nSigs; i++) {
const uint32 count = rings_[i].Count();
const float64 span = rings_[i].TimeSpan();
/* Need a decent sample of the stream before extrapolating a rate;
* a couple of packets' worth of span is enough at any rate. */
if ((count < 2u) || (span <= 0.0)) { continue; }
const float64 rate = static_cast<float64>(count) / span;
/* 20 % headroom absorbs rate jitter and the capture margin. */
float64 need = rate * seconds * 1.2;
if (need > static_cast<float64>(maxPts)) {
need = static_cast<float64>(maxPts);
}
const uint32 needPts = static_cast<uint32>(need);
if (needPts > rings_[i].Capacity()) {
if (rings_[i].Grow(needPts)) { grew = true; }
}
}
return grew;
}
uint32 UDPSourceSession::GetMaxRingCapacity() const {
(void) metaMutex_.FastLock();
const uint32 nSigs = numSignals_;
metaMutex_.FastUnLock();
uint32 maxCap = 0u;
for (uint32 i = 0u; i < nSigs; i++) {
const uint32 c = rings_[i].Capacity();
if (c > maxCap) { maxCap = c; }
}
return maxCap;
}
float64 UDPSourceSession::ProducerNewestTime() const {
/* Mirror exactly the ParseDataPayload branches that timestamp from the
* referenced time signal; every other branch stamps on arrival and so
* would report "now" in the hub's clock, not the producer's. The time
* signal itself is one of those it is PACKET-timed. */
(void) metaMutex_.FastLock();
const uint32 nSigs = numSignals_;
bool producerTimed[UDPSS_MAX_SIGNALS];
for (uint32 i = 0u; i < nSigs; i++) {
const UDPSSignalDescriptor &d = sigDescs_[i];
uint64 ne = static_cast<uint64>(d.numRows) *
static_cast<uint64>(d.numCols);
if (ne == 0u) { ne = 1u; }
const bool hasTimeSig = (d.timeSignalIdx != UDPS_NO_TIME_SIGNAL) &&
(d.timeSignalIdx < nSigs);
const bool isFirstLast = (ne > 1u) &&
((d.timeMode == UDPS_TIMEMODE_FIRST_SAMPLE) ||
(d.timeMode == UDPS_TIMEMODE_LAST_SAMPLE));
const bool isFullArray = (d.timeMode == UDPS_TIMEMODE_FULL_ARRAY);
producerTimed[i] = hasTimeSig && (isFullArray || isFirstLast);
}
metaMutex_.FastUnLock();
/* Signals of one source share a packet, so they advance together; the max
* is "how far this source has produced" without stalling on a signal that
* simply is not being sent. */
float64 newest = 0.0;
for (uint32 i = 0u; i < nSigs; i++) {
if (!producerTimed[i]) { continue; }
const float64 t = rings_[i].NewestTime();
if (t > newest) { newest = t; }
}
return newest;
}
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
/* DATA parsing */ /* DATA parsing */
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
@@ -154,6 +154,37 @@ public:
*/ */
void SetRingCapacities(uint32 temporal, uint32 scalar); void SetRingCapacities(uint32 temporal, uint32 scalar);
/**
* @brief Grow every ring so it can retain at least @p seconds of history.
*
* The required capacity is seconds × the rate measured from the ring
* itself (count / time span), because most sources advertise
* samplingRate = 0. Signals whose ring has not filled enough to measure a
* rate are left alone; the caller is expected to retry.
*
* @param seconds Retention target.
* @param maxPts Per-signal ceiling, so a multi-Msps source cannot be
* asked to allocate an unbounded amount of memory.
* @return true if at least one ring was enlarged.
*/
bool GrowRingsForSeconds(float64 seconds, uint32 maxPts);
/** @return Largest ring capacity currently allocated in this session. */
uint32 GetMaxRingCapacity() const;
/**
* @brief Newest timestamp this source has produced on its *own* clock, or
* 0 when it publishes no producer-timed signal (or has no data yet).
*
* Only signals that reference a time signal count: PACKET-timed signals
* are stamped on arrival and so live in the hub's wall-clock domain, not
* the producer's, even when they come from the very same source. A source
* free-running on its own clock sits seconds away from wall time and drifts,
* so anything waiting for a capture window to fill must compare against
* this, never clock_gettime().
*/
float64 ProducerNewestTime() const;
/** /**
* @brief Attach the (shared) hub trigger engine. * @brief Attach the (shared) hub trigger engine.
* Every decoded sample of the trigger's configured signal resolved * Every decoded sample of the trigger's configured signal resolved
@@ -241,24 +272,42 @@ private:
* signal @p tIdx given the first decoded timer value @p timer0S of the * signal @p tIdx given the first decoded timer value @p timer0S of the
* current packet and the arrival wall time @p wallNowS. * current packet and the arrival wall time @p wallNowS.
* *
* Re-anchors the offset (offset = wallNowS timer0S) when (a) it is the * Snaps the offset to wallNowS timer0S only when there is a genuine
* first packet, (b) the source clock jumped backward versus the previous * discontinuity in the source: the first packet, or a backward jump of the
* packet (a looping/rewinding producer), or (c) the computed wall time has * source clock (a looping/rewinding producer).
* drifted past kRecalibThresholdS from the true arrival wall time. *
* A source that free-runs on its own clock also *drifts* against wall time,
* without any discontinuity. Snapping that away would shift the whole
* published timeline in one step and so tear a hole of exactly the drift
* into a stream that is in fact continuous, which is worse than the drift
* itself. Past kRecalibThresholdS the offset is therefore slewed instead:
* nudged towards wall time by at most kMaxSlewFraction of the packet's own
* duration, so the seam can never exceed a fraction of one packet.
*
* @return the calibration offset to add to timer-seconds for this signal. * @return the calibration offset to add to timer-seconds for this signal.
*/ */
inline float64 CalibrateTimeSignal(uint32 tIdx, float64 timer0S, inline float64 CalibrateTimeSignal(uint32 tIdx, float64 timer0S,
float64 wallNowS) { float64 wallNowS) {
static const float64 kRecalibThresholdS = 2.0; static const float64 kRecalibThresholdS = 2.0;
static const float64 kMaxSlewFraction = 0.1;
const bool reset = timeSigLastValid_[tIdx] && const bool reset = timeSigLastValid_[tIdx] &&
(timer0S < timeSigLastTimerS_[tIdx]); (timer0S < timeSigLastTimerS_[tIdx]);
const float64 drift = (timeSigCalib_[tIdx] + timer0S) - wallNowS; if ((!timeSigCalibValid_[tIdx]) || reset) {
const float64 absDrift = (drift < 0.0) ? -drift : drift;
if ((!timeSigCalibValid_[tIdx]) || reset ||
(absDrift > kRecalibThresholdS)) {
timeSigCalib_[tIdx] = wallNowS - timer0S; timeSigCalib_[tIdx] = wallNowS - timer0S;
timeSigCalibValid_[tIdx] = true; timeSigCalibValid_[tIdx] = true;
} }
else {
const float64 drift = (timeSigCalib_[tIdx] + timer0S) - wallNowS;
const float64 absDrift = (drift < 0.0) ? -drift : drift;
if (absDrift > kRecalibThresholdS) {
const float64 pktSpan = timer0S - timeSigLastTimerS_[tIdx];
const float64 maxStep = pktSpan * kMaxSlewFraction;
float64 step = -drift;
if (step > maxStep) { step = maxStep; }
if (step < -maxStep) { step = -maxStep; }
timeSigCalib_[tIdx] += step;
}
}
timeSigLastTimerS_[tIdx] = timer0S; timeSigLastTimerS_[tIdx] = timer0S;
timeSigLastValid_[tIdx] = true; timeSigLastValid_[tIdx] = true;
return timeSigCalib_[tIdx]; return timeSigCalib_[tIdx];
+79 -18
View File
@@ -8,6 +8,7 @@
#include "SHA1.h" #include "SHA1.h"
#include "Base64.h" #include "Base64.h"
#include "AdvancedErrorManagement.h" #include "AdvancedErrorManagement.h"
#include "Select.h"
#include "Sleep.h" #include "Sleep.h"
#include "Threads.h" #include "Threads.h"
#include "TimeoutType.h" #include "TimeoutType.h"
@@ -57,8 +58,10 @@ static const char *FindSubstr(const char *s, const char *pattern) {
WSServer::WSServer() WSServer::WSServer()
: numClients(0u), : numClients(0u),
liveReadThreads(0u),
callback(static_cast<WSCommandCallback *>(0)), callback(static_cast<WSCommandCallback *>(0)),
running(false), running(false),
numAllowedOrigins(0u),
acceptTid(MARTe::InvalidThreadIdentifier) { acceptTid(MARTe::InvalidThreadIdentifier) {
for (uint32 i = 0u; i < WS_MAX_CLIENTS; i++) { for (uint32 i = 0u; i < WS_MAX_CLIENTS; i++) {
@@ -66,6 +69,20 @@ WSServer::WSServer()
clients[i].active = false; clients[i].active = false;
clients[i].readTid = MARTe::InvalidThreadIdentifier; clients[i].readTid = MARTe::InvalidThreadIdentifier;
} }
for (uint32 i = 0u; i < WS_MAX_ORIGINS; i++) {
allowedOrigins[i][0] = '\0';
}
}
bool WSServer::AddAllowedOrigin(const char *origin) {
if ((origin == static_cast<const char *>(0)) || (origin[0] == '\0')) {
return false;
}
if (numAllowedOrigins >= WS_MAX_ORIGINS) { return false; }
if (strlen(origin) >= WS_MAX_ORIGIN_LEN) { return false; }
strcpy(allowedOrigins[numAllowedOrigins], origin);
numAllowedOrigins++;
return true;
} }
WSServer::~WSServer() { WSServer::~WSServer() {
@@ -104,9 +121,9 @@ bool WSServer::Start(uint16 port, WSCommandCallback *cb) {
bool WSServer::Stop() { bool WSServer::Stop() {
if (!running) { return true; } if (!running) { return true; }
running = false; running = false;
Sleep::MSec(200u);
/* Close all client connections — their read threads will exit on error */ /* Close all client connections — their read threads wake out of select()
* and unwind through FreeSlot. */
(void) clientsMutex.FastLock(); (void) clientsMutex.FastLock();
for (uint32 i = 0u; i < WS_MAX_CLIENTS; i++) { for (uint32 i = 0u; i < WS_MAX_CLIENTS; i++) {
if (clients[i].active && (clients[i].sock != static_cast<BasicTCPSocket *>(0))) { if (clients[i].active && (clients[i].sock != static_cast<BasicTCPSocket *>(0))) {
@@ -114,10 +131,23 @@ bool WSServer::Stop() {
} }
} }
clientsMutex.FastUnLock(); clientsMutex.FastUnLock();
Sleep::MSec(200u);
/* The accept loop polls WaitConnection with a 500 ms timeout, so it is out
* of the listener by now. */
Sleep::MSec(600u);
tcpListener.Close(); tcpListener.Close();
Sleep::MSec(100u);
/* Wait for the read threads: they hold pointers to the sockets freed
* below. Bounded leaking a socket at exit beats deleting one that a
* wedged thread is still reading from. */
static const uint32 kReadJoinMs = 3000u;
for (uint32 waited = 0u; waited < kReadJoinMs; waited += 20u) {
(void) clientsMutex.FastLock();
const uint32 live = liveReadThreads;
clientsMutex.FastUnLock();
if (live == 0u) { break; }
Sleep::MSec(20u);
}
/* Free any remaining slots */ /* Free any remaining slots */
(void) clientsMutex.FastLock(); (void) clientsMutex.FastLock();
@@ -170,6 +200,10 @@ void WSServer::AcceptLoop() {
} }
/* Start per-client read thread */ /* Start per-client read thread */
(void) clientsMutex.FastLock();
liveReadThreads++;
clientsMutex.FastUnLock();
ClientThreadArg *arg = new ClientThreadArg(); ClientThreadArg *arg = new ClientThreadArg();
arg->srv = this; arg->srv = this;
arg->slot = slot; arg->slot = slot;
@@ -200,12 +234,28 @@ bool WSServer::UpgradeHTTP(BasicTCPSocket *sock) {
} }
/* Origin validation (CSWSH / CSRF defence, RFC 6455 §10.2). /* Origin validation (CSWSH / CSRF defence, RFC 6455 §10.2).
* If an Origin header is present, its host must match the Host header * If an Origin header is present it must either be on the configured
* (same-origin). Non-browser clients (no Origin) are allowed. */ * allowlist or its host must match the Host header (same-origin).
* Non-browser clients (no Origin) are allowed. */
const char *originHdr = FindSubstr(hdrBuf, "Origin:"); const char *originHdr = FindSubstr(hdrBuf, "Origin:");
if (originHdr != static_cast<const char *>(0)) { if (originHdr != static_cast<const char *>(0)) {
originHdr += 7; /* skip "Origin:" */ originHdr += 7; /* skip "Origin:" */
while (*originHdr == ' ') { originHdr++; } while (*originHdr == ' ') { originHdr++; }
/* Full origin value "scheme://host[:port]", for the allowlist. */
char originFull[WS_MAX_ORIGIN_LEN];
uint32 ofLen = 0u;
while ((originHdr[ofLen] != '\r') && (originHdr[ofLen] != '\n') &&
(originHdr[ofLen] != '\0') && (ofLen < (WS_MAX_ORIGIN_LEN - 1u))) {
originFull[ofLen] = originHdr[ofLen];
ofLen++;
}
originFull[ofLen] = '\0';
bool allowed = false;
for (uint32 i = 0u; (i < numAllowedOrigins) && !allowed; i++) {
if (strcmp(originFull, allowedOrigins[i]) == 0) { allowed = true; }
}
/* Extract the host part of Origin: "scheme://host[:port]" */ /* Extract the host part of Origin: "scheme://host[:port]" */
char originHost[256]; char originHost[256];
uint32 ohLen = 0u; uint32 ohLen = 0u;
@@ -221,7 +271,7 @@ bool WSServer::UpgradeHTTP(BasicTCPSocket *sock) {
/* Extract Host header value */ /* Extract Host header value */
const char *hostHdr = FindSubstr(hdrBuf, "Host:"); const char *hostHdr = FindSubstr(hdrBuf, "Host:");
if (hostHdr != static_cast<const char *>(0)) { if (!allowed && (hostHdr != static_cast<const char *>(0))) {
hostHdr += 5; /* skip "Host:" */ hostHdr += 5; /* skip "Host:" */
while (*hostHdr == ' ') { hostHdr++; } while (*hostHdr == ' ') { hostHdr++; }
char hostVal[256]; char hostVal[256];
@@ -299,23 +349,30 @@ void WSServer::ClientReadLoop(uint32 slotIdx) {
uint32 filled = 0u; uint32 filled = 0u;
while (running && slot.active) { while (running && slot.active) {
/* Read more bytes (with short timeout so we can check running) */
uint32 want = kRecvBuf - filled; uint32 want = kRecvBuf - filled;
if (want == 0u) { if (want == 0u) {
/* Buffer full — discard old frame (shouldn't happen with reasonable clients) */ /* Buffer full — discard old frame (shouldn't happen with reasonable clients) */
filled = 0u; filled = 0u;
continue; continue;
} }
bool ok = sock->Read(reinterpret_cast<char *>(buf + filled), want,
TimeoutType(500u)); /* Wait for readability before reading. BasicTCPSocket::Read reports a
if (!ok) { * timeout and a closed peer identically (false, zero bytes), so polling
/* Timeout or error — check running and retry */ * it on its own cannot end the loop: once the client goes away recv
if (!running) { break; } * returns immediately and forever, and the thread spins at 100% CPU
if (want == kRecvBuf) { * until it starves the rest of the hub. select() tells the two apart
/* Zero bytes read — connection likely closed */ * readable followed by no data is end of stream. A wait consumes the
break; * handle set, hence a fresh Select each pass. */
} MARTe::Select sel;
continue; if (!sel.AddReadHandle(*sock)) { break; }
const MARTe::int32 ready = sel.WaitUntil(TimeoutType(500u));
if (ready == 0) { continue; } /* idle client — recheck running */
if (ready < 0) { break; } /* socket closed or errored */
/* Readable: this returns at once, and only fails at end of stream. */
if (!sock->Read(reinterpret_cast<char *>(buf + filled), want,
TimeoutType(500u))) {
break;
} }
filled += want; filled += want;
@@ -383,6 +440,10 @@ client_done:
callback->OnWSClientDisconnected(); callback->OnWSClientDisconnected();
} }
FreeSlot(slotIdx); FreeSlot(slotIdx);
(void) clientsMutex.FastLock();
if (liveReadThreads > 0u) { liveReadThreads--; }
clientsMutex.FastUnLock();
} }
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
+25 -1
View File
@@ -34,6 +34,12 @@ static const uint32 WS_MAX_RECV_PAYLOAD = 65536u;
/** Maximum WebSocket frame payload we will send (data frames can be large). */ /** Maximum WebSocket frame payload we will send (data frames can be large). */
static const uint32 WS_MAX_SEND_PAYLOAD = 4u * 1024u * 1024u; /* 4 MiB */ static const uint32 WS_MAX_SEND_PAYLOAD = 4u * 1024u * 1024u; /* 4 MiB */
/** Maximum entries in the Origin allowlist. */
static const uint32 WS_MAX_ORIGINS = 8u;
/** Maximum length of one allowlisted Origin ("scheme://host[:port]"). */
static const uint32 WS_MAX_ORIGIN_LEN = 128u;
/** /**
* @brief Callback interface implemented by StreamHub. * @brief Callback interface implemented by StreamHub.
*/ */
@@ -77,6 +83,20 @@ public:
*/ */
bool Start(uint16 port, WSCommandCallback *cb); bool Start(uint16 port, WSCommandCallback *cb);
/**
* @brief Add an Origin that is accepted for the WebSocket upgrade.
*
* With an empty allowlist (the default) only same-origin requests pass:
* the Origin's host must equal the Host header, which excludes the usual
* deployment where the SPA is served by a separate web server on another
* port. Add that server's origin (e.g. "http://localhost:8080") to allow
* it. Requests without an Origin header (non-browser clients) always pass.
*
* @param origin "scheme://host[:port]", compared verbatim.
* @return false if the allowlist is full or the string is too long.
*/
bool AddAllowedOrigin(const char *origin);
/** /**
* @brief Stop accept thread; close all client connections; close listener. * @brief Stop accept thread; close all client connections; close listener.
*/ */
@@ -119,11 +139,15 @@ private:
BasicTCPSocket tcpListener; BasicTCPSocket tcpListener;
WSClientSlot clients[WS_MAX_CLIENTS]; WSClientSlot clients[WS_MAX_CLIENTS];
uint32 numClients; uint32 numClients;
mutable FastPollingMutexSem clientsMutex; ///< Protects numClients and clients[] array uint32 liveReadThreads; ///< Read threads not yet unwound; Stop() waits on it
mutable FastPollingMutexSem clientsMutex; ///< Protects numClients, liveReadThreads and clients[] array
WSCommandCallback *callback; WSCommandCallback *callback;
volatile bool running; volatile bool running;
char allowedOrigins[WS_MAX_ORIGINS][WS_MAX_ORIGIN_LEN];
uint32 numAllowedOrigins;
MARTe::ThreadIdentifier acceptTid; MARTe::ThreadIdentifier acceptTid;
}; };
@@ -244,10 +244,14 @@ bool UDPStreamerClient::Initialise(StructuredDataI &data) {
dp = port + UDPS_CLIENT_DEFAULT_DP_OFFSET; dp = port + UDPS_CLIENT_DEFAULT_DP_OFFSET;
} }
dataPort = dp; dataPort = dp;
StreamString ifaceStr = "";
(void) data.Read("Interface", ifaceStr);
multicastInterface = ifaceStr;
REPORT_ERROR(ErrorManagement::Information, REPORT_ERROR(ErrorManagement::Information,
"Multicast mode: group=%s, server=%s, controlPort=%u, dataPort=%u.", "Multicast mode: group=%s, server=%s, controlPort=%u, dataPort=%u, interface=%s.",
multicastGroup.Buffer(), serverAddress.Buffer(), multicastGroup.Buffer(), serverAddress.Buffer(),
static_cast<uint32>(port), static_cast<uint32>(dataPort)); static_cast<uint32>(port), static_cast<uint32>(dataPort),
(multicastInterface.Size() > 0u) ? multicastInterface.Buffer() : "default");
} }
else { else {
useMulticast = false; useMulticast = false;
@@ -263,6 +267,9 @@ bool UDPStreamerClient::Initialise(StructuredDataI &data) {
if (ok && useMulticast) { if (ok && useMulticast) {
ok = cdb.Write("MulticastGroup", multicastGroup); ok = cdb.Write("MulticastGroup", multicastGroup);
if (ok) { ok = cdb.Write("DataPort", static_cast<uint32>(dataPort)); } if (ok) { ok = cdb.Write("DataPort", static_cast<uint32>(dataPort)); }
if (ok && (multicastInterface.Size() > 0u)) {
ok = cdb.Write("Interface", multicastInterface);
}
} }
if (ok) { ok = cdb.Write("MaxPayloadSize", maxPayloadSize); } if (ok) { ok = cdb.Write("MaxPayloadSize", maxPayloadSize); }
if (ok) { ok = cdb.Write("KeepAliveInterval", keepAliveInterval); } if (ok) { ok = cdb.Write("KeepAliveInterval", keepAliveInterval); }
@@ -178,9 +178,10 @@ private:
float32 silenceTimeout; /**< Seconds of no data before reconnect (sub-second allowed, 0 disables). */ float32 silenceTimeout; /**< Seconds of no data before reconnect (sub-second allowed, 0 disables). */
uint32 cpuMask; /**< Background thread CPU affinity. */ uint32 cpuMask; /**< Background thread CPU affinity. */
uint32 stackSize; /**< Background thread stack size. */ uint32 stackSize; /**< Background thread stack size. */
StreamString multicastGroup; /**< Multicast group IP; empty = unicast. */ StreamString multicastGroup; /**< Multicast group IP; empty = unicast. */
uint16 dataPort; /**< UDP port for DATA datagrams (multicast). */ StreamString multicastInterface; /**< Local IPv4 address for multicast join; empty = INADDR_ANY. */
bool useMulticast; /**< True when MulticastGroup is set. */ uint16 dataPort; /**< UDP port for DATA datagrams (multicast). */
bool useMulticast; /**< True when MulticastGroup is set. */
/* Signal metadata */ /* Signal metadata */
uint32 numSigs; /**< Number of signals. */ uint32 numSigs; /**< Number of signals. */
@@ -16,6 +16,10 @@ TimeArrayGAM::TimeArrayGAM() :
GAM(), GAM(),
samplingRate(1000000.0), samplingRate(1000000.0),
anchorIsFirst(true), anchorIsFirst(true),
anchorIsCont(false),
contStarted(false),
contOriginNs(0u),
contSamples(0u),
nElements(0u), nElements(0u),
inputTime(NULL_PTR(uint32 *)), inputTime(NULL_PTR(uint32 *)),
outputBuf(NULL_PTR(uint64 *)) { outputBuf(NULL_PTR(uint64 *)) {
@@ -42,9 +46,12 @@ bool TimeArrayGAM::Initialise(StructuredDataI &data) {
else if (anchor == "LastSample") { else if (anchor == "LastSample") {
anchorIsFirst = false; anchorIsFirst = false;
} }
else if (anchor == "Continuous") {
anchorIsCont = true;
}
else { else {
REPORT_ERROR(ErrorManagement::InitialisationError, REPORT_ERROR(ErrorManagement::InitialisationError,
"TimeArrayGAM: Anchor must be 'FirstSample' or 'LastSample'."); "TimeArrayGAM: Anchor must be 'FirstSample', 'LastSample' or 'Continuous'.");
ok = false; ok = false;
} }
} }
@@ -88,7 +95,21 @@ bool TimeArrayGAM::Execute() {
/* Input is uint32 microseconds (LinuxTimer); convert to nanoseconds. */ /* Input is uint32 microseconds (LinuxTimer); convert to nanoseconds. */
uint64 anchorNs = static_cast<uint64>(*inputTime) * 1000u; uint64 anchorNs = static_cast<uint64>(*inputTime) * 1000u;
if (anchorIsFirst) { if (anchorIsCont) {
/* Latch the timer once, then run off an internal sample counter so a
* lost RT cycle (LinuxTimer re-phases with counter += nCycles) cannot
* punch a hole into an otherwise contiguous sample stream. */
if (!contStarted) {
contOriginNs = anchorNs;
contStarted = true;
}
for (uint32 k = 0u; k < nElements; k++) {
outputBuf[k] = contOriginNs +
(contSamples + static_cast<uint64>(k)) * periodNs;
}
contSamples += static_cast<uint64>(nElements);
}
else if (anchorIsFirst) {
/* out[k] = anchorNs + k * periodNs */ /* out[k] = anchorNs + k * periodNs */
for (uint32 k = 0u; k < nElements; k++) { for (uint32 k = 0u; k < nElements; k++) {
outputBuf[k] = anchorNs + static_cast<uint64>(k) * periodNs; outputBuf[k] = anchorNs + static_cast<uint64>(k) * periodNs;
@@ -10,6 +10,15 @@
* *
* Anchor = FirstSample: out[k] = input + k * period_us * Anchor = FirstSample: out[k] = input + k * period_us
* Anchor = LastSample: out[k] = input - (N-1-k) * period_us * Anchor = LastSample: out[k] = input - (N-1-k) * period_us
* Anchor = Continuous: out[k] = input(first cycle) + (n + k) * period_us
*
* FirstSample/LastSample re-read the timer every cycle, so they propagate any
* cycle the RT thread loses: LinuxTimer re-phases (counter += nCycles) and the
* emitted time base jumps by a whole period while only one array of samples is
* produced, leaving a hole. Continuous anchors once and then advances an
* internal sample counter by N per cycle, which is what an acquisition card
* with its own clock does use it when the data signal is itself contiguous
* (SineArrayGAM, for instance, never skips phase on a lost cycle).
* *
* The resulting time array is suitable as the TimeSignal for a UDPStreamer signal * The resulting time array is suitable as the TimeSignal for a UDPStreamer signal
* configured with TimeMode = FullArray, providing exact per-sample timestamps. * configured with TimeMode = FullArray, providing exact per-sample timestamps.
@@ -19,7 +28,7 @@
* +TimeArrayGAM1 = { * +TimeArrayGAM1 = {
* Class = TimeArrayGAM * Class = TimeArrayGAM
* SamplingRate = 1000000.0 // Sample rate in Hz (must match data signal) * SamplingRate = 1000000.0 // Sample rate in Hz (must match data signal)
* Anchor = FirstSample // FirstSample (default) or LastSample * Anchor = FirstSample // FirstSample (default), LastSample or Continuous
* InputSignals = { * InputSignals = {
* Time = { DataSource = DDB; Type = uint32 } * Time = { DataSource = DDB; Type = uint32 }
* } * }
@@ -54,6 +63,10 @@ public:
private: private:
float64 samplingRate; /**< Sample rate [Hz] */ float64 samplingRate; /**< Sample rate [Hz] */
bool anchorIsFirst; /**< true = FirstSample anchor, false = LastSample */ bool anchorIsFirst; /**< true = FirstSample anchor, false = LastSample */
bool anchorIsCont; /**< true = Continuous anchor (internal sample counter) */
bool contStarted; /**< Continuous: origin has been latched */
uint64 contOriginNs; /**< Continuous: timer value latched on the first cycle */
uint64 contSamples; /**< Continuous: samples emitted so far */
uint32 nElements; /**< Number of output elements */ uint32 nElements; /**< Number of output elements */
uint32 *inputTime; /**< Pointer to scalar input (microseconds, uint32 from LinuxTimer) */ uint32 *inputTime; /**< Pointer to scalar input (microseconds, uint32 from LinuxTimer) */
uint64 *outputBuf; /**< Pointer to output array (nanoseconds, uint64) */ uint64 *outputBuf; /**< Pointer to output array (nanoseconds, uint64) */
@@ -85,6 +85,9 @@ bool UDPSClient::Initialise(StructuredDataI &data) {
uint32 dpU32 = static_cast<uint32>(serverPort) + 1u; uint32 dpU32 = static_cast<uint32>(serverPort) + 1u;
(void) data.Read("DataPort", dpU32); (void) data.Read("DataPort", dpU32);
dataPort = static_cast<uint16>(dpU32); dataPort = static_cast<uint16>(dpU32);
StreamString iface;
(void) data.Read("Interface", iface);
multicastInterface = iface;
} }
float32 silenceS = UDPS_CLIENT_DEFAULT_SILENCE_TIMEOUT_S; float32 silenceS = UDPS_CLIENT_DEFAULT_SILENCE_TIMEOUT_S;
@@ -311,7 +314,12 @@ bool UDPSClient::ConnectMulticast() {
return false; return false;
} }
ok = mcastSocket.Join(multicastGroup.Buffer()); if (multicastInterface.Size() > 0u) {
ok = mcastSocket.Join(multicastGroup.Buffer(), multicastInterface.Buffer());
}
else {
ok = mcastSocket.Join(multicastGroup.Buffer());
}
if (!ok) { if (!ok) {
REPORT_ERROR_STATIC(ErrorManagement::Warning, REPORT_ERROR_STATIC(ErrorManagement::Warning,
"UDPSClient: Could not join multicast group %s.", "UDPSClient: Could not join multicast group %s.",
@@ -320,8 +328,9 @@ bool UDPSClient::ConnectMulticast() {
return false; return false;
} }
REPORT_ERROR_STATIC(ErrorManagement::Information, REPORT_ERROR_STATIC(ErrorManagement::Information,
"UDPSClient: Joined multicast group %s on port %u.", "UDPSClient: Joined multicast group %s on port %u via interface %s.",
multicastGroup.Buffer(), static_cast<uint32>(dataPort)); multicastGroup.Buffer(), static_cast<uint32>(dataPort),
(multicastInterface.Size() > 0u) ? multicastInterface.Buffer() : "default");
// Now open the TCP control connection and announce ourselves // Now open the TCP control connection and announce ourselves
if (!tcpSocket.Open()) { if (!tcpSocket.Open()) {
@@ -116,7 +116,10 @@ public:
* - ServerAddr (char*) Server IPv4 address. Required. * - ServerAddr (char*) Server IPv4 address. Required.
* - Port (uint16) Server UDP port (unicast) or TCP listen port (multicast). Required. * - Port (uint16) Server UDP port (unicast) or TCP listen port (multicast). Required.
* - MulticastGroup (char*) IPv4 multicast address; presence enables multicast mode. * - MulticastGroup (char*) IPv4 multicast address; presence enables multicast mode.
* - Interface (char*) Network interface for multicast join (e.g. "lo"). Required when MulticastGroup is set. * - Interface (char*) Local IPv4 dotted-quad address (e.g. "127.0.0.1") of the interface on
* which to join the multicast group. Optional; omitting it uses the
* default-route interface (INADDR_ANY), which silently receives nothing
* if the server sends on a different interface.
* - DataPort (uint16) UDP multicast data port (defaults to Port+1). * - DataPort (uint16) UDP multicast data port (defaults to Port+1).
* - SilenceTimeout (float32) Seconds of no data before reconnect. Default 1.0. * - SilenceTimeout (float32) Seconds of no data before reconnect. Default 1.0.
* Sub-second values allowed; 0 disables the check. * Sub-second values allowed; 0 disables the check.
@@ -201,6 +204,7 @@ private:
StreamString serverAddr; StreamString serverAddr;
uint16 serverPort; uint16 serverPort;
StreamString multicastGroup; StreamString multicastGroup;
StreamString multicastInterface;
uint16 dataPort; uint16 dataPort;
bool useMulticast; bool useMulticast;
uint64 silenceTimeoutTicks; uint64 silenceTimeoutTicks;
@@ -121,7 +121,7 @@ TEST(TriggerEngineGTest, TestConfigClamping) {
TriggerEngine eng; TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 100.0, 150.0)); eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 100.0, 150.0));
TriggerConfig cfg = eng.GetConfig(); TriggerConfig cfg = eng.GetConfig();
EXPECT_DOUBLE_EQ(10.0, cfg.windowSec); EXPECT_DOUBLE_EQ(60.0, cfg.windowSec);
EXPECT_DOUBLE_EQ(100.0, cfg.prePercent); EXPECT_DOUBLE_EQ(100.0, cfg.prePercent);
eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 1.0e-6, -5.0)); eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 1.0e-6, -5.0));
@@ -1559,6 +1559,7 @@ bool UDPStreamerTest::TestInitialise_MulticastMode_Valid() {
ConfigurationDatabase cdb; ConfigurationDatabase cdb;
cdb.Write("Port", 44710u); cdb.Write("Port", 44710u);
cdb.Write("MulticastGroup", "239.0.0.1"); cdb.Write("MulticastGroup", "239.0.0.1");
cdb.Write("Interface", "127.0.0.1");
cdb.Write("DataPort", 44711u); cdb.Write("DataPort", 44711u);
cdb.CreateRelative("Signals"); cdb.CreateRelative("Signals");
cdb.MoveToRoot(); cdb.MoveToRoot();
@@ -1574,6 +1575,7 @@ bool UDPStreamerTest::TestInitialise_MulticastMode_DefaultDataPort() {
ConfigurationDatabase cdb; ConfigurationDatabase cdb;
cdb.Write("Port", 44712u); cdb.Write("Port", 44712u);
cdb.Write("MulticastGroup", "239.0.0.1"); cdb.Write("MulticastGroup", "239.0.0.1");
cdb.Write("Interface", "127.0.0.1");
/* DataPort intentionally omitted: should default to 44713 */ /* DataPort intentionally omitted: should default to 44713 */
cdb.CreateRelative("Signals"); cdb.CreateRelative("Signals");
cdb.MoveToRoot(); cdb.MoveToRoot();
@@ -1588,6 +1590,9 @@ bool UDPStreamerTest::TestInitialise_MulticastMode_InvalidDataPort() {
ConfigurationDatabase cdb; ConfigurationDatabase cdb;
cdb.Write("Port", 44714u); cdb.Write("Port", 44714u);
cdb.Write("MulticastGroup", "239.0.0.1"); cdb.Write("MulticastGroup", "239.0.0.1");
/* Interface is mandatory for multicast; supply it so the rejection below
* is provably caused by DataPort == Port and not by a missing Interface. */
cdb.Write("Interface", "127.0.0.1");
cdb.Write("DataPort", 44714u); /* same as Port — must be rejected */ cdb.Write("DataPort", 44714u); /* same as Port — must be rejected */
cdb.CreateRelative("Signals"); cdb.CreateRelative("Signals");
cdb.MoveToRoot(); cdb.MoveToRoot();
@@ -1618,6 +1623,7 @@ bool UDPStreamerTest::TestPrepareNextState_Multicast() {
" Class = UDPStreamer\n" " Class = UDPStreamer\n"
" Port = 44716\n" " Port = 44716\n"
" MulticastGroup = \"239.0.0.1\"\n" " MulticastGroup = \"239.0.0.1\"\n"
" Interface = \"127.0.0.1\"\n"
" DataPort = 44717\n" " DataPort = 44717\n"
" MaxPayloadSize = 1400\n" " MaxPayloadSize = 1400\n"
" Signals = {\n" " Signals = {\n"
@@ -1695,6 +1701,7 @@ bool UDPStreamerTest::TestExecute_MulticastConnectDataDisconnect() {
" Class = UDPStreamer\n" " Class = UDPStreamer\n"
" Port = 44720\n" " Port = 44720\n"
" MulticastGroup = \"239.0.0.1\"\n" " MulticastGroup = \"239.0.0.1\"\n"
" Interface = \"127.0.0.1\"\n"
" DataPort = 44721\n" " DataPort = 44721\n"
" MaxPayloadSize = 1400\n" " MaxPayloadSize = 1400\n"
" Signals = {\n" " Signals = {\n"
@@ -1786,7 +1793,12 @@ bool UDPStreamerTest::TestExecute_MulticastConnectDataDisconnect() {
ok = mcastReader.Listen(44721u); ok = mcastReader.Listen(44721u);
} }
if (ok) { if (ok) {
ok = mcastReader.Join("239.0.0.1"); /* Join on the same interface the streamer sends from (Interface =
* 127.0.0.1 sets IP_MULTICAST_IF on the server's data socket). The
* single-argument Join() would pass INADDR_ANY, letting the kernel
* pick the default-route interface, and the datagram would never
* reach this socket. */
ok = mcastReader.Join("239.0.0.1", "127.0.0.1");
} }
/* Step 4: Trigger Synchronise() to generate a DATA packet */ /* Step 4: Trigger Synchronise() to generate a DATA packet */
@@ -145,3 +145,8 @@ TEST(UDPStreamerClientGTest, TestExecute_ConnectConfigDataEndToEnd) {
UDPStreamerClientTest test; UDPStreamerClientTest test;
ASSERT_TRUE(test.TestExecute_ConnectConfigDataEndToEnd()); ASSERT_TRUE(test.TestExecute_ConnectConfigDataEndToEnd());
} }
TEST(UDPStreamerClientGTest, TestExecute_MulticastReceivesDataOnInterface) {
UDPStreamerClientTest test;
ASSERT_TRUE(test.TestExecute_MulticastReceivesDataOnInterface());
}
@@ -26,11 +26,14 @@
/* Standard header includes */ /* Standard header includes */
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
#include <string.h> #include <string.h>
#include <netinet/in.h>
#include <sys/socket.h>
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
/* Project header includes */ /* Project header includes */
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
#include "AdvancedErrorManagement.h" #include "AdvancedErrorManagement.h"
#include "BasicTCPSocket.h"
#include "BasicUDPSocket.h" #include "BasicUDPSocket.h"
#include "ConfigurationDatabase.h" #include "ConfigurationDatabase.h"
#include "GAM.h" #include "GAM.h"
@@ -1491,3 +1494,183 @@ bool UDPStreamerClientTest::TestExecute_ConnectConfigDataEndToEnd() {
ObjectRegistryDatabase::Instance()->Purge(); ObjectRegistryDatabase::Instance()->Purge();
return ok; return ok;
} }
bool UDPStreamerClientTest::TestExecute_MulticastReceivesDataOnInterface() {
using namespace MARTe;
static const uint16 controlPort = 44730u;
static const uint16 dataPort = 44731u;
static const char8 *const mcGroup = "239.0.0.7";
static const char8 *const mcIface = "127.0.0.1";
static const char8 *const cfg =
"+Test = {\n"
" Class = RealTimeApplication\n"
" +Functions = {\n"
" Class = ReferenceContainer\n"
" +Reader = {\n"
" Class = UDPStreamerClientTestGAM\n"
" InputSignals = {\n"
" Counter = { DataSource = ClientDS Type = uint32 }\n"
" }\n"
" OutputSignals = {\n"
" Counter = { DataSource = DDB Type = uint32 }\n"
" }\n"
" }\n"
" }\n"
" +Data = {\n"
" Class = ReferenceContainer\n"
" DefaultDataSource = DDB\n"
" +DDB = { Class = GAMDataSource }\n"
" +ClientDS = {\n"
" Class = UDPStreamerClient\n"
" ServerAddress = \"127.0.0.1\"\n"
" Port = 44730\n"
" MulticastGroup = \"239.0.0.7\"\n"
" DataPort = 44731\n"
" Interface = \"127.0.0.1\"\n"
" MaxPayloadSize = 1400\n"
" Signals = {\n"
" Counter = { Type = uint32 }\n"
" }\n"
" }\n"
" +Timings = { Class = TimingDataSource }\n"
" }\n"
" +States = {\n"
" Class = ReferenceContainer\n"
" +State1 = {\n"
" Class = RealTimeState\n"
" +Threads = {\n"
" Class = ReferenceContainer\n"
" +Thread1 = {\n"
" Class = RealTimeThread\n"
" Functions = { Reader }\n"
" }\n"
" }\n"
" }\n"
" }\n"
" +Scheduler = {\n"
" Class = GAMScheduler\n"
" TimingDataSource = Timings\n"
" }\n"
"}\n";
ReferenceT<RealTimeApplication> app = LoadApplication(cfg);
bool ok = app.IsValid();
/* Open a TCP listener for the control port BEFORE PrepareNextState so we
* never miss the client's CONNECT. */
BasicTCPSocket tcpListener;
if (ok) {
ok = tcpListener.Open() && tcpListener.Listen(controlPort, 5);
}
if (ok) {
ok = (app->PrepareNextState("State1") == ErrorManagement::NoError);
}
/* Open the multicast data socket aimed at the group, with IP_MULTICAST_IF
* set to 127.0.0.1 so the datagram leaves on loopback exactly what
* UDPSServer does. */
BasicUDPSocket dataSocket;
if (ok) {
ok = dataSocket.Open();
}
if (ok) {
struct in_addr localIf;
localIf.s_addr = inet_addr(mcIface);
int fd = static_cast<int>(dataSocket.GetWriteHandle());
ok = (setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF,
&localIf, static_cast<socklen_t>(sizeof(localIf))) == 0);
}
if (ok) {
ok = dataSocket.Connect(mcGroup, dataPort);
}
/* Accept the client's TCP CONNECT. */
BasicTCPSocket *clientConn = NULL_PTR(BasicTCPSocket *);
if (ok) {
clientConn = tcpListener.WaitConnection(TimeoutType(2000u));
ok = (clientConn != NULL_PTR(BasicTCPSocket *));
}
/* Read the CONNECT packet from the accepted TCP connection. */
if (ok) {
uint8 recvBuf[64u];
uint32 recvSize = UDPS_HEADER_SIZE;
ok = clientConn->Read(reinterpret_cast<char8 *>(recvBuf), recvSize);
if (ok) {
const UDPSPacketHeader *hdr = reinterpret_cast<const UDPSPacketHeader *>(recvBuf);
ok = (hdr->magic == UDPS_MAGIC) && (hdr->type == UDPS_TYPE_CONNECT);
}
}
/* Send CONFIG: one scalar "Counter" uint32 signal, unquantised. */
if (ok) {
UDPSSignalDescriptor desc;
(void) memset(&desc, 0, sizeof(desc));
(void) strncpy(desc.name, "Counter", UDPS_MAX_SIGNAL_NAME - 1u);
desc.typeCode = UDPS_TYPECODE_UINT32;
desc.numRows = 1u;
desc.numCols = 1u;
uint8 buf[UDPS_HEADER_SIZE + 4u + UDPS_SIGNAL_DESC_SIZE + 1u];
const uint32 configPayloadBytes = 4u + UDPS_SIGNAL_DESC_SIZE + 1u;
UDPSBuildHeader(buf, UDPS_TYPE_CONFIG, 1u, 0u, 1u, configPayloadBytes);
uint32 numSigs = 1u;
(void) memcpy(buf + UDPS_HEADER_SIZE, &numSigs, 4u);
(void) memcpy(buf + UDPS_HEADER_SIZE + 4u, &desc, UDPS_SIGNAL_DESC_SIZE);
buf[UDPS_HEADER_SIZE + 4u + UDPS_SIGNAL_DESC_SIZE] = UDPS_PUBLISH_STRICT;
uint32 sendSize = static_cast<uint32>(sizeof(buf));
ok = clientConn->Write(reinterpret_cast<const char8 *>(buf), sendSize);
}
Sleep::MSec(100u);
ReferenceT<UDPStreamerClient> ds;
if (ok) {
ds = ObjectRegistryDatabase::Instance()->Find("Test.Data.ClientDS");
ok = ds.IsValid();
}
/* Send DATA over UDP multicast. Each attempt uses a fresh packet counter
* so UDPSClient's reassembly layer does not drop retransmissions. */
bool gotValue = false;
for (uint32 attempt = 0u; ok && (!gotValue) && (attempt < 30u); attempt++) {
SynchroniseThreadArgs *syncArgs = StartSynchroniseThread(ds);
uint8 buf[UDPS_HEADER_SIZE + 8u + 4u];
const uint32 dataPayloadBytes = 8u + 4u;
UDPSBuildHeader(buf, UDPS_TYPE_DATA, 2u + attempt, 0u, 1u, dataPayloadBytes);
(void) memset(buf + UDPS_HEADER_SIZE, 0, 8u);
uint32 value = 424242u;
(void) memcpy(buf + UDPS_HEADER_SIZE + 8u, &value, 4u);
uint32 sendSize = static_cast<uint32>(sizeof(buf));
ok = dataSocket.Write(reinterpret_cast<const char8 *>(buf), sendSize);
if (ok && JoinSynchroniseThread(syncArgs)) {
void *sigMem = NULL_PTR(void *);
if (ds->GetSignalMemoryBuffer(0u, 0u, sigMem)) {
uint32 decoded = 0u;
(void) memcpy(&decoded, sigMem, sizeof(uint32));
gotValue = (decoded == 424242u);
}
}
else if (!ok) {
(void) JoinSynchroniseThread(syncArgs);
}
}
ok = ok && gotValue;
if (clientConn != NULL_PTR(BasicTCPSocket *)) {
(void) clientConn->Close();
delete clientConn;
}
(void) tcpListener.Close();
(void) dataSocket.Close();
Sleep::MSec(50u);
ObjectRegistryDatabase::Instance()->Purge();
return ok;
}
@@ -158,6 +158,14 @@ public:
* UDP sockets, mirroring the server side of the wire protocol. * UDP sockets, mirroring the server side of the wire protocol.
*/ */
bool TestExecute_ConnectConfigDataEndToEnd(); bool TestExecute_ConnectConfigDataEndToEnd();
/**
* @brief Regression test: verifies that UDPStreamerClient receives multicast
* DATA when Interface is set to "127.0.0.1", ensuring the two-argument
* Join(group, interface) path in UDPSClient::ConnectMulticast is taken.
* This test fails without the UDPSClient multicastInterface fix.
*/
bool TestExecute_MulticastReceivesDataOnInterface();
}; };
#endif /* UDPSTREAMERCLIENTTEST_H_ */ #endif /* UDPSTREAMERCLIENTTEST_H_ */
+2 -2
View File
@@ -175,7 +175,7 @@ $App = {
+TimeArrayGAM1 = { +TimeArrayGAM1 = {
Class = TimeArrayGAM Class = TimeArrayGAM
SamplingRate = 1000000.0 SamplingRate = 1000000.0
Anchor = "FirstSample" Anchor = "Continuous"
InputSignals = { InputSignals = {
Time = { Time = {
DataSource = DDB2 DataSource = DDB2
@@ -291,7 +291,7 @@ $App = {
+TimeArrayGAM2 = { +TimeArrayGAM2 = {
Class = TimeArrayGAM Class = TimeArrayGAM
SamplingRate = 5000000.0 SamplingRate = 5000000.0
Anchor = "FirstSample" Anchor = "Continuous"
InputSignals = { InputSignals = {
Time = { Time = {
DataSource = DDB3 DataSource = DDB3
+2
View File
@@ -0,0 +1,2 @@
chain-client
configcheck/configcheck
+403
View File
@@ -0,0 +1,403 @@
// configcheck exercises the calibration and config-persistence WebSocket frames
// against a StreamHub (either the Go hub or the C++ StreamHub) and exits
// non-zero if the hub's replies do not match the protocol.
//
// Frame-strictness rule
// ─────────────────────
// Frames are divided into two categories:
//
// Protocol frames — part of the five command→response sequences under test:
// "calibration", "sources", "configSaved", "configReloaded"
// Ambient frames — unsolicited live traffic the hubs push independently:
// "data", "stats", "triggerState", "monotonicState" (and any unknown type)
//
// The checker is strict about protocol frames: if one arrives when a different
// protocol frame is expected, that is an error (out-of-order or unexpected).
// Ambient frames are logged and skipped without failing the check.
//
// Known documented exception: C++ HandleReloadConfig() emits a "sources" frame
// after "calibration", while the Go hub does not (because Go propagates source
// changes per-add, already broadcasting "sources" when sources are added).
// Both behaviours are correct; the extra "sources" frame from C++ is accepted
// and logged as a deliberate known difference.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"time"
"github.com/gorilla/websocket"
)
type calEntry struct {
Source string `json:"source"`
Signal string `json:"signal"`
Scale float64 `json:"scale"`
Offset float64 `json:"offset"`
Unit string `json:"unit"`
}
type frame struct {
Type string `json:"type"`
Cal []calEntry `json:"cal"`
OK bool `json:"ok"`
Path string `json:"path"`
Error string `json:"error"`
}
// protocolFrameTypes is the set of frame types that are part of the protocol
// sequences under test. Any frame whose type is in this set but is not the
// one currently expected causes an immediate failure. Frames with types NOT
// in this set are ambient traffic and are silently skipped.
var protocolFrameTypes = map[string]bool{
"calibration": true,
"sources": true,
"configSaved": true,
"configReloaded": true,
}
type conn struct {
ws *websocket.Conn
timeout time.Duration
// readCh is driven by a long-lived background goroutine; nil until startReader.
readCh chan readResult
}
type readResult struct {
f frame
err error
}
// startReader launches a background goroutine that reads all text frames from
// the WebSocket and forwards them on readCh. This avoids setting a read
// deadline on the underlying connection, which permanently poisons gorilla
// websocket v1.5.1 after a timeout fires.
func (c *conn) startReader() {
c.readCh = make(chan readResult, 32)
go func() {
for {
mt, data, err := c.ws.ReadMessage()
if err != nil {
c.readCh <- readResult{err: fmt.Errorf("ws read: %w", err)}
return
}
if mt != websocket.TextMessage {
continue
}
var f frame
if jsonErr := json.Unmarshal(data, &f); jsonErr != nil {
continue
}
c.readCh <- readResult{f: f}
}
}()
}
// next reads from the background reader, skipping ambient frames, until a
// protocol frame arrives or the deadline passes.
//
// If a protocol frame arrives that is NOT the expected one, next returns an
// error immediately — that is the out-of-order/unexpected signal.
func (c *conn) next(want string) (frame, error) {
return c.nextSkipping(want, nil)
}
// nextSkipping is like next but also skips (logs and discards) protocol frames
// whose type is in also. This is used for the initial-connect step where C++
// emits "sources" before "calibration" as part of its state-push sequence,
// whereas the Go hub emits "calibration" first. Passing also=[]string{"sources"}
// lets the checker accept either ordering without letting the connect-time
// sources frame go completely unnoticed.
func (c *conn) nextSkipping(want string, also []string) (frame, error) {
isSkip := make(map[string]bool, len(also))
for _, t := range also {
isSkip[t] = true
}
deadline := time.NewTimer(c.timeout)
defer deadline.Stop()
for {
select {
case <-deadline.C:
return frame{}, fmt.Errorf("timeout waiting for %q", want)
case r, ok := <-c.readCh:
if !ok {
return frame{}, fmt.Errorf("reader closed while waiting for %q", want)
}
if r.err != nil {
return frame{}, fmt.Errorf("read while waiting for %q: %w", want, r.err)
}
if r.f.Type == want {
return r.f, nil
}
// Explicitly-skipped protocol frames (known ordering differences).
if isSkip[r.f.Type] {
fmt.Printf("[skip allowed protocol frame %q while waiting for %q]\n", r.f.Type, want)
continue
}
// Is this an unexpected protocol frame (out-of-order)?
if protocolFrameTypes[r.f.Type] {
return frame{}, fmt.Errorf(
"unexpected protocol frame %q while waiting for %q (out-of-order or spurious emission)",
r.f.Type, want)
}
// Ambient frame — log and skip.
fmt.Printf("[skip ambient %q]\n", r.f.Type)
}
}
}
// nextWithin reads from the background reader until a frame with the wanted
// type arrives within d, returning (frame, true) or (frame{}, false). Unlike
// next() it does NOT return an error on timeout, making it suitable for the
// "must NOT arrive" assertion. Protocol frames with wrong type still skip
// (they will be picked up by the next next() call from the buffer).
func (c *conn) nextWithin(want string, d time.Duration) (frame, bool) {
deadline := time.NewTimer(d)
defer deadline.Stop()
for {
select {
case <-deadline.C:
return frame{}, false
case r, ok := <-c.readCh:
if !ok || r.err != nil {
return frame{}, false
}
if r.f.Type == want {
return r.f, true
}
// For the "must not arrive" check we skip everything else
// (ambient and other protocol frames alike) — we're only
// interested in whether the specific type appears.
}
}
}
func (c *conn) send(v interface{}) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
return c.ws.WriteMessage(websocket.TextMessage, data)
}
func findCal(list []calEntry, source, signal string) (calEntry, bool) {
for _, e := range list {
if e.Source == source && e.Signal == signal {
return e, true
}
}
return calEntry{}, false
}
// isSorted returns true iff the calibration list is sorted by source then signal.
func isSorted(list []calEntry) bool {
for i := 1; i < len(list); i++ {
prev, cur := list[i-1], list[i]
if prev.Source > cur.Source {
return false
}
if prev.Source == cur.Source && prev.Signal > cur.Signal {
return false
}
}
return true
}
func run(url string, timeout time.Duration) error {
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return fmt.Errorf("dial %s: %w", url, err)
}
defer ws.Close()
c := &conn{ws: ws, timeout: timeout}
c.startReader()
// ── Step 1: hub sends a calibration frame on connect, even when empty ────
// C++ emits "sources" before "calibration" as part of its initial state
// push; Go emits "calibration" first (Go's sources broadcast is triggered
// per-add when sources are added, not on client-connect in this test where
// no sources exist yet). We explicitly skip "sources" here so the checker
// is not confused by the ordering difference at connect time.
fmt.Println("Step 1: expect calibration on connect")
if _, err := c.nextSkipping("calibration", []string{"sources"}); err != nil {
return fmt.Errorf("on connect: %w", err)
}
// ── Step 2a: set two calibration entries in REVERSE sort order ───────────
// We deliberately set signal "Zeta" before "Alpha" under source "src1",
// and set source "src2" before "src1". The hubs must sort them and the
// checker asserts the received frame and the saved config file are both
// in sorted (source asc, signal asc) order.
wantEntries := []calEntry{
{Source: "src1", Signal: "Alpha", Scale: 2.0, Offset: 0.5, Unit: "m"},
{Source: "src1", Signal: "Zeta", Scale: 0.5, Offset: -1.25, Unit: "V"},
{Source: "src2", Signal: "Beta", Scale: 1.5, Offset: 0.0, Unit: "A"},
}
// Submit in reverse-sort order: src2/Beta, then src1/Zeta, then src1/Alpha.
submitOrder := []calEntry{
wantEntries[2], // src2/Beta
wantEntries[1], // src1/Zeta
wantEntries[0], // src1/Alpha
}
fmt.Println("Step 2: set calibration entries in reverse sort order")
var lastCalFrame frame
for i, e := range submitOrder {
if err := c.send(map[string]interface{}{
"type": "setCalibration",
"source": e.Source,
"signal": e.Signal,
"scale": e.Scale,
"offset": e.Offset,
"unit": e.Unit,
}); err != nil {
return err
}
cf, cerr := c.next("calibration")
if cerr != nil {
return fmt.Errorf("after setCalibration[%d]: %w", i, cerr)
}
if !isSorted(cf.Cal) {
return fmt.Errorf("setCalibration[%d]: calibration frame not sorted by source/signal; got %v", i, cf.Cal)
}
got, ok := findCal(cf.Cal, e.Source, e.Signal)
if !ok {
return fmt.Errorf("setCalibration[%d]: entry %s/%s missing from broadcast", i, e.Source, e.Signal)
}
if got != e {
return fmt.Errorf("setCalibration[%d]: got %+v, want %+v", i, got, e)
}
lastCalFrame = cf
}
// The last broadcast should contain all three entries in sorted order.
if len(lastCalFrame.Cal) != len(wantEntries) {
return fmt.Errorf("after all setCalibration: got %d entries, want %d", len(lastCalFrame.Cal), len(wantEntries))
}
// Confirm sorted order in the final broadcast.
if !isSorted(lastCalFrame.Cal) {
return fmt.Errorf("final calibration broadcast not sorted; got %v", lastCalFrame.Cal)
}
// ── Step 3: invalid entry (scale = 0) must be rejected ───────────────────
fmt.Println("Step 3: setCalibration with scale=0 must be rejected (no broadcast)")
if err := c.send(map[string]interface{}{
"type": "setCalibration", "source": "src1", "signal": "Alpha",
"scale": 0.0, "offset": 0.0, "unit": "",
}); err != nil {
return err
}
if _, accepted := c.nextWithin("calibration", 500*time.Millisecond); accepted {
return fmt.Errorf("setCalibration with scale=0 was accepted, must be rejected")
}
// ── Step 4: saveSources → configSaved ────────────────────────────────────
fmt.Println("Step 4: saveSources → configSaved")
if err := c.send(map[string]string{"type": "saveSources"}); err != nil {
return err
}
savedF, err := c.next("configSaved")
if err != nil {
return err
}
if !savedF.OK {
return fmt.Errorf("configSaved: ok=false, error=%q", savedF.Error)
}
if savedF.Path == "" {
return fmt.Errorf("configSaved: ok=true but path is empty")
}
savedPath := savedF.Path
// ── Step 5: reloadConfig → configReloaded → calibration ─────────────────
// Documented exception: C++ emits an additional "sources" frame after
// "calibration" in HandleReloadConfig(). The Go hub does not emit it
// at that point (it broadcast per-source additions earlier). We accept
// the "sources" frame from C++ but log it as a deliberate known difference.
fmt.Println("Step 5: reloadConfig → configReloaded → calibration")
if err := c.send(map[string]string{"type": "reloadConfig"}); err != nil {
return err
}
reloadedF, err := c.next("configReloaded")
if err != nil {
return err
}
if !reloadedF.OK {
return fmt.Errorf("configReloaded: ok=false, error=%q", reloadedF.Error)
}
calAfterReload, err := c.next("calibration")
if err != nil {
return fmt.Errorf("after reloadConfig, expected calibration: %w", err)
}
if !isSorted(calAfterReload.Cal) {
return fmt.Errorf("reloadConfig calibration not sorted; got %v", calAfterReload.Cal)
}
if len(calAfterReload.Cal) != len(wantEntries) {
return fmt.Errorf("reloadConfig: got %d calibration entries, want %d", len(calAfterReload.Cal), len(wantEntries))
}
for _, want := range wantEntries {
got, ok := findCal(calAfterReload.Cal, want.Source, want.Signal)
if !ok {
return fmt.Errorf("reloadConfig: entry %s/%s did not survive round-trip", want.Source, want.Signal)
}
if got != want {
return fmt.Errorf("reloadConfig: entry %s/%s: got %+v, want %+v", want.Source, want.Signal, got, want)
}
}
// Check for the optional extra "sources" frame from C++ (documented exception).
// We peek with a short timeout; if it arrives we log the known difference.
// If another unexpected protocol frame arrives instead, that is still a failure.
if extraF, arrived := c.nextWithin("sources", 500*time.Millisecond); arrived {
fmt.Printf("[KNOWN DIFFERENCE] C++ hub emitted extra \"sources\" frame after reload "+
"(Go hub does not). This is expected — C++ BroadcastSources() in "+
"HandleReloadConfig() notifies clients of source-list changes after "+
"LoadSourcesFile(skipActive=true); Go hub propagates per-add via commandCh. "+
"Extra frame sources count: %d\n", len(extraF.Cal))
}
// ── Step 6: verify the saved config file is sorted ───────────────────────
// We re-read the saved file and parse it to check sort order.
// This is a file-system check, not a WebSocket check.
fmt.Printf("Step 6: verify saved config file is sorted: %s\n", savedPath)
raw, fileErr := os.ReadFile(savedPath)
if fileErr != nil {
// The config file may not be accessible from this process (e.g. different
// temp dir). Log and skip — the frame sort assertion above already
// provides coverage.
fmt.Printf("[note: cannot read config file %s: %v — skipping file sort check]\n", savedPath, fileErr)
} else {
var fileEntries []calEntry
if jsonErr := json.Unmarshal(raw, &fileEntries); jsonErr != nil {
return fmt.Errorf("config file %s: invalid JSON: %w", savedPath, jsonErr)
}
if !isSorted(fileEntries) {
// Build the expected sorted order for the error message.
sorted := make([]calEntry, len(fileEntries))
copy(sorted, fileEntries)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Source != sorted[j].Source {
return sorted[i].Source < sorted[j].Source
}
return sorted[i].Signal < sorted[j].Signal
})
return fmt.Errorf("config file not sorted by source/signal:\n got: %v\n want: %v", fileEntries, sorted)
}
fmt.Printf("Config file has %d entries in sorted order.\n", len(fileEntries))
}
return nil
}
func main() {
url := flag.String("url", "ws://127.0.0.1:8090/ws", "hub WebSocket URL")
timeout := flag.Duration("timeout", 5*time.Second, "per-frame timeout")
flag.Parse()
if err := run(*url, *timeout); err != nil {
fmt.Fprintf(os.Stderr, "configcheck FAIL: %v\n", err)
os.Exit(1)
}
fmt.Println("configcheck OK")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,225 @@
# Per-signal calibration and persistent hub config
Date: 2026-08-16
Scope: `Client/udpstreamer/static/`, `Common/Client/go/wshub/`, `Source/Applications/StreamHub/`
## Problem
The web oscilloscope plots whatever the streamer sends. A signal carrying raw ADC
counts cannot be read in volts, and the vertical-scale toolbar's V/div and Offset
are display-only: they move the trace on screen but do not change what the cursor,
hover readout or CSV export report.
Separately, the only persistent state is the source list, saved server-side by the
`saveSources` WebSocket command. That command is fire-and-forget — the browser
never learns whether the write succeeded — and there is no way to re-read the file
without restarting the hub.
## Goals
1. A per-signal affine calibration `y = raw * scale + offset`, with an optional
unit override, applied consistently everywhere a value is shown.
2. Calibration stored in the hub's config file alongside the sources, so it
survives a browser reload and is shared between browsers.
3. A left-sidebar section to save and reload that config, with success/error
feedback.
4. Identical behaviour from the Go hub (`Common/Client/go/wshub`) and the C++
`StreamHub`, per the protocol-parity rule in CLAUDE.md.
## Non-goals
- Calibrating on the data path. Raw samples stay raw in the rings, in recorded
history, and in the trigger comparator.
- Per-array-element calibration. One entry covers a whole array signal.
- Named profiles. One config file, the existing `-sources-file` / `SourcesFile`.
- Persisting display state (layout, trace colours, V/div, window, trigger config).
## Data model
A calibration entry is keyed by `(source label, signal base name)`:
| Field | Type | Default | Validation |
|---|---|---|---|
| `source` | string | — | must be non-empty |
| `signal` | string | — | base signal name, no `[i]` suffix |
| `scale` | float64 | `1` | finite, non-zero |
| `offset` | float64 | `0` | finite |
| `unit` | string | `""` | trimmed, max 16 UTF-8 bytes; empty means "use the streamer's unit" |
The key uses the source **label**, not the runtime id (`s1`, `s2`). Ids are
assigned in add-order at startup, so a saved calibration keyed by id would rebind
to a different source whenever the source list order changed. Labels default to
the address when the user leaves the label blank, which keeps them unique in
practice; two sources sharing a label share a calibration, which is a documented
consequence rather than an error.
Array signals get one entry covering every element. The V-Scale toolbar can be
opened on a single element (`Adc[3]`), so its calibration row states which base
signal and how many elements the edit affects.
## Config file format
The file remains a flat JSON array of **flat** objects. Sources keep their current
shape; calibration entries are appended as additional elements:
```json
[
{"label": "wave", "addr": "127.0.0.1:44500"},
{"label": "mc", "addr": "127.0.0.1:44501", "multicastGroup": "239.0.0.1", "dataPort": 44502},
{"source": "wave", "signal": "Adc", "scale": 0.00030518, "offset": -1.25, "unit": "V"}
]
```
A block containing `addr` is a source; a block containing `signal` is a
calibration; anything else is skipped with a warning.
The flatness is a hard constraint, not a preference. `StreamHub::LoadSourcesFile`
(`StreamHub.cpp:854`) is a hand-rolled scanner that takes each `{` up to the next
`}` as one object. A nested `"calibration": { ... }` inside a source entry would
truncate at the inner brace and corrupt the parse. Keeping every element flat lets
that scanner gain a single discriminator branch, and every config file written by
the current binaries still loads unchanged in both hubs.
## WebSocket protocol
New frames, implemented identically in both hubs:
| Direction | Frame |
|---|---|
| hub → client | `{"type":"calibration","cal":[{"source","signal","scale","offset","unit"}, …]}` |
| client → hub | `{"type":"setCalibration","source","signal","scale","offset","unit"}` |
| hub → client | `{"type":"configSaved","ok":bool,"path":string,"error":string}` |
| client → hub | `{"type":"reloadConfig"}` |
| hub → client | `{"type":"configReloaded","ok":bool,"path":string,"error":string}` |
`calibration` is broadcast when a client connects and after every accepted
`setCalibration` or successful `reloadConfig`. It is a separate message rather
than a field on the existing `sources` broadcast because the C++
`BroadcastSources` serialises into a fixed 4096-byte buffer that a calibration
table would overflow.
`setCalibration` is validated hub-side against the table above. An invalid entry
is rejected and no broadcast is emitted, so the offending client reverts to the
last broadcast value.
`saveSources` keeps its name and now writes both sources and calibration entries.
It gains the `configSaved` acknowledgement it currently lacks.
## Reload semantics
`reloadConfig` re-reads the config file, then:
- **replaces** the calibration table wholesale with the file's contents;
- **adds** any source present in the file that is not currently active;
- **never** removes, restarts, or reconnects a live source.
Reload must not interrupt streaming, so an unsaved source the user added stays
running. The asymmetry (calibration replaced, sources merged) is deliberate:
calibration is cheap to reapply, a source is a live UDP session.
## Hub implementation
**Go (`Common/Client/go/wshub/sources.go`, `hub.go`).** A
`map[string]calEntry` keyed by `source + "\x00" + signal`, owned by the
`SourceManager` behind its existing `sync.RWMutex`. `setCalibration` and
`reloadConfig` are dispatched from the `readPump` command switch in `hub.go`
alongside `addSource`/`removeSource`/`saveSources`/`zoom`. `SourceConfig` gains a
sibling `CalConfig` type; `Save` writes both slices into one array;
`Load` decodes into `[]map[string]json.RawMessage` and discriminates per element.
**C++ (`Source/Applications/StreamHub/StreamHub.{h,cpp}`).** A fixed
`kMaxCalibration = 256` array of `CalibrationEntry` structs with fixed
`char[]` fields (`source[128]`, `signal[128]`, `unit[17]`) and `float64` scale
and offset — no STL and no per-entry heap allocation, per the `Source/Components`
and StreamHub style rules. `HandleSetCalibration`,
`HandleReloadConfig` and `BroadcastCalibration` mirror the existing
`HandleAddSource` / `BroadcastSources` shape, with `BroadcastCalibration` using
its own 16 KiB buffer like `BroadcastConfig`. `LoadSourcesFile` gains the
discriminator branch; `HandleSaveSources` appends the calibration entries and
sends `configSaved`.
## SPA implementation
Calibration composes into the existing vertical-scale transform:
```
y_cal = raw * scale + offset
y_norm = (y_cal - vsOffset) / divValue + screenPos
```
`applyVScaleNorm` (`app.js:186`) and its `applyDigitalNorm` / `applyMixedNorm`
siblings are the only places raw values enter the display path. Applying the
calibration there leaves the norm↔value inverse arithmetic untouched, so the
hover readout, cursors, rulers, Y-axis tick values and the V-Scale toolbar's own
V/div and Offset fields all report calibrated units without further change. V/div
and Offset are redefined as "calibrated units per division" and "calibrated value
at screen centre"; they remain a display concern, distinct from calibration.
Four sites need explicit handling because they bypass that path:
1. `resolveVScale` (`app.js:109`) `range` mode reads `meta.rangeMin` /
`meta.rangeMax` from the streamer CONFIG. Both are calibrated and then
re-ordered, since a negative `scale` swaps them.
2. `exportAllCSV` (`app.js:2871`) fetches full-resolution data from the ring or
history and formats it directly. It calibrates each column and writes the
effective unit into the header.
3. Trigger threshold. The hub compares against raw samples, so the SPA sends
`(threshold - offset) / scale` and displays the inverse. V2 capture frames
arrive raw and flow through the normal display path, so they need nothing.
4. Unit display: the sidebar `sig-unit` badge, the hover readout and the V-Scale
header show the override when set, otherwise the streamer's `sig.unit`.
**Calibration UI.** A new row in `#vscale-menu` (`index.html:187`), the toolbar
already opened by clicking a signal in a plot:
```
Cal Scale [ 1 ] Offset [ 0 ] Unit [ V ] [Reset]
```
with a header line naming the base signal and element count it will affect. Edits
apply locally, send `setCalibration`, and are confirmed by the hub's broadcast.
`Reset` restores `scale=1, offset=0, unit=""` and sends that.
**Config UI.** The collapsible "Add Source" section (`app.js:3345`) is renamed
"Sources & Config" and keeps its address/label/multicast inputs and Connect
button. The existing fire-and-forget "Save list" button is replaced by **Save**
and **Reload**, plus a one-line status area rendering the `configSaved` /
`configReloaded` ack: the written path on success, the error text on failure.
**Fallback.** The SPA seeds its calibration table from
`localStorage['udpscope.calibration']` at page load, and overwrites it wholesale
the first time a `calibration` message arrives. The mirror is rewritten on every
change, including changes received from the hub. A hub binary that predates this
change never sends `calibration`, so the mirror simply remains authoritative;
the same holds for a hub started without a config file.
**Validation.** The same rules as the hub are enforced in the input handlers: a
non-finite or zero `scale` keeps the rejected text in the field and marks it
with a red `cal-invalid` border, so the user can see what was wrong.
## Testing
**Go.** Table tests for the heterogeneous-array parse (current-format file,
new-format file, unrecognised block, malformed entry), `setCalibration`
validation including `scale = 0` and non-finite values, and a save→load
round-trip asserting sources and calibration both survive.
**C++.** A standalone Go program at `Test/E2E/suite/client/configcheck/` connects
to either hub (Go or C++) via WebSocket and asserts identical calibration
behaviour: it exercises `setCalibration`, `saveSources → configSaved`,
`reloadConfig → configReloaded`, and verifies that the saved config file and all
broadcast frames are sorted and complete. The program exits non-zero on any
deviation from the protocol, making it runnable against both hubs in CI.
**Browser.** `node --check static/app.js`, plus a manual pass: set a scale and
offset on a live signal and confirm the plot, hover readout, cursor readout, CSV
export and trigger threshold all agree; reload the page and confirm the
calibration returns; press Reload and confirm an unsaved edit is discarded while
the live source keeps streaming.
## Documentation
`Docs/StreamHub-API.md` gains the five new frames (`calibration`, `setCalibration`,
`configSaved`, `reloadConfig`, `configReloaded`); `Docs/WebUI.md` gains the
calibration row and the Sources & Config section; `ARCHITECTURE.md` §6 gains the
config file format.
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# run_streamhub.sh — Launch a MARTe2 app with UDPStreamer + StreamHub
#
# Usage:
# ./run_streamhub.sh [OPTIONS]
#
# Options:
# -m <MARTe2_DIR> Override MARTe2 installation dir (default: $MARTe2_DIR)
# -c <MARTe2_Components_DIR> Override MARTe2-components dir (default: $MARTe2_Components_DIR)
# -b <BUILD_TARGET> Build target (default: x86-linux)
# -p <WS_PORT> StreamHub WebSocket port (default: 8090)
# -n <MAX_POINTS> StreamHub ring-buffer size per signal (default: 10000)
# -s Skip building — run with whatever is already built
# -g Launch the ImGui desktop client after start
# -w Build and launch the web UI server (Client/webui)
# -h Show this help
#
# Ports used:
# 44500/udp UDPStreamer scalar signals (unicast control)
# 44503/udp UDPStreamer scalar signals (multicast data, group 239.0.0.1)
# 44501/udp UDPStreamer array signals (FirstSample / LastSample)
# 44502/udp UDPStreamer array signals (FullArray)
# 8080/tcp DebugService control
# 8081/udp DebugService stream
# 9090/tcp TCPLogger
# 8090/tcp StreamHub WebSocket (default, override with -p)
#
# Environment:
# MARTe2_DIR must be set (or passed via -m)
# MARTe2_Components_DIR must be set (or passed via -c)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MARTe_CFG="${SCRIPT_DIR}/Test/Configurations/streamhub_demo.cfg"
BUILD_TARGET="${TARGET:-x86-linux}"
WS_PORT=8090
MAX_POINTS=1000000
SKIP_BUILD=0
START_GUI=0
START_WEBUI=0
WEBUI_PORT=8080
# ── Parse arguments ───────────────────────────────────────────────────────────
while getopts "m:c:b:p:n:sgwh" opt; do
case "$opt" in
m) MARTe2_DIR="$OPTARG" ;;
c) MARTe2_Components_DIR="$OPTARG" ;;
b) BUILD_TARGET="$OPTARG" ;;
p) WS_PORT="$OPTARG" ;;
n) MAX_POINTS="$OPTARG" ;;
s) SKIP_BUILD=1 ;;
g) START_GUI=1 ;;
w) START_WEBUI=1 ;;
h)
sed -n '2,30p' "$0" | grep '^#' | sed 's/^# \?//'
exit 0
;;
*) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
esac
done
# ── Validate environment ──────────────────────────────────────────────────────
if [[ -z "${MARTe2_DIR:-}" ]]; then
echo "ERROR: MARTe2_DIR is not set. Source env.sh first or pass -m <dir>."
echo " source ${SCRIPT_DIR}/env.sh"
exit 1
fi
if [[ -z "${MARTe2_Components_DIR:-}" ]]; then
echo "ERROR: MARTe2_Components_DIR is not set. Source env.sh first or pass -c <dir>."
exit 1
fi
BUILD_DIR="${SCRIPT_DIR}/Build/${BUILD_TARGET}"
STREAMHUB_EX="${BUILD_DIR}/StreamHub/StreamHub.ex"
IMGUI_CLIENT="${SCRIPT_DIR}/Client/streamhub/build/StreamHubClient"
WEBUI_DIR="${SCRIPT_DIR}/Client/webui"
WEBUI_BIN="${WEBUI_DIR}/streamhub-webui"
MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTeApp.ex"
if [[ ! -x "$MARTE2_BIN" ]]; then
MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTe2.sh"
fi
if [[ ! -x "$MARTE2_BIN" ]]; then
echo "ERROR: MARTe2 executable not found at ${MARTe2_DIR}/Build/${BUILD_TARGET}/App/"
exit 1
fi
# ── Build ─────────────────────────────────────────────────────────────────────
if [[ "$SKIP_BUILD" -eq 0 ]]; then
echo "==> Building MARTe2 components (TARGET=${BUILD_TARGET})..."
make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" 2>&1 | tail -10
echo "==> Building StreamHub (TARGET=${BUILD_TARGET})..."
make -C "${SCRIPT_DIR}/Source/Applications/StreamHub" \
-f Makefile.gcc TARGET="${BUILD_TARGET}" \
MARTe2_DIR="${MARTe2_DIR}" 2>&1 | tail -10
if [[ "$START_GUI" -eq 1 ]]; then
if [[ -d "${SCRIPT_DIR}/Client/streamhub/build" ]]; then
echo "==> Building ImGui client (a full rebuild can take ~2 min;"
echo " ImPlot's implot_items.cpp is one slow -O3 translation unit)..."
cmake --build "${SCRIPT_DIR}/Client/streamhub/build" -j"$(nproc)"
fi
fi
if [[ "$START_WEBUI" -eq 1 ]]; then
echo "==> Building web UI server..."
(cd "${WEBUI_DIR}" && go build -o streamhub-webui .)
fi
echo "==> Build done."
fi
# ── Sanity-check binaries ─────────────────────────────────────────────────────
if [[ ! -x "$STREAMHUB_EX" ]]; then
echo "ERROR: StreamHub binary not found: ${STREAMHUB_EX}"
echo " Build it with: make -C Source/Applications/StreamHub -f Makefile.gcc"
exit 1
fi
# ── Write StreamHub config ────────────────────────────────────────────────────
HUB_CFG="$(mktemp /tmp/streamhub_XXXXXX.cfg)"
cat > "$HUB_CFG" <<EOF
/**
* StreamHub configuration — auto-generated by run_streamhub.sh
*
* Three sources matching the streamhub_demo MARTe2 configuration:
* scalar : 1 kHz scalar sines @ 1 ksps (Sine1, Sine2)
* med : 1 kHz arrays @ 1 Msps (Ch1, Ch2)
* fast : 5 kHz arrays @ 5 Msps (Ch3, Ch4)
*/
Hub = {
WSPort = ${WS_PORT}
MaxPoints = ${MAX_POINTS}
PushRate = 30
MaxPushPoints = 2000
RingTemporal = 1000000
RingScalar = 100000
+History = {
Directory = "/tmp/streamhub_history"
DurationHours = 1
Decimation = 10
FlushIntervalSec = 5
MinDiskFreeMB = 200
}
+Recorder = {
Enabled = 0
AutoStart = 1
Directory = "/tmp/streamhub_rec"
MaxFileMB = 256
KeepFiles = 8
StagingMB = 8
FlushIntervalSec = 5
MinDiskFreeMB = 500
Signals = "all"
}
Sources = {
scalar = {
Label = "Scalar Sines (1 ksps)"
Addr = "127.0.0.1"
Port = 44500
MulticastGroup = "239.0.0.1"
DataPort = 44503
}
med = {
Label = "1 Msps Sines (Ch1 1kHz, Ch2 5kHz)"
Addr = "127.0.0.1"
Port = 44501
}
fast = {
Label = "5 Msps Sines (Ch3 10kHz, Ch4 50kHz)"
Addr = "127.0.0.1"
Port = 44502
}
}
}
EOF
# ── Library path — covers both MARTe2 app and StreamHub ──────────────────────
export LD_LIBRARY_PATH="\
${MARTe2_DIR}/Build/${BUILD_TARGET}/Core:\
${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/DataSources/LinuxTimer:\
${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/GAMs/IOGAM:\
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
${BUILD_DIR}/Components/GAMs/SineArrayGAM:\
${BUILD_DIR}/Components/GAMs/TimeArrayGAM:\
${BUILD_DIR}/Components/Interfaces/UDPStream:\
${LD_LIBRARY_PATH:-}"
# ── Cleanup handler ───────────────────────────────────────────────────────────
MARTE_PID=""
HUB_PID=""
GUI_PID=""
WEBUI_PID=""
cleanup() {
echo ""
echo "==> Shutting down..."
[[ -n "$WEBUI_PID" ]] && kill "$WEBUI_PID" 2>/dev/null || true
[[ -n "$GUI_PID" ]] && kill "$GUI_PID" 2>/dev/null || true
[[ -n "$HUB_PID" ]] && kill "$HUB_PID" 2>/dev/null || true
[[ -n "$MARTE_PID" ]] && kill "$MARTE_PID" 2>/dev/null || true
wait "$WEBUI_PID" 2>/dev/null || true
wait "$GUI_PID" 2>/dev/null || true
wait "$HUB_PID" 2>/dev/null || true
wait "$MARTE_PID" 2>/dev/null || true
rm -f "$HUB_CFG"
echo "==> Done."
}
trap cleanup EXIT INT TERM
# ── Launch MARTe2 ─────────────────────────────────────────────────────────────
echo ""
echo "==> Launching MARTe2..."
echo " Binary : ${MARTE2_BIN}"
echo " Config : ${MARTe_CFG}"
echo " Signals: Sine1 (1 Hz), Sine2 (0.3 Hz), Ch1-Ch4 (arrays)"
echo ""
"${MARTE2_BIN}" \
-l RealTimeLoader \
-f "${MARTe_CFG}" \
-s Running \
-m StateMachine:START &
MARTE_PID="$!"
# Give MARTe2 a moment to bind its UDP ports before StreamHub connects
sleep 1
# ── Launch StreamHub ──────────────────────────────────────────────────────────
echo "==> Launching StreamHub..."
echo " Binary : ${STREAMHUB_EX}"
echo " Config : ${HUB_CFG}"
echo " WS port : ${WS_PORT}"
echo " MaxPoints: ${MAX_POINTS}"
echo ""
"${STREAMHUB_EX}" -cfg "${HUB_CFG}" &
HUB_PID="$!"
# ── Optionally launch the ImGui client ───────────────────────────────────────
if [[ "$START_GUI" -eq 1 ]]; then
if [[ ! -x "$IMGUI_CLIENT" ]]; then
echo "WARNING: ImGui client not found at ${IMGUI_CLIENT}"
echo " Build it with: cd Client/streamhub && cmake -B build && cmake --build build"
else
sleep 0.5
echo "==> Launching ImGui client (127.0.0.1:${WS_PORT})..."
"${IMGUI_CLIENT}" -host 127.0.0.1 -port "${WS_PORT}" &
GUI_PID="$!"
fi
fi
# ── Optionally launch the web UI server ──────────────────────────────────────
if [[ "$START_WEBUI" -eq 1 ]]; then
if [[ ! -x "$WEBUI_BIN" ]]; then
echo "WARNING: webui binary not found at ${WEBUI_BIN}"
echo " Build it with: cd Client/webui && go build -o streamhub-webui ."
else
echo "==> Launching web UI server (:${WEBUI_PORT})..."
"${WEBUI_BIN}" -addr ":${WEBUI_PORT}" \
-hub "localhost:${WS_PORT}" \
-static "${SCRIPT_DIR}/Client/udpstreamer/static" &
WEBUI_PID="$!"
fi
fi
# ── Status ────────────────────────────────────────────────────────────────────
echo " MARTe2 PID : ${MARTE_PID}"
echo " StreamHub PID: ${HUB_PID}"
[[ -n "$GUI_PID" ]] && echo " ImGui PID : ${GUI_PID}"
[[ -n "$WEBUI_PID" ]] && echo " WebUI PID : ${WEBUI_PID}"
echo ""
echo " StreamHub WebSocket: ws://127.0.0.1:${WS_PORT}"
[[ -n "$WEBUI_PID" ]] && echo " Browser client : http://localhost:${WEBUI_PORT}/"
[[ -x "$IMGUI_CLIENT" ]] && echo " ImGui client : ${IMGUI_CLIENT} -host 127.0.0.1 -port ${WS_PORT}"
echo ""
echo " Press Ctrl-C to stop all processes."
echo ""
# ── Wait until any child exits ────────────────────────────────────────────────
wait -n "${MARTE_PID}" "${HUB_PID}" ${GUI_PID:-} ${WEBUI_PID:-} 2>/dev/null || true
echo "==> A process exited — stopping remaining processes."
+3
View File
@@ -138,6 +138,9 @@ Hub = {
MaxPushPoints = 2000 MaxPushPoints = 2000
RingTemporal = 1000000 RingTemporal = 1000000
RingScalar = 100000 RingScalar = 100000
// The SPA is served on WEBUI_PORT, not WSPort, so its Origin does not match
// the hub's Host and the default same-origin check would 403 the handshake.
AllowedOrigins = "http://localhost:${WEBUI_PORT},http://127.0.0.1:${WEBUI_PORT}"
+History = { +History = {
Directory = "/tmp/streamhub_history" Directory = "/tmp/streamhub_history"
DurationHours = 1 DurationHours = 1
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env bash
# run_udp_producer.sh — Run a MARTe2 app that streams N sine channels at 1 Msps.
#
# A producer only: no StreamHub, no clients. Point whatever consumer you like at
# the UDP port (StreamHub, the Go hub, or Test/E2E tooling).
#
# Each channel is a 1000-element float32 array published every 1 ms by a 1 kHz
# real-time thread — 1000 samples x 1000 Hz = 1 Msps per channel. A parallel
# uint64 time array gives every sample its own timestamp (TimeMode=FullArray),
# so consumers reconstruct the waveform at full rate rather than one point per
# cycle.
#
# Usage:
# ./run_udp_producer.sh [OPTIONS]
#
# Options:
# -n <CHANNELS> Number of 1 Msps channels (default 4, max 13 — see below)
# -p <PORT> UDP port to stream on (default 44501)
# -b <TARGET> Build target (default: $TARGET or x86-linux)
# -s Skip the component rebuild
# -k Keep the generated .cfg on exit and print its path
# -h Show this help
#
# Why 13 channels max: one cycle is TimeArray(8000 B) + CHANNELS x 4000 B, and
# it is sent as a single datagram to keep the receiver's fragment-reassembly
# pool from evicting in-flight cycles (which shows up as periodic gaps in the
# trace). A UDP datagram tops out at 65507 B, so 8000 + 4000*13 + headroom fits
# and 14 does not.
#
# Environment:
# MARTe2_DIR must be set (or source env.sh first)
# MARTe2_Components_DIR must be set (or source env.sh first)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_TARGET="${TARGET:-x86-linux}"
CHANNELS=4
PORT=44501
SKIP_BUILD=0
KEEP_CFG=0
MAX_CHANNELS=13
while getopts "n:p:b:skh" opt; do
case "$opt" in
n) CHANNELS="$OPTARG" ;;
p) PORT="$OPTARG" ;;
b) BUILD_TARGET="$OPTARG" ;;
s) SKIP_BUILD=1 ;;
k) KEEP_CFG=1 ;;
h) sed -n '2,33p' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
esac
done
# ── Validate ──────────────────────────────────────────────────────────────────
if ! [[ "$CHANNELS" =~ ^[0-9]+$ ]] || (( CHANNELS < 1 || CHANNELS > MAX_CHANNELS )); then
echo "ERROR: -n must be 1..${MAX_CHANNELS} (got '${CHANNELS}')." >&2
exit 1
fi
if [[ -z "${MARTe2_DIR:-}" || -z "${MARTe2_Components_DIR:-}" ]]; then
echo "ERROR: MARTe2_DIR / MARTe2_Components_DIR not set." >&2
echo " source ${SCRIPT_DIR}/env.sh" >&2
exit 1
fi
MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTeApp.ex"
if [[ ! -x "$MARTE2_BIN" ]]; then
echo "ERROR: MARTeApp.ex not found at ${MARTE2_BIN}" >&2
exit 1
fi
# ── Build ─────────────────────────────────────────────────────────────────────
if [[ "$SKIP_BUILD" -eq 0 ]]; then
echo "==> Building components (TARGET=${BUILD_TARGET})..."
make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" core 2>&1 | tail -5
fi
# ── Generate the config ───────────────────────────────────────────────────────
# Distinct amplitude/frequency/phase per channel so traces stay tellable apart
# (and so a shared-Y-axis view has a spread of magnitudes to cope with).
AMPS=(1.0 2.5 0.5 5.0 1.5 3.0 0.8 4.0 2.0 0.3 6.0 1.2 3.5)
FREQS=(1000 2000 5000 500 10000 3000 20000 1500 7000 50000 800 4000 15000)
PHASES=(0.0 0.7854 1.5708 2.3562 3.1416 3.9270 4.7124 5.4978 0.3927 1.1781 1.9635 2.7489 3.5343)
ELEMS=1000 # samples per cycle
RATE=1000 # cycles per second -> 1 Msps
CYCLE_BYTES=$(( 8 * ELEMS + CHANNELS * 4 * ELEMS ))
PAYLOAD=$(( CYCLE_BYTES + 2000 )) # headroom for header + descriptors
sine_gams=""; iogam_in=""; iogam_out=""; stream_sigs=""; func_list="TimerGAM"
for (( i = 1; i <= CHANNELS; i++ )); do
k=$(( i - 1 ))
amp="${AMPS[$k]}"; frq="${FREQS[$k]}"; pha="${PHASES[$k]}"
sine_gams+="
+SineGAM${i} = {
Class = SineArrayGAM
Frequency = ${frq}.0
Amplitude = ${amp}
Phase = ${pha}
Offset = 0.0
SamplingRate = 1000000.0
OutputSignals = {
Ch${i} = {
DataSource = DDB1
Type = float32
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}
}
}
"
iogam_in+="
Ch${i} = {
DataSource = DDB1
Type = float32
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}"
iogam_out+="
Ch${i} = {
DataSource = Streamer
Type = float32
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}"
stream_sigs+="
Ch${i} = {
Type = float32
Unit = \"V\"
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
RangeMin = -${amp}
RangeMax = ${amp}
TimeMode = \"FullArray\"
TimeSignal = TimeArray
}"
func_list+=", SineGAM${i}"
done
func_list+=", TimeArrayGAM1, StreamerGAM"
CFG="$(mktemp /tmp/udp_producer_XXXXXX.cfg)"
cat > "$CFG" <<EOF
/**
* udp_producer — auto-generated by run_udp_producer.sh
* ${CHANNELS} channel(s), ${ELEMS} elem x ${RATE} Hz = 1 Msps each, port ${PORT}.
*/
\$App = {
Class = RealTimeApplication
+Functions = {
Class = ReferenceContainer
+TimerGAM = {
Class = IOGAM
InputSignals = {
Time = {
DataSource = Timer
Type = uint32
Frequency = ${RATE}
}
}
OutputSignals = {
Time = {
DataSource = DDB1
Type = uint32
}
}
}
${sine_gams}
// Expands the cycle's scalar timestamp into one timestamp per sample, so
// consumers place all ${ELEMS} samples instead of collapsing them to a point.
+TimeArrayGAM1 = {
Class = TimeArrayGAM
SamplingRate = 1000000.0
Anchor = "Continuous"
InputSignals = {
Time = {
DataSource = DDB1
Type = uint32
}
}
OutputSignals = {
TimeArray = {
DataSource = DDB1
Type = uint64
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}
}
}
+StreamerGAM = {
Class = IOGAM
InputSignals = {
TimeArray = {
DataSource = DDB1
Type = uint64
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}${iogam_in}
}
OutputSignals = {
TimeArray = {
DataSource = Streamer
Type = uint64
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}${iogam_out}
}
}
}
+Data = {
Class = ReferenceContainer
DefaultDataSource = DDB1
+DDB1 = {
Class = GAMDataSource
}
+Timer = {
Class = LinuxTimer
SleepNature = "Default"
Signals = {
Counter = {
Type = uint32
}
Time = {
Type = uint32
}
}
}
+Streamer = {
Class = UDPStreamer
Port = ${PORT}
// One cycle is ${CYCLE_BYTES} B; sizing the payload above that sends each
// cycle as a single datagram, which keeps the receiver's reassembly pool
// from evicting in-flight cycles and gapping the trace.
MaxPayloadSize = ${PAYLOAD}
PublishingMode = "Strict"
Signals = {
TimeArray = {
Type = uint64
Unit = "ns"
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}${stream_sigs}
}
}
+Timings = {
Class = TimingDataSource
}
}
+States = {
Class = ReferenceContainer
+Running = {
Class = RealTimeState
+Threads = {
Class = ReferenceContainer
+Thread1 = {
Class = RealTimeThread
CPUs = 0x2
Functions = { ${func_list} }
}
}
}
}
+Scheduler = {
Class = GAMScheduler
TimingDataSource = Timings
}
}
EOF
# ── Run ───────────────────────────────────────────────────────────────────────
BUILD_DIR="${SCRIPT_DIR}/Build/${BUILD_TARGET}"
# UDPStream is not used directly here, but UDPStreamer.so carries a NEEDED entry
# on it, so dlopen of the DataSource fails without it on the path.
export LD_LIBRARY_PATH="\
${MARTe2_DIR}/Build/${BUILD_TARGET}/Core:\
${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/DataSources/LinuxTimer:\
${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/GAMs/IOGAM:\
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
${BUILD_DIR}/Components/GAMs/SineArrayGAM:\
${BUILD_DIR}/Components/GAMs/TimeArrayGAM:\
${BUILD_DIR}/Components/Interfaces/UDPStream:\
${LD_LIBRARY_PATH:-}"
cleanup() {
if [[ "$KEEP_CFG" -eq 1 ]]; then
echo ""
echo "==> Config kept at ${CFG}"
else
rm -f "$CFG"
fi
}
trap cleanup EXIT INT TERM
echo ""
echo "==> Streaming on udp/${PORT}"
echo " Channels : ${CHANNELS} x 1 Msps (${ELEMS} elem @ ${RATE} Hz)"
for (( i = 1; i <= CHANNELS; i++ )); do
k=$(( i - 1 ))
printf ' Ch%-2d %8s Hz %s V\n' "$i" "${FREQS[$k]}" "${AMPS[$k]}"
done
echo " Cycle : ${CYCLE_BYTES} B (MaxPayloadSize ${PAYLOAD})"
echo " Config : ${CFG}"
echo ""
echo " Consume with e.g.:"
echo " Addr = \"127.0.0.1\" Port = ${PORT} (StreamHub source)"
echo ""
echo " Press Ctrl-C to stop."
echo ""
exec "${MARTE2_BIN}" \
-l RealTimeLoader \
-f "${CFG}" \
-s Running \
-m StateMachine:START