# 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` 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: 1. **Canonical**: `.` (e.g. `App.Data.DDB.Counter`) 2. **GAM alias**: `.In.` or `.Out.` 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 by `tracePushMutex` (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. ```cpp 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: 1. Maintains a list of registered client sessions (UDP source address + port) 2. On each `Synchronise()` call (once per RT cycle): - Serialises all configured signals into one or more UDPS DATA packets - Sends to all connected clients 3. Handles `CONNECT` / `DISCONNECT` / `ACK` packets from clients 4. Sends `CONFIG` packets when the signal list changes or a new client connects 5. Applies quantization (`QuantizedType`, `RangeMin`, `RangeMax`) per signal 6. Supports unicast and multicast UDP delivery (configurable per instance) ### TCP variant `TCPStreamer` / `TCPStreamerClient` subclass the UDP DataSources and replace the transport (`TCPSServer` / `TCPSClient`) behind the `UDPSServerI` / `UDPSClientI` seam (`UDPSTransportI.h`). Framing is unchanged (`totalFragments` is always 1); each client has a bounded queue, so a slow client drops only its own frames. `TCPStreamService` (`Source/Components/Interfaces/TCPStreamService/`) is an optional in-process server: one acceptor plus `Workers` I/O threads serve many `TCPSServiceStream`s (one per `TCPStreamer` with `Service=`/`StreamName=`). Producers only copy frames into per-connection queues and wake the worker via a self-pipe. Lock order: topology, stream, connection. A client picks streams with a `SUBSCRIBE` frame (type 5): - **Multiplexing**: the payload may be a comma separated list; each server frame carries the stream's list index in `fragmentIdx`; queues are accounted per stream. `TCPSClient` demultiplexes into per-stream listeners (`GetStream(i)` facades), and StreamHub creates one session per stream over one shared client. - **Joining**: `TCPStreamJoin` taps several streams (`TCPSStreamTapI`), watches the service for (un)registration (`TCPSStreamObserverI`) and publishes a merged Strict stream: CONFIG = concatenated descriptors named `_`; one DATA frame per master frame with the other sources held at their last value. No extra thread. Accumulate sources and differing HRT frequencies are refused. Lock order: source tap, join, output stream, connection queue. See `Docs/TCPStreamer.md`. ### Packed Signals (Accumulate mode) When a signal has `NumberOfElements > 1` and `SamplingRate` is configured: - `TimeMode = FirstSample`: the `TimeSignal` value anchors the burst timestamp - Clients interpolate per-sample timestamps as `t0 + e/SamplingRate` - `UDPStreamer` packs 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): 1. 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) 2. Trigger servicing (capture finalisation, auto-rearm, state events) 3. Every `PushRate / StatsRate` ticks: 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 } } } ``` A TCP source sets `Transport = "tcp"` and, for a `TCPStreamService`, `StreamName = ""`; a comma list (`"a,b"`) takes several streams over one connection and creates one session per stream (label `