Files
MARTe-Integrated-Components/Docs/StreamHub-Developer.md
T
Martino Ferrari 45dcb9a71f docs: fix remaining dangling references to retired run_e2e_test.sh
README.md and Docs/StreamHub-Developer.md still pointed at the deleted
run_e2e_test.sh / Test/E2E/streamhub Go client after their removal in the
previous commit; repoint at Test/E2E/suite/run_e2e.sh.
2026-07-01 22:05:29 +02:00

232 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# StreamHub — Developer Guide
`Source/Applications/StreamHub/` is a headless C++ application (MARTe2-linked,
MARTe2 coding style, no STL) that aggregates one or more UDPS sources
(`UDPStreamer` DataSources) and serves them to oscilloscope clients over
WebSocket. The Go hub (`Client/udpstreamer`) is the feature-complete reference
implementation of the same protocol and is kept untouched.
Wire protocols: [Protocol.md](Protocol.md) (UDPS, source → hub) and
[StreamHub-API.md](StreamHub-API.md) (WebSocket, hub → clients).
---
## 1. Source layout
| File | Role |
|------|------|
| `main.cpp` | CLI entry (`-cfg file.cfg -port N -maxPoints N`), signal handling |
| `StreamHub.{h,cpp}` | Top-level object: config, push loop, WS command dispatch, broadcasts |
| `UDPSourceSession.{h,cpp}` | One UDPS source: `UDPSClient` listener, payload decode, wall-clock calibration, ring buffers, stats |
| `SignalRingBuffer.h` | Per-signal (t,v) ring: monotonic write counter, `ReadSince` cursor reads, binary-search `ReadRange` |
| `TriggerEngine.{h,cpp}` | Trigger FSM (IDLE/ARMED/COLLECTING/TRIGGERED), edge detection per decoded sample |
| `UDPSourceStats.h` | 512-entry cycle/frag/byte rings → avg/std/min/max, rate, 20-bin histogram |
| `HistoryWriter.{h,cpp}` | Disk-backed circular history: per-signal `.shist` files, `WriteTick`, `ReadRange` (binary search via `pread`), disk space monitoring |
| `WSServer.{h,cpp}` | RFC 6455 server: handshake, framing, per-client write mutex, broadcast/unicast |
| `WSFrame.h`, `SHA1.h`, `Base64.h` | Header-only WS plumbing, shared with the ImGui client |
| `LTTB.h` | Largest-Triangle-Three-Buckets decimation |
The UDPS client itself lives in the shared library
`Source/Components/Interfaces/UDPStream/` (`UDPSClient`, also used by
`DebugService`). Note: in multicast mode the server delivers CONFIG over the
**TCP control connection**, so `UDPSClient` selects on both the multicast UDP
socket and the TCP socket and frames TCP reads.
## 2. Thread model
| Thread | Created by | Work |
|--------|-----------|------|
| main / push loop | `StreamHub::Run()` | At `PushRate` Hz: `PushData()` (serialise v1 frames), trigger servicing (capture finalisation, auto-rearm), `PushStats()` at `StatsRate` Hz |
| WS accept | `WSServer::Start()` | `accept()` + handshake, spawns client readers |
| WS client reader ×16 | `WSServer` | Reads frames, may contain several coalesced frames per TCP read; dispatches JSON to `StreamHub::OnWSCommand(json, len, slotIdx)` |
| UDPS receive ×32 | `UDPSClient::Start()` (one per session) | select() on UDP (+TCP in multicast), reassembles fragments, calls `UDPSourceSession` listener callbacks |
Synchronisation:
- Each `SignalRingBuffer` has its own `FastPollingMutexSem`; writers are the
UDPS receive threads, readers are the push loop and zoom handlers.
- `WSServer` has a per-client write mutex (push loop and command replies can
write concurrently).
- WS commands run on reader threads, but mutating operations (ring resize via
`setMaxPoints`) are deferred to the push loop through pending atomics.
- Session slots use an `active` flag; removal never compacts the array, so
indices stay stable.
## 3. Time base (wall-clock calibration)
All timestamps exposed to clients are **Unix wall-clock seconds** (float64).
Each session calibrates per time-source:
- First DATA packet anchors `pktCalibOffset = wallNow hrt/hrtFreq`; thereafter
`packetT = pktCalibOffset + hrt/hrtFreq`.
- Each referenced time signal gets its own offset on first value;
`timerToSec = 1e-9` for `uint64` time signals, `1e-6` otherwise.
- Re-anchoring on reconnect, CONFIG change, or if computed time drifts > 2 s
from wall clock (source restart / remote-vs-local HRT frequency drift).
Per `timeMode`:
| timeMode | Timestamping |
|----------|-------------|
| FIRST/LAST_SAMPLE | anchor = calibrated time-signal value (fallback `packetT`); `t = anchor ± k·dt` from `samplingRate` |
| FULL_ARRAY | `t[k]` = calibrated time array element |
| PACKET, n=1 | `packetT` |
| PACKET, n>1 | elements span `(lastPktWall, wallNow]` — backward anchoring, deliberately different from the Go hub (which extrapolates forward and overlaps the next packet under jitter); keeps ring time strictly monotonic |
Multi-element PACKET signals are exposed per element as `name[i]`.
## 4. Push path (only-new samples)
`SignalRingBuffer` keeps a monotonic `totalWritten`; the hub keeps a
per-(session, signal) cursor and uses `ReadSince(cursor, …)` (clamped to the
oldest sample on overrun). Each tick serialises only new samples, LTTB-capped
to `MaxPushPoints` (default 50) per signal — LTTB is applied only to temporal
signals (multi-element, timeMode ≠ PACKET). Cursors advance even with zero WS
clients so a connecting client never receives a backlog burst. Cursors are
reset on (re)CONFIG and on ring resize.
This replaces the original "re-send the last N points each tick" design, which
caused visible trace corruption (LTTB picked different points per overlapping
window).
## 5. Trigger engine
Hub-side, web-client semantics (`setTrigger` fields in
[StreamHub-API.md](StreamHub-API.md)):
```
IDLE --arm--> ARMED --edge crossing--> COLLECTING --wallNow ≥ trigTime+postSec+0.15s--> TRIGGERED
TRIGGERED --rearm (single) / auto ~200ms (normal, unless stopped)--> ARMED
any --disarm--> IDLE
```
`UDPSourceSession` calls `TriggerEngine::CheckSample` for every decoded sample
of the configured signal (signal index cached per config epoch). On
finalisation the push loop reads `[trigTimepreSec, trigTime+postSec]` from all
rings, LTTB-caps to 20 000 pts/signal and broadcasts a binary **version 2**
capture frame; every FSM transition broadcasts a `triggerState` event.
## 6. Configuration
```
WSPort = 8090
MaxPoints = 20000 // legacy global cap (overridable with -maxPoints)
PushRate = 30 // Hz
MaxPushPoints = 50 // per signal per push
StatsRate = 1 // Hz
RingTemporal = 1000000 // ring capacity, temporal signals (pts)
RingScalar = 100000 // ring capacity, scalar/PACKET signals (pts)
SourcesFile = "streamhub_sources.json" // saveSources persistence
Sources = {
Src1 = { Label = "PSU" Addr = "127.0.0.1" Port = 44500
MulticastGroup = "239.0.0.1" DataPort = 44503 } // multicast optional
}
```
Sources from `SourcesFile` are loaded after the static `Sources` block;
sources added at runtime via WS `addSource` get ids `s1, s2, …`.
### History configuration
An optional `+History` block enables disk-backed circular storage
(`HistoryWriter`). The `+` prefix is MARTe2 `StandardParser` syntax for a
child node; the hub looks for both `+History` and `History` as the node name.
```
+History = {
Directory = "/data/streamhub_history" // required
DurationHours = 1 // hours of data to retain per signal (default 1)
Decimation = 10 // keep every Nth sample (default 1)
FlushIntervalSec = 5 // header flush period in seconds (default 5)
MinDiskFreeMB = 500 // pause writes below this threshold (default 500)
}
```
Per-signal file capacity is computed at source CONFIG time:
`capacity = ceil(DurationHours × 3600 × samplingRate / Decimation)`, minimum
1000 pairs.
### `.shist` binary file format
Each signal gets one file: `<Directory>/<sourceId>/<signalName>.shist`.
The file size is fixed at creation (`64 + capacity × 16` bytes) and never grows.
```
Offset Size Field
0 4 Magic: "SHR1"
4 4 uint32 version (1)
8 4 uint32 capacity (max pairs)
12 4 uint32 head (next write position, 0-based, wraps at capacity)
16 4 uint32 count (valid entries, ≤ capacity)
20 4 uint32 decimation
24 8 float64 tOldest (Unix seconds)
32 8 float64 tNewest (Unix seconds)
40 24 reserved (zero-padded to 64 bytes)
64 … data: capacity × 16 bytes (float64 time + float64 value per pair)
```
Data is written at the `head` position and wraps circularly. The oldest valid
entry is at logical index `(head + capacity count) % capacity`. All I/O
uses `pwrite`/`pread` (no `mmap`), so concurrent reads from WS threads are
safe without locking.
Headers are flushed to disk every `FlushIntervalSec` seconds and on shutdown.
On restart, if an existing file has matching magic, version and capacity, it is
reopened — head/count/time bounds are restored from the on-disk header.
### History query path
`historyZoom` requests (see [StreamHub-API.md](StreamHub-API.md)) call
`HistoryWriter::ReadRange` which performs binary search over the circular file
using `pread` to locate the `[t0, t1]` window, then copies matching pairs.
If the result exceeds the requested `n`, LTTB decimation is applied (same
`LTTBDecimate` as in-memory zoom).
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
window, the in-memory ring covers the recent part.
## 7. Build & test
```bash
source env.sh # always, for build and run
make -f Makefile.gcc apps # builds StreamHub.ex
# or: make -C Source/Applications/StreamHub -f Makefile.gcc
./Build/x86-linux/StreamHub/StreamHub.ex -cfg hub.cfg
make -f Makefile.gcc test
./Build/x86-linux/GTest/MainGTest.ex # 71 unit tests, incl.
# SignalRingBuffer (ReadSince / binary-search ReadRange / wrap),
# TriggerEngine FSM, LTTB — sources in Test/Applications/StreamHub/
cd Test/E2E/suite && ./run_e2e.sh # full-stack E2E (see below)
./run_streamhub.sh -w -g # interactive demo stack
```
### End-to-end test
`Test/E2E/suite/run_e2e.sh` is the unified E2E suite covering the whole
streaming + debug chain (`chain`/`direct`/`recorder`/`debug`/`tcplogger`
scenario kinds, see `Test/E2E/suite/scenarios.py`), including StreamHub live
push, zoom, window and trigger checks via the Go `chain-client`. It builds
everything, runs the scenario matrix plus the stress matrix, and produces a
consolidated `report_data.json` + Typst PDF report
(`Test/E2E/suite/E2E_Report.typ`). See the script's `--help` for options.
When changing the WS protocol, update **in lockstep**: this hub, the Go hub
(`Common/Client/go/wshub`), the browser SPA (`Client/udpstreamer/static`), the
ImGui client (`Client/streamhub/Protocol.cpp`), the E2E `chain-client`
(`Test/E2E/suite/client`), and [StreamHub-API.md](StreamHub-API.md).
## 8. Gotchas
- **No STL** in this directory (MARTe2 style); use `StreamString`,
`FastPollingMutexSem`, fixed arrays.
- Link against UDPStream **dynamically** (`LIBRARIES += -lUDPStream`), never
`LIBRARIES_STATIC` — the GTest binary links every `.a` it finds and would get
duplicate symbols.
- WS text frames may arrive coalesced; never NUL-terminate a payload in place
without restoring the byte (it is the first header byte of the next frame).
- Zoom replies must print `t` with `%.17g`: at Unix-epoch magnitudes `%.6f`
destroys µs resolution.