45dcb9a71f
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.
232 lines
11 KiB
Markdown
232 lines
11 KiB
Markdown
# 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 `[trigTime−preSec, 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.
|