DATA packets timestamp with the raw value of the producer's high-resolution counter, and the wire never said how fast that counter runs. The hub divided by its own timer's frequency instead, which is only the same number while producer and hub share a machine — on x86 it is the TSC frequency and differs from model to model. Off-box, every accumulated batch was therefore laid out over the wrong span of time: the samples in it drift away from where they belong and start colliding with the next packet's, which is the "same" symptom as a stale time base even though nothing is out of order. CONFIG now carries the rate as a trailing uint64, alongside the publish-mode byte and read the same tolerant way: absent or zero means the producer did not say, and the hub falls back to its own timer as before. Anything below 1 kHz is not a high-resolution timer and is refused, so a mis-parsed payload cannot stretch a millisecond batch across seconds. The Accumulate DATA payload is unchanged, so this costs nothing per packet and the period *within* a batch is still estimated from the gap between packets. The Go, C and browser parsers already ignore trailer bytes they do not know, so they read the new CONFIG unchanged; none of them uses the HRT timestamp. Also corrects the Accumulate DATA layout in all three protocol documents: they described it as one snapshot per array signal, where it has always been one per accumulated cycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
34 KiB
Architecture
This document describes the internal architecture of the MARTe2 Integrated Components.
1. Repository Overview
DebugService path (legacy introspection / breakpoints)
┌────────────────────────────────────────────────────────────────────┐
│ MARTe2 Application │
│ ┌──────────┐ ┌───────────────────────┐ ┌──────────────────┐ │
│ │ GAM(s) │ │ DebugBrokerWrapper │ │ FastScheduler │ │
│ │ │◄──│ (Registry-patched) │ │ (unmodified) │ │
│ └──────────┘ └──────────┬────────────┘ └──────────────────┘ │
│ │ RT-path API │
└────────────────────────────┼───────────────────────────────────────┘
│
┌────────▼────────┐
│ DebugServiceI │ ← Abstract singleton interface
└────────┬────────┘
┌────────▼────────┐
│ DebugService │
│ TCP/UDP │
└────────┬────────┘
┌───────────────┼──────────────┐
│ │ │
TCP 8080 UDP 8081 TCP 8082
(commands) (telemetry) (TcpLogger)
│ │
┌────────▼───────────────▼──────┐
│ Client/debugger │
│ Go web client (browser) │
└───────────────────────────────┘
UDPStreamer / StreamHub path (high-throughput signal streaming)
┌────────────────────────────────────────────────────────────────────┐
│ MARTe2 Application │
│ ┌─────────────────────────────────────────────┐ │
│ │ UDPStreamer DataSource │ │
│ │ - Receives signals from GAMs via IOGAM │ │
│ │ - Serialises to UDPS binary protocol │ │
│ │ - Manages per-client sessions │ │
│ └──────────────────────┬──────────────────────┘ │
└─────────────────────────┼──────────────────────────────────────────┘
UDP 44500 (unicast or multicast)
│
┌───────────────▼───────────────┐
│ StreamHub │
│ Source/Applications/StreamHub│
│ - N simultaneous sources │
│ - Ring buffers per signal │
│ - LTTB decimation │
│ - Trigger engine (FSM) │
│ - M WebSocket clients │
└──────────────┬────────────────┘
WebSocket 8090
│
┌──────────────┴──────────────┐
│ │
┌───────▼──────────┐ ┌────────────▼──────────────┐
│ Browser (any) │ │ ImGui Desktop Client │
│ Client/udpstreamer│ │ Client/streamhub/ │
│ Go web server + │ │ C++ native window │
│ JS oscilloscope │ │ (SDL2 + OpenGL + ImPlot) │
└──────────────────┘ └───────────────────────────┘
2. UDPS Binary Protocol
Defined in Common/UDP/UDPSProtocol.h and shared between:
UDPStreamer(C++ producer)DebugService(C++ producer for trace packets)Common/Client/go/udpsprotocol(Go decoder)Source/Components/Interfaces/UDPStream/UDPSClient(C++ consumer)
Packet Header (17 bytes, little-endian, packed)
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 0 | 4 | uint32 | magic |
Always 0x53504455 ('UDPS' LE) |
| 4 | 1 | uint8 | type |
Packet type (DATA=0, CONFIG=1, ACK=2, CONNECT=3, DISCONNECT=4) |
| 5 | 4 | uint32 | counter |
Per-update sequence number (same across all fragments) |
| 9 | 2 | uint16 | fragmentIdx |
0-based fragment index |
| 11 | 2 | uint16 | totalFragments |
Total fragments for this update |
| 13 | 4 | uint32 | payloadBytes |
Bytes of payload following this header |
Signal Descriptor (136 bytes, packed)
| Field | Type | Description |
|---|---|---|
name |
char[64] | Null-terminated signal name |
typeCode |
uint8 | UDPS_TYPECODE_* (0=u8, 1=i8, … 9=f32, 10=f64) |
quantType |
uint8 | UDPS_QUANT_* (0=none, 1=u8, 2=i8, 3=u16, 4=i16) |
numDimensions |
uint8 | 0=scalar, 1=array, 2=matrix |
numRows |
uint32 | Element count for 1D; rows for 2D |
numCols |
uint32 | 1 for scalar/1D; columns for 2D |
rangeMin |
float64 | Physical range minimum (for dequantisation) |
rangeMax |
float64 | Physical range maximum (for dequantisation) |
timeMode |
uint8 | UDPS_TIMEMODE_* (0=packet, 1=first, 2=last, 3=full_array) |
samplingRate |
float64 | Hz; used for first/last sample interpolation |
timeSignalIdx |
uint32 | Index of time-ref signal; 0xFFFFFFFF if none |
unit |
char[32] | Null-terminated physical unit string |
CONFIG Payload
Sent when the signal set changes or a client connects:
[uint32 numSigs]
numSigs × UDPSSignalDescriptor (136 bytes each, packed)
[uint8 publishMode] 0=Strict, 1=Accumulate, 2=Decimate
[uint64 hrtFrequency] producer's HRT ticks per second; 0 = unknown
Everything after the descriptors is an optional trailer: a receiver accepts a
payload that stops early and ignores bytes it does not know. hrtFrequency is
what lets a receiver on another host turn the raw counter in DATA into seconds
— without it the only option is the receiver's own timer, which agrees with the
producer only when the two share a machine.
DATA Payload (Strict / Decimate modes)
[uint64 HRT timestamp]
per-signal data in CONFIG order (quantised or raw, no inter-signal padding)
DATA Payload (Accumulate mode)
[uint64 HRT timestamp of the first slot in the batch]
[uint32 numSamples] RT cycles accumulated into this packet
for each signal, in CONFIG order: numSamples × NumElements values
Quantization / Dequantization
When quantType != QUANT_NONE, the server maps [rangeMin, rangeMax] to the full
integer range. Clients reverse with:
| quantType | Formula |
|---|---|
| QUANT_UINT8 | rangeMin + (q / 255.0) × (rangeMax − rangeMin) |
| QUANT_INT8 | rangeMin + ((q + 127.0) / 254.0) × (rangeMax − rangeMin) |
| QUANT_UINT16 | rangeMin + (q / 65535.0) × (rangeMax − rangeMin) |
| QUANT_INT16 | rangeMin + ((q + 32767.0) / 65534.0) × (rangeMax − rangeMin) |
3. DebugService Architecture
3.1 Registry Patching (Zero-Code-Change Instrumentation)
DebugService::PatchRegistry() replaces the ObjectBuilder for all MemoryMap*Broker
types in the MARTe2 ClassRegistryDatabase. Any subsequent ConfigureApplication() call
will instantiate DebugBrokerWrapper<T> objects instead of the originals. No application
source changes are needed.
Wrapped types: MemoryMapInputBroker, MemoryMapOutputBroker,
MemoryMapSynchronisedInputBroker, MemoryMapSynchronisedOutputBroker,
MemoryMapMultiBufferBroker, MemoryMapMultiBufferOutputBroker,
MemoryMapAsynchronousInputBroker, MemoryMapAsynchronousOutputBroker,
MemoryMapInterpolatedInputBroker, MemoryMapStatefulOutputBroker,
MemoryMapStatefulInputBroker.
3.2 Signal Registration
ConfigureApplication()
└─► DebugBrokerWrapper::Init()
└─► DebugBrokerHelper::InitSignals()
├─► DebugServiceI::RegisterSignal() (canonical + GAM alias)
└─► DebugServiceI::RegisterBroker()
Each signal is registered twice:
- Canonical:
<DataSourcePath>.<SignalName>(e.g.App.Data.DDB.Counter) - GAM alias:
<GAMPath>.In.<SignalName>or<GAMPath>.Out.<SignalName>
Both map to the same DebugSignalInfo*. AliasMatch() in DebugServiceBase.cpp
performs bidirectional suffix matching so short unqualified names work in commands.
3.3 RT Hot Path
RealTimeThread::Execute()
└─► DebugBrokerWrapper::Execute()
├─► Base::Execute() (actual data movement)
└─► DebugBrokerHelper::Process()
├─► For each active signal:
│ └─► DebugServiceI::ProcessSignal()
│ ├─► if isForcing: memcpy forcedValue → signal memory
│ ├─► if isTracing & decimation fires: push to TraceRingBuffer
│ └─► if breakOp set: evaluate condition → SetPaused(true)
└─► (output brokers only) ConsumeStepIfNeeded()
3.4 TraceRingBuffer
Single-producer/single-consumer circular byte buffer (4 MB default).
Entry format: [ID:4][Timestamp:8][Size:4][Data:N].
Push()serialised bytracePushMutex(multiple RT threads may write)Pop()called exclusively by the Streamer thread- Corrupt entries detected by
size >= bufferSize; discarded gracefully
3.5 DebugService Threads
| Thread | Role |
|---|---|
Server() |
Accepts one TCP client; reads text commands; writes JSON/text responses |
Streamer() |
Drains TraceRingBuffer; assembles/sends UDP datagrams; polls monitored signals |
3.6 DebugServiceI Abstraction
DebugServiceI.h defines a pure-virtual singleton so transport implementations
(DebugService TCP/UDP, WebDebugService HTTP/SSE) share the same broker injection layer.
DebugServiceI::SetInstance(this); // concrete implementation registers itself
DebugServiceI *svc = DebugServiceI::GetInstance(); // broker wrapper retrieves it
4. UDPStreamer DataSource
UDPStreamer is a standard MARTe2 DataSourceI that:
- Maintains a list of registered client sessions (UDP source address + port)
- On each
Synchronise()call (once per RT cycle):- Serialises all configured signals into one or more UDPS DATA packets
- Sends to all connected clients
- Handles
CONNECT/DISCONNECT/ACKpackets from clients - Sends
CONFIGpackets when the signal list changes or a new client connects - Applies quantization (
QuantizedType,RangeMin,RangeMax) per signal - Supports unicast and multicast UDP delivery (configurable per instance)
Packed Signals (Accumulate mode)
When a signal has NumberOfElements > 1 and SamplingRate is configured:
TimeMode = FirstSample: theTimeSignalvalue anchors the burst timestamp- Clients interpolate per-sample timestamps as
t0 + e/SamplingRate UDPStreamerpacks all elements contiguously with no per-element header
5. StreamHub C++ Application
StreamHub (Source/Applications/StreamHub/) is a headless standalone process that
bridges N MARTe2 UDPStreamer sources to M simultaneous WebSocket clients. It replaces the
Go-based Client/udpstreamer/ hub for deployments where a Go toolchain is unavailable
or where the hub must run as a C++ service.
Components
| File | Purpose |
|---|---|
main.cpp |
Entry point; parses -cfg, -port, -maxPoints flags |
StreamHub.h/.cpp |
Top-level orchestrator; push loop; WS command dispatch |
UDPSourceSession.h/.cpp |
One UDPS source: UDPSClient + ring buffers + wall-clock calibration + stats |
TriggerEngine.h/.cpp |
FSM: IDLE / ARMED / COLLECTING / TRIGGERED (web-client semantics) |
WSServer.h/.cpp |
RFC 6455 WebSocket server; multi-client accept+read threads |
WSFrame.h |
WebSocket frame encode/decode helpers |
SignalRingBuffer.h |
Thread-safe fixed-capacity circular buffer (float64 t, v) |
UDPSourceStats.h |
Per-source statistics struct |
LTTB.h |
Largest Triangle Three Buckets decimation (header-only) |
SHA1.h |
Inline SHA-1 for WebSocket handshake Accept key |
Base64.h |
Inline Base64 encoder for WebSocket handshake |
Time Base (wall-clock calibration)
All ring-buffer timestamps are Unix wall-clock seconds (float64). Each
UDPSourceSession calibrates source time to the hub's CLOCK_REALTIME on the
first packet (offset = wallNow − sourceTime) and re-anchors automatically if the
computed time drifts more than 2 s from arrival time (source restart). Per-sample
time depends on the signal's UDPS timeMode:
| timeMode | Per-sample timestamp |
|---|---|
FirstSample / LastSample |
calibrated time-signal anchor ± k/samplingRate |
FullArray |
calibrated per-element time-signal array |
| Packet (scalar) | packet arrival wall time |
| Packet (array, n>1) | elements span (lastPktWall, wallNow], interpolated from the inter-packet gap |
Push Loop
Runs at PushRate Hz (default 30 Hz):
- For each configured source:
- On first CONFIG: broadcast
sources+config, reset push cursors - For each signal: read only the samples written since the last tick (per-signal monotonic cursor into the ring; overrun clamps to oldest)
- LTTB-decimate temporal signals to
MaxPushPoints(default 50); scalars and packet-timed arrays go verbatim - Serialise one binary v1 frame per source; broadcast to all WS clients
- Cursors advance even with zero clients (no backlog burst on connect)
- On first CONFIG: broadcast
- Trigger servicing (capture finalisation, auto-rearm, state events)
- Every
PushRate / StatsRateticks: broadcast JSON stats frame
Pushing only-new samples is essential: re-reading overlapping windows and re-LTTBing them picks different points per window and corrupts the traces.
Trigger Engine
Hub-side trigger with the web client's semantics (config: signal key
"src:sig" or "src:sig[i]", edge rising/falling/both, threshold, window
0.1 ms–10 s, pre-trigger percent, mode normal/single):
IDLE →[arm]→ ARMED
ARMED →[edge crossing]→ COLLECTING (latches trigTime, pre/postSec)
COLLECTING →[every source produced past the window]→ TRIGGERED (broadcast binary v2 capture)
TRIGGERED →[auto-rearm (normal, ~200 ms) | rearm (single)]→ ARMED
any →[disarm]→ IDLE
CheckSample() is called from the UDPSClient receive thread for every decoded
sample of the configured signal. The capture is assembled in the push loop from
ReadSignalRange(trigTime−preSec, trigTime+postSec) over all signals,
LTTB-capped at 20 000 points/signal, and broadcast as a binary version-2 frame.
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)
Hub = {
WSPort = 8090
MaxPoints = 20000 // zoom/live window cap (setMaxPoints)
PushRate = 30 // push loop Hz
MaxPushPoints = 50 // LTTB cap per signal per tick
StatsRate = 1 // stats broadcast Hz
RingTemporal = 1000000 // initial ring capacity (points) for multi-element 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
AllowedOrigins = "http://127.0.0.1:8099,http://localhost:8099" // see below
Sources = {
App1 = {
Label = "MARTe2 App 1"
Addr = "127.0.0.1"
Port = 44500
}
App2 = {
Label = "MARTe2 App 2"
Addr = "192.168.1.10"
Port = 44501
MulticastGroup = "239.0.0.1"
DataPort = 44504
}
}
}
Sources added at runtime (addSource) get generated ids s1, s2, …;
saveSources persists them to SourcesFile (JSON array of
{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
MARTe2_DIR=/path/to/MARTe2 \
make -C Source/Applications/StreamHub -f Makefile.gcc TARGET=x86-linux
# Produces: Build/x86-linux/StreamHub/StreamHub.ex
6. StreamHub WebSocket Protocol
All client↔hub communication uses WebSocket (RFC 6455). Text frames carry JSON; binary frames carry data push payloads.
Commands (client → hub, JSON text frames)
type field |
Additional fields | Action |
|---|---|---|
ping |
— | Hub replies {"type":"pong"} |
addSource |
label, addr ("host:port"), multicastGroup?, dataPort? |
Connect to a new UDPS source; hub assigns id s1, s2, … |
removeSource |
id |
Disconnect and remove a source |
saveSources |
— | Persist the dynamic source list and the calibration table to SourcesFile; replies configSaved |
setCalibration |
source (label), signal (base name), scale, offset, unit |
Record value = raw × scale + offset for one signal; metadata only, the hub never applies it. Identity entries are deleted. Replies with a calibration broadcast |
reloadConfig |
— | Re-read SourcesFile: calibration replaced wholesale, missing sources added, live sources never touched; replies configReloaded |
getSources |
— | Trigger sources broadcast |
getConfig |
sourceId |
Trigger config broadcast for one source |
getStats |
— | Trigger stats broadcast |
setTrigger |
signal ("src:sig" or "src:sig[i]"), edge ("rising"|"falling"|"both"), threshold, windowSec, prePercent, mode ("normal"|"single") |
Configure the hub-side trigger |
arm |
— | IDLE → ARMED |
disarm |
— | Any state → IDLE |
rearm |
— | TRIGGERED → ARMED (used in single mode) |
trigStop |
stopped? (bool; absent = toggle) |
Suppress auto-rearm in normal mode |
zoom |
reqId, t0, t1, n?, signals? (comma-separated "src:sig" keys; absent = all) |
Hi-res window read; reply is unicast to the requester. n absent → 2400; n≤0 → no decimation |
setMaxPoints |
maxPoints |
Resize all ring buffers (applied in push loop; cursors reset) |
Events (hub → client, JSON text frames)
type field |
Fields | When sent |
|---|---|---|
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 |
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?, preSec?, postSec? |
On any trigger FSM transition |
zoom |
reqId, signals:{"src:sig":{t:[…], v:[…]}} (t printed %.17g, v %.9g) |
Unicast reply to zoom |
maxPointsUpdated |
maxPoints |
After ring buffer resize |
calibration |
cal:[{source, signal, scale, offset, unit}] |
On connect; after an accepted setCalibration; after a successful reloadConfig |
configSaved |
ok, path, error? |
In reply to saveSources |
configReloaded |
ok, path, error? |
In reply to reloadConfig |
pong |
— | In reply to ping |
Config File Format
SourcesFile is a flat JSON array of flat objects; addr marks a source,
signal marks a calibration entry.
[
{"label": "wave", "addr": "127.0.0.1:44500"},
{"source": "wave", "signal": "Adc", "scale": 0.00030518, "offset": -1.25, "unit": "V"}
]
Flatness is a hard constraint: StreamHub::LoadSourcesFile scans from each {
to the next }, so a nested object would truncate the parse. Both hubs read and
write this format identically, and pre-calibration files load unchanged.
Calibration is applied client-side only. Rings, history, zoom replies, both
binary frames and the trigger comparator are all in raw units.
Binary Push Frame (version 1, hub → client, binary WS frame)
Little-endian throughout. Sent at PushRate Hz per source; contains only
samples new since the previous push (per-signal cursors), decimated to
MaxPushPoints per signal.
[1] version = 1
[1] sourceIdLen (L)
[L] sourceId (UTF-8, no null terminator)
[4] numSignals (uint32 LE)
Per signal:
[2] keyLen (uint16 LE) (K)
[K] key = signal name (UTF-8; "name[i]" per element for multi-element PACKET signals)
[4] pairCount (uint32 LE) (N)
[N×8] time array (float64 LE, Unix wall-clock seconds)
[N×8] value array (float64 LE, physical units)
Binary Capture Frame (version 2, hub → client, binary WS frame)
Broadcast once per trigger capture (FSM COLLECTING → TRIGGERED). Each signal is LTTB-decimated to at most 20 000 points.
[1] version = 2
[8] trigTime (float64 LE, Unix seconds)
[8] preSec (float64 LE)
[8] postSec (float64 LE)
[4] numSignals (uint32 LE)
Per signal:
[2] keyLen (uint16 LE) (K)
[K] fullKey = "src:sig" (UTF-8)
[4] pairCount (uint32 LE) (N)
[N×8] time array (float64 LE, Unix seconds)
[N×8] value array (float64 LE)
7. ImGui Desktop Client
Client/streamhub/ is a native C++ desktop oscilloscope that connects directly to
StreamHub via WebSocket and provides the same feature set as the browser client.
Technology Stack
| Component | Library | Notes |
|---|---|---|
| UI framework | Dear ImGui (v1.91.x) | Fetched by CMake FetchContent |
| Time-series plots | ImPlot (v0.17.x) | Fetched by CMake FetchContent |
| Window + input | SDL2 | System package (sdl2) |
| GPU rendering | OpenGL 3.3 core | System package |
| WebSocket client | Custom (POSIX) | Reuses WSFrame.h, SHA1.h, Base64.h from StreamHub |
Features
| Feature | Implementation |
|---|---|
| Source sidebar | ImGui::TreeNodeEx per source; draggable signal leaves |
| Drag & drop signals | ImGui::BeginDragDropSource → ImGui::BeginDragDropTarget on plots |
| Multi-plot layout | 1×1, 1×2, 2×1, 1×3, 3×1, 2×2, 1×4, 4×1 grid; ImGui::BeginTable |
| Live data streaming | Binary v1 frame parser → per-signal ring buffer → ImPlot; live x-axis follows wall clock |
| LTTB decimation | Client-side, before plotting |
| Pause / resume | Per-plot toggle; frozen data still rendered |
| Zoom | ImPlot scroll+drag with history stack (Back / Fit / Reset-to-live); hub WS zoom for hi-res when zoomed in |
| Cursor A / B | ImPlot DragLineX annotations + ΔT and per-trace readouts |
| Trigger panel | Signal/edge/threshold/window/pre%/mode controls; Arm/Disarm/Rearm/Stop; badge (IDLE/ARMED/COLLECTING/TRIGGERED) |
| Trigger capture view | Binary v2 frames rendered relative to trigTime in [-preSec, +postSec], marker at 0 |
| V-scale modes | normal / digital / mixed normalisation (parity with web client) |
| Stats panel | Per-source rate/cycle stats + 20-bin cycle-time histogram (ImPlot::PlotBars) |
| Add / remove source | Modal dialog; sends addSource / removeSource JSON |
| Signal styles | Per-signal color picker, line width, markers |
| Auto-reconnect | WSClient receive thread retries every 3 s |
Build
# Install SDL2 (Arch Linux)
sudo pacman -S sdl2
cd Client/streamhub
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
# Produces: build/StreamHubClient
# Run (StreamHub must already be running on port 8090)
./build/StreamHubClient -host 127.0.0.1 -port 8090
7b. Qt Desktop Client
Client/streamhub-qt/ is a native Qt Widgets desktop oscilloscope — a
feature/UX-equivalent alternative to the ImGui client, speaking the identical
StreamHub WebSocket protocol. It targets deployments that prefer a system Qt
runtime over bundled ImGui/SDL2/OpenGL, and it builds against either Qt6
(preferred) or Qt5 for old-Linux back-compatibility.
Technology Stack
| Component | Library | Notes |
|---|---|---|
| UI framework | Qt Widgets | Autodetect Qt6 → Qt5 via find_package(QT NAMES Qt6 Qt5 ...), then Qt${QT_VERSION_MAJOR}:: targets |
| Time-series plots | Custom QPainter (PlotWidget) |
No QtCharts/QCustomPlot — for ImPlot parity, zero extra deps, EUPL-clean |
| WebSocket client | QtWebSockets QWebSocket (WsClient) |
Signals delivered on the GUI thread |
| Wire layer | Reused verbatim from ../streamhub/ |
Protocol.{h,cpp}, SignalBuffer.h (framework-free C++17) |
Threading & keyword model
- Single GUI thread.
QWebSockettext/binary signals arrive on the GUI thread, so no locks are needed (unlike the ImGui client's background receive thread + drain). A 60 HzQTimer(16 ms) drives repaint and panel refresh. QT_NO_KEYWORDS. The reusedProtocol.h/Model.hstructs have members namedsignals(e.g.ZoomResponse::signals), which collide with Qt'ssignals/slots/emitmacros. The build definesQT_NO_KEYWORDS; all Qt classes here useQ_SIGNALS:/Q_SLOTS:/Q_EMITinstead. This keeps the shared wire layer unmodified.
Components
| File | Responsibility |
|---|---|
Hub.{h,cpp} |
Domain model + command builders; owns WsClient; re-emits parsed events as Qt signals |
WsClient.{h,cpp} |
QWebSocket wrapper; auto-reconnect (3 s timer) |
PlotWidget.{h,cpp} |
One QPainter plot; live/stored/trigger modes, cursors, zoom cache |
PlotGrid.{h,cpp} |
Persistent pool of 8 PlotWidgets mounted into nested QSplitters per layout |
SourceSidebar.{h,cpp} |
QTreeWidget of sources/signals; drag source = mime application/x-shq-signal carrying qint32[2] {srcIdx, sigIdx} (LittleEndian) |
TriggerBar.{h,cpp} |
Trigger config/arm controls + state badge |
StatsDialog.{h,cpp} |
Per-source stats table + 20-bin QPainter histogram |
HistoryBar.{h,cpp} |
Live / pan / jump-ago / show-all history navigation |
MainWindow.{h,cpp} |
Toolbars, docks, layout menu, connection controls, 60 Hz tick |
Build & run
cd Client/streamhub-qt
cmake -B build # autodetects Qt6, falls back to Qt5
cmake --build build -j$(nproc)
# Produces: build/StreamHubQtClient
# Run (StreamHub must already be running on port 8090)
./build/StreamHubQtClient --host 127.0.0.1 --port 8090
Use long
--host/--port(or short-H/-p). A single-dash-hostis misparsed byQCommandLineParseras clustered short flags.
Verified: builds and links cleanly against both Qt6 (6.11) and Qt5 (5.15); the Qt6 binary connects to a live StreamHub (server logs "WebSocket client connected") and runs without error.
8. Go Client Packages
Common/Client/go/udpsprotocol
Pure Go decoder for UDPS packets:
Decoder— reassembles fragments; returns complete CONFIG or DATA payloadsSignalDescriptor— mirrorsUDPSSignalDescriptorfrom C++DataPacket— decoded signal values with per-sample timestamps
Common/Client/go/wshub
WebSocket broadcast hub:
- Multiple browser clients share one UDP → WebSocket relay
- JSON-encodes signal samples and broadcasts to all connected browsers
Client/debugger
Go HTTP server providing the debug web UI:
main.go— CLI entry point; starts TCP relay and HTTP servermartecontrol.go— TCP client toDebugService; routes commands from browserstatic/— Single-page application (HTML/JS/CSS) with Chart.js plots
Client/udpstreamer
Go HTTP server providing the UDP streamer oscilloscope web UI:
main.go— CLI entry point; connects to one or more UDPStreamer portsstatic/— Full-featured SPA (uPlot plots, LTTB decimation, trigger UI, zoom)- Compatible with StreamHub's WebSocket protocol (identical binary frame format)
9. Key Design Decisions
| Decision | Rationale |
|---|---|
Shared UDPSProtocol.h in Common/UDP/ |
Eliminates duplication between UDPStreamer, UDPSClient, and any future UDPS producer; Go packages decode the same binary layout |
DebugServiceI abstract interface |
Allows DebugService (TCP/UDP) and WebDebugService (HTTP/SSE) to share broker injection without coupling |
| No STL in C++ MARTe2 components | MARTe2 coding convention; FastPollingMutexSem and fixed arrays used throughout |
| STL allowed in ImGui client | Client/streamhub/ is not a MARTe2 component; C++17 + STL reduce boilerplate without any architectural conflict |
FastPollingMutexSem on hot path |
Lowest-latency synchronisation primitive available in MARTe2; avoids OS scheduler involvement |
Separate TCPLogger component |
Decoupled from DebugService; can be used with any MARTe2 application independently |
| C++ StreamHub instead of Go hub | Single binary, no Go toolchain required on deployment host; reuses MARTe2 UDPSClient directly; embeds trigger engine |
| ImPlot for time-series in native client | Best-in-class ImGui plotting extension; handles millions of points with GPU acceleration; native zoom/pan without custom code |
Reuse WSFrame.h / SHA1.h / Base64.h in ImGui client |
Avoids duplicating the WebSocket framing implementation; CMake include path points directly at Source/Applications/StreamHub/ |
| POSIX WebSocket client (no external library) | Keeps the client self-contained; RFC 6455 frame parsing is already implemented in WSFrame.h; only handshake SHA-1 and Base64 are needed |
| Go for browser-based clients | Minimal dependencies, single binary, easy cross-compilation; no Node/npm toolchain required for the web UI path |