diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b74b7cc..6daa99e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,13 +6,15 @@ This document describes the internal architecture of the MARTe2 Integrated Compo ## 1. Repository Overview +### DebugService path (legacy introspection / breakpoints) + ``` ┌────────────────────────────────────────────────────────────────────┐ -│ MARTe2 Application │ -│ ┌──────────┐ ┌───────────────────────┐ ┌──────────────────┐ │ -│ │ GAM(s) │ │ DebugBrokerWrapper │ │ FastScheduler │ │ -│ │ │◄──│ (Registry-patched) │ │ (unmodified) │ │ -│ └──────────┘ └──────────┬────────────┘ └──────────────────┘ │ +│ MARTe2 Application │ +│ ┌──────────┐ ┌───────────────────────┐ ┌──────────────────┐ │ +│ │ GAM(s) │ │ DebugBrokerWrapper │ │ FastScheduler │ │ +│ │ │◄──│ (Registry-patched) │ │ (unmodified) │ │ +│ └──────────┘ └──────────┬────────────┘ └──────────────────┘ │ │ │ RT-path API │ └────────────────────────────┼───────────────────────────────────────┘ │ @@ -32,28 +34,41 @@ This document describes the internal architecture of the MARTe2 Integrated Compo │ 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 │ │ -│ └──────────────────────┬──────────────────────┘ │ +│ MARTe2 Application │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ UDPStreamer DataSource │ │ +│ │ - Receives signals from GAMs via IOGAM │ │ +│ │ - Serialises to UDPS binary protocol │ │ +│ │ - Manages per-client sessions │ │ +│ └──────────────────────┬──────────────────────┘ │ └─────────────────────────┼──────────────────────────────────────────┘ - UDP 44500 + UDP 44500 (unicast or multicast) │ - ┌───────────▼──────────────┐ - │ Common/Client/go │ - │ udpsprotocol package │ - │ (decode CONFIG+DATA) │ - └───────────┬──────────────┘ - │ WebSocket - ┌───────────▼──────────────┐ - │ Browser oscilloscope │ - │ (Chart.js plots) │ - └──────────────────────────┘ + ┌───────────────▼───────────────┐ + │ 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) │ + └──────────────────┘ └───────────────────────────┘ ``` --- @@ -65,45 +80,72 @@ 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 | +| 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] +[uint8 publishMode] 0=Strict/Decimate, 1=Accumulate ``` ### 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] [uint32 numSamples] for each signal: if scalar → numSamples elements; else → NumElements once ``` -### Quantization +### Quantization / Dequantization -When `QuantizedType` is set on a signal, the server maps `[RangeMin, RangeMax]` to the -full integer range of the quantized type before transmission. The Go client reverses this -using the `Scale` and `Offset` fields from `UDPSSignalDescriptor`. +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)` | --- @@ -112,8 +154,8 @@ using the `Scale` and `Offset` fields from `UDPSSignalDescriptor`. ### 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 +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`, @@ -134,10 +176,11 @@ ConfigureApplication() ``` 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` +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 @@ -166,9 +209,9 @@ Entry format: `[ID:4][Timestamp:8][Size:4][Data:N]`. ### 3.5 DebugService Threads -| Thread | Role | -|---|---| -| `Server()` | Accepts one TCP client; reads text commands; writes JSON/text responses | +| 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 @@ -194,21 +237,273 @@ DebugServiceI *svc = DebugServiceI::GetInstance(); // broker wrapper retrieves i 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) ### Packed Signals (Accumulate mode) When a signal has `NumberOfElements > 1` and `SamplingRate` is configured: + - `TimeMode = FirstSample`: the `TimeSignal` value anchors the burst timestamp -- The Go client interpolates per-sample timestamps as `t0 + e/SamplingRate * dt` +- Clients interpolate per-sample timestamps as `t0 + e/SamplingRate` - `UDPStreamer` packs all elements contiguously with no per-element header --- -## 5. Go Client Packages +## 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 →[post window + margin elapsed]→ 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. + +### 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 // ring capacity (points) for multi-element signals + RingScalar = 100000 // ring capacity (points) for scalar signals + SourcesFile = "streamhub_sources.json" // dynamic-source persistence + 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. + +### Build + +```bash +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 current dynamic source list to `SourcesFile` (JSON) | +| `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?` | 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 | +| `pong` | — | In reply to `ping` | + +### 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 + +```bash +# 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 +``` + +--- + +## 8. Go Client Packages ### `Common/Client/go/udpsprotocol` Pure Go decoder for UDPS packets: + - `Decoder` — reassembles fragments; returns complete CONFIG or DATA payloads - `SignalDescriptor` — mirrors `UDPSSignalDescriptor` from C++ - `DataPacket` — decoded signal values with per-sample timestamps @@ -216,25 +511,40 @@ Pure Go decoder for UDPS packets: ### `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 server - `martecontrol.go` — TCP client to `DebugService`; routes commands from browser - `static/` — 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 ports +- `static/` — Full-featured SPA (uPlot plots, LTTB decimation, trigger UI, zoom) +- Compatible with StreamHub's WebSocket protocol (identical binary frame format) + --- -## 6. Key Design Decisions +## 9. Key Design Decisions | Decision | Rationale | -|---|---| -| Shared `UDPSProtocol.h` in `Common/UDP/` | Eliminates duplication between `UDPStreamer` and any future UDP producer; Go packages decode the same binary layout | +|----------|-----------| +| 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++ components | MARTe2 coding convention; `Vec` used instead of `std::vector` | +| 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 | -| Go for clients | Minimal dependencies, single binary, easy cross-compilation; no Node/npm toolchain required | +| 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 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..39b79f3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +MARTe2 component library providing real-time signal streaming (`UDPStreamer` DataSource → StreamHub → web/ImGui oscilloscopes) and zero-code-change debugging (`DebugService` Interface: trace/force/breakpoint via registry-patched broker wrappers). See `ARCHITECTURE.md` for full detail and `Docs/` for per-component references. + +## Build & Test + +Requires MARTe2 and MARTe2-components built externally; `env.sh` sets `MARTe2_DIR`, `MARTe2_Components_DIR`, `TARGET=x86-linux`, and `LD_LIBRARY_PATH` (needed both for building and for running anything). + +```bash +source env.sh + +make -f Makefile.gcc core # all C++ MARTe2 components +make -f Makefile.gcc apps # StreamHub standalone app +make -f Makefile.gcc test # build GTest + Integration tests +make -f Makefile.gcc clean + +# Run tests (after source env.sh) +./Build/x86-linux/GTest/MainGTest.ex # unit tests (UDPStreamer) +./Build/x86-linux/GTest/MainGTest.ex --gtest_filter='Name*' # single test +./Build/x86-linux/Test/Integration/Integration/IntegrationTests.ex # DebugService integration tests + +# Build a single component (each component dir has its own Makefile.gcc) +make -C Source/Components/DataSources/UDPStreamer -f Makefile.gcc + +# Go clients +cd Common/Client/go && go build ./... +cd Client/debugger && go build ./... + +# ImGui desktop client (not a MARTe2 component; needs SDL2) +cd Client/streamhub && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build +``` + +End-to-end demo scripts (build + launch full stack, see headers for ports/options): `./run_combined_test.sh`, `./run_streamhub.sh`. + +Build output goes to `Build/x86-linux/` (shared libs per component, `.ex` executables). + +## Architecture + +Two independent data paths: + +1. **Streaming path**: `UDPStreamer` DataSource serialises signals each RT cycle to UDPS binary packets (UDP 44500, unicast/multicast) → `StreamHub` (`Source/Applications/StreamHub/`, headless C++ app: ring buffers, LTTB decimation, trigger FSM) → WebSocket 8090 → browser (`Client/udpstreamer`, Go) or native ImGui client (`Client/streamhub`). +2. **Debug path**: `DebugService` patches the `ClassRegistryDatabase` at `Initialise()` so subsequent `ConfigureApplication()` instantiates `DebugBrokerWrapper` around all `MemoryMap*Broker` types — no application changes. RT hot path goes through `DebugServiceI` (abstract singleton in `DebugServiceI.h`) for forcing/tracing/breakpoints. Exposes TCP 8080 (text commands), UDP 8081 (trace telemetry), works with `TcpLogger` on 8082. Web UI: `Client/debugger` (Go). + +**Shared wire format**: `Common/UDP/UDPSProtocol.h` defines the UDPS binary protocol (17-byte packed header, 136-byte signal descriptors, little-endian). It is deliberately MARTe2-free so it's shared by C++ producers (`UDPStreamer`, `DebugService`), the C++ consumer (`Source/Components/Interfaces/UDPStream/UDPSClient`), and the Go decoder (`Common/Client/go/udpsprotocol`). Changes to the protocol must be mirrored across all of these, plus the JS client parsers. + +**StreamHub WebSocket protocol**: JSON text frames for commands/events, binary frames for data pushes — spec in `ARCHITECTURE.md` §6. The Go hub (`Client/udpstreamer`) and C++ StreamHub implement the identical protocol; both clients (browser JS and ImGui) must stay compatible with both. + +## Conventions + +- **No STL in MARTe2 components** (`Source/Components/`): use MARTe2 types (`StreamString`, `FastPollingMutexSem`, fixed arrays). STL/C++17 is fine in `Client/streamhub/` and `Source/Applications/StreamHub/` follows MARTe2 style but links MARTe2 core. +- `FastPollingMutexSem` on RT hot paths (never OS mutexes). +- Each component dir has `Makefile.gcc` (wrapper), `Makefile.inc` (sources/includes), and `depends.x86-linux`/`dependsRaw.x86-linux` (generated dependency files). +- EUPL v1.1 license headers on C++ sources. diff --git a/Client/debugger/martecontrol.go b/Client/debugger/martecontrol.go index 45b2bef..79b1198 100644 --- a/Client/debugger/martecontrol.go +++ b/Client/debugger/martecontrol.go @@ -82,6 +82,14 @@ type MarteController struct { cmdPort int udpPort int logPort int + + // rawSigs holds the most-recently received UDP CONFIG signals before + // translation. After DISCOVER populates m.signals, we re-translate and + // push a corrected config to the hub so that src.signals names match the + // s.Values keys used by ParseData (which also re-translates rawSigs). + rawSigsMu sync.RWMutex + rawSigs []udpsprotocol.SignalInfo + rawPm uint8 } // NewMarteController creates a MarteController bound to the given hub. @@ -483,8 +491,19 @@ func (m *MarteController) handleJSONResponse(tag, data string) { all := append(m.discoverAcc, resp.Signals...) m.discoverAcc = nil m.parseDiscoverSignals(all) - // Synthesize SignalInfo for the hub so the plot system gets a config - m.synthesizeHubConfig(all) + // Update hub config now that m.signals is populated for name translation. + // If UDP CONFIG was already received (rawSigs non-empty), re-translate the + // real config so that src.signals uses the same names as s.Values produced + // by ParseData (which also re-translates rawSigs on every PktData). + // If UDP CONFIG has not arrived yet, fall back to synthesizing from DISCOVER. + m.rawSigsMu.RLock() + raw := m.rawSigs + m.rawSigsMu.RUnlock() + if len(raw) > 0 { + m.hub.UpdateConfigForSource("debug", m.translateSignalNames(raw)) + } else { + m.synthesizeHubConfig(all) + } // Re-marshal the merged list so the browser gets a single consistent blob. merged, _ := json.Marshal(discoverResp{Signals: all}) broadcastHub(m.hub, map[string]any{ @@ -747,7 +766,15 @@ func (m *MarteController) synthesizeHubConfig(sigs []discoverSignalJSON) { } sigInfos = append(sigInfos, si) } - m.hub.UpdateConfigForSource("debug", sigInfos) + // Translate to GAM-path names so the synthesised config matches the names + // that the real UDP CONFIG (and data frames) use. Without this translation, + // synthesizeHubConfig would overwrite the real config with DISCOVER-canonical + // names (e.g. "DDB1.Ch3"), causing buildBinaryDataMessageForSource to fail to + // find signals in s.Values (which uses translated names), starving the JS + // buffer and limiting live streaming to the fraction of a second that + // accumulated before the DISCOVER response arrived. + translated := m.translateSignalNames(sigInfos) + m.hub.UpdateConfigForSource("debug", translated) } // --------------------------------------------------------------------------- @@ -829,6 +856,12 @@ func (m *MarteController) runDebugUDP(host string, port int) { log.Printf("[debug-udp] parse config: %v", err) continue } + // Store raw (untranslated) sigs so that after DISCOVER populates + // m.signals we can re-translate and push a corrected config to the hub. + m.rawSigsMu.Lock() + m.rawSigs = sigs + m.rawPm = pm + m.rawSigsMu.Unlock() sigs = m.translateSignalNames(sigs) currentSigs = sigs currentPublishMode = pm @@ -839,6 +872,17 @@ func (m *MarteController) runDebugUDP(host string, port int) { if len(currentSigs) == 0 { continue } + // Re-translate on every data packet: if DISCOVER ran since the last + // UDP CONFIG arrived (which is the common case — CONFIG is sent once + // at startup, before the browser connects and triggers DISCOVER), + // m.signals is now populated and we must use translated names so that + // s.Values keys match src.signals keys in buildBinaryDataMessageForSource. + m.rawSigsMu.RLock() + raw := m.rawSigs + m.rawSigsMu.RUnlock() + if len(raw) > 0 { + currentSigs = m.translateSignalNames(raw) + } samples, err := udpsprotocol.ParseData(complete, currentSigs, currentPublishMode, arrivalTime) if err != nil { log.Printf("[debug-udp] parse data: %v", err) diff --git a/Client/debugger/static/app.js b/Client/debugger/static/app.js index 65461f3..3afa5c2 100644 --- a/Client/debugger/static/app.js +++ b/Client/debugger/static/app.js @@ -338,6 +338,12 @@ function connectWS() { ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected – waiting for data'); }; ws.onclose = () => { setStatus('red', 'Disconnected (reconnecting…)'); + // Reset MARTe connection state so that when the WS reconnects the hub can + // replay the 'connected' message and trigger a fresh DISCOVER/TREE sequence. + // Without this, the marteConnected guard in onMarteConnected blocks the + // sequence from re-running after a hub-WS reconnect (even if the MARTe2 TCP + // connection stayed alive and no 'disconnected' message was sent). + marteConnected = false; setTimeout(connectWS, wsBackoff); wsBackoff = Math.min(wsBackoff * 2, 30000); }; @@ -353,7 +359,7 @@ function connectWS() { else if (msg.type === 'connected') onMarteConnected(); else if (msg.type === 'disconnected') onMarteDisconnected(); else if (msg.type === 'log') onLog(msg); - else if (msg.type === 'response') onResponse(msg); + else if (msg.type === 'response') { onResponse(msg); } else if (msg.type === 'tree_node') { try { onTreeNode(JSON.parse(msg.data)); } catch(e) { console.error('tree_node error:', e, msg.data); appendLog('sys','ERROR','Tree render error: '+e.message); } } else if (msg.type === 'text_line') onTextLine(msg.data); else if (msg.type === 'service_config') onServiceConfig(msg); @@ -411,7 +417,12 @@ function onConfig(msg) { const src = sourcesMap[sid]; const newSigs = msg.signals || []; const oldSigs = src.signals || []; - const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0); + // Fingerprint only covers fields that change the binary layout of the data. + // timeMode and samplingRate are intentionally excluded: synthesizeHubConfig + // always sets timeMode=TimeModePacket, so including it would cause a buffer + // reset on every real UDP CONFIG arrival (synthesized→real transition), wiping + // the fast-signal buffer and limiting visible history to ~0.5 s. + const fp = s => s.name + ':' + s.typeCode + ':' + numElements(s); const changed = newSigs.length !== oldSigs.length || newSigs.some((s, i) => fp(s) !== fp(oldSigs[i])); src.signals = newSigs; if (changed) { @@ -1011,14 +1022,17 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) { const t0 = p.xRange ? p.xRange[0] : -Infinity; const t1 = p.xRange ? p.xRange[1] : Infinity; - let masterKey = p.traces[0], masterCount = -1, masterRate = -1; + let masterKey = p.traces[0], masterCount = -1, masterRate = -1, masterSpan = -1; for (const key of p.traces) { let sd = fetchedSignals[key]; if (!sd || !sd.t || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1); if (!sd || !sd.t.length) continue; const rate = getKeySamplingRate(key); - if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) { - masterRate = rate; masterCount = sd.t.length; masterKey = key; + const span = sd.t.length > 1 ? sd.t[sd.t.length - 1] - sd.t[0] : 0; + if (span > masterSpan || + (span === masterSpan && rate > masterRate) || + (span === masterSpan && rate === masterRate && sd.t.length > masterCount)) { + masterRate = rate; masterSpan = span; masterCount = sd.t.length; masterKey = key; } } @@ -1244,7 +1258,7 @@ function drawBandSeparators(u, p) { const dpr = window.devicePixelRatio || 1; const bandH = 8 / n; ctx.save(); - ctx.strokeStyle = 'rgba(127,132,156,0.25)'; + ctx.strokeStyle = 'rgba(127,132,156,0.55)'; ctx.lineWidth = dpr; for (let i = 1; i < n; i++) { const yNorm = 4 - i * bandH; // boundary between band i-1 and band i @@ -1841,20 +1855,26 @@ function buildLiveData(p) { } } - // Slice all traces; pick master by sampling rate then count. + // Slice all traces; pick master by time span (widest window wins), then + // by sampling rate, then by point count. A fast signal that has only 0.5s + // of history must NOT beat a slow signal that has 5s of history — otherwise + // sharedT covers only 0.5s and all other signals appear truncated. // In zoom mode, include one bracketing point on each side so lines are drawn // across the visible area even when the window contains 0 or 1 samples. const sliceFn = isRolling ? getBufferSliceRange : getBufferSliceRangeWithBrackets; const slices = {}; - let masterKey = p.traces[0], masterCount = -1, masterRate = -1; + let masterKey = p.traces[0], masterCount = -1, masterRate = -1, masterSpan = -1; for (const key of p.traces) { const buf = buffers[key]; if (!buf || buf.size === 0) continue; const sl = sliceFn(buf, t0, t1); slices[key] = sl; const rate = getKeySamplingRate(key); - if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) { - masterRate = rate; masterCount = sl.t.length; masterKey = key; + const span = sl.t.length > 1 ? sl.t[sl.t.length - 1] - sl.t[0] : 0; + if (span > masterSpan || + (span === masterSpan && rate > masterRate) || + (span === masterSpan && rate === masterRate && sl.t.length > masterCount)) { + masterRate = rate; masterSpan = span; masterCount = sl.t.length; masterKey = key; } } @@ -2090,19 +2110,9 @@ document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit); /* ════════════════════════════════════════════════════════════════ Cursor controls ════════════════════════════════════════════════════════════════ */ -// Show the cursor button only when paused or in trigger-snapshot mode. +// Cursor button is always visible; readout visibility follows cursors.mode. function updateCursorBtnVisibility() { - const canUseCursors = globalPause || (trig.enabled && trig.snapshot !== null); - const btn = document.getElementById('btn-cursor'); - btn.style.display = canUseCursors ? '' : 'none'; - if (!canUseCursors && cursors.mode !== 'off') { - cursors.mode = 'off'; - cursors.tA = null; cursors.tB = null; // context changed, clear positions - btn.textContent = 'Cursors'; - btn.classList.remove('active'); - document.getElementById('cursor-readout').classList.remove('visible'); - cursorsDirty = true; - } + document.getElementById('btn-cursor').style.display = ''; } document.getElementById('btn-cursor').addEventListener('click', () => { @@ -3684,6 +3694,11 @@ function onTracedState(msg) { } function onMarteConnected() { + // Guard against double-invocation: onSources() may call us when it detects + // debug.state==='connected', and replayStateToClient also sends a 'connected' + // message. A second call would wipe _cmdSent mid-flight (via _clearStartupState), + // cancelling the in-progress DISCOVER/TREE sequence. + if (marteConnected) return; marteConnected = true; _clearStartupState(); const el = document.getElementById('conn-status'); diff --git a/Client/debugger/static/index.html b/Client/debugger/static/index.html index 82f234a..d3d2d81 100644 --- a/Client/debugger/static/index.html +++ b/Client/debugger/static/index.html @@ -70,7 +70,7 @@ - + diff --git a/Client/streamhub/App.cpp b/Client/streamhub/App.cpp new file mode 100644 index 0000000..fc85154 --- /dev/null +++ b/Client/streamhub/App.cpp @@ -0,0 +1,708 @@ +/** + * @file App.cpp + * @brief Application state machine implementation. + */ + +#include "App.h" +#include "SourcePanel.h" +#include "PlotPanel.h" +#include "TriggerPanel.h" +#include "StatsPanel.h" + +#include "imgui.h" +#include "implot.h" +#include "Icons.h" + +#include +#include +#include +#include +#include + +namespace StreamHubClient { + +/* ── Layout icon helper ──────────────────────────────────────────────────── */ + +static void DrawLayoutIcon(ImDrawList* dl, ImVec2 tl, ImVec2 sz, + int cols, int rows, bool selected) { + ImU32 bg = selected ? IM_COL32(49,50,68,255) : IM_COL32(24,24,37,255); + ImU32 cell = selected ? IM_COL32(137,180,250,200) : IM_COL32(88,91,112,160); + ImU32 bord = selected ? IM_COL32(137,180,250,255) : IM_COL32(69,71,90,255); + dl->AddRectFilled(tl, ImVec2(tl.x+sz.x, tl.y+sz.y), bg, 4.f); + dl->AddRect(tl, ImVec2(tl.x+sz.x, tl.y+sz.y), bord, 4.f, 0, 1.f); + float cw = sz.x / cols, rh = sz.y / rows; + for (int c = 0; c < cols; c++) { + for (int r = 0; r < rows; r++) { + ImVec2 p0(tl.x + c*cw + 2.f, tl.y + r*rh + 2.f); + ImVec2 p1(tl.x + (c+1)*cw - 2.f, tl.y + (r+1)*rh - 2.f); + dl->AddRectFilled(p0, p1, cell, 2.f); + } + } +} + +/* ── Signal ─────────────────────────────────────────────────────────────── */ + +/* ── App ─────────────────────────────────────────────────────────────────── */ + +App::App() { + for (int i = 0; i < kMaxPlotSlots; i++) { + plotPaused_[i] = false; + liveFollow_[i] = true; + activeSlot_[i] = -1; + } +} + +App::~App() { + shutdown(); +} + +void App::init(const std::string& host, uint16_t port) { + snprintf(hostBuf_, sizeof(hostBuf_), "%s", host.c_str()); + snprintf(portBuf_, sizeof(portBuf_), "%u", static_cast(port)); + ws_.connect(host, port); + /* Initial queries are sent on first connect transition in update() */ +} + +void App::shutdown() { + ws_.disconnect(); +} + +/* ── Per-frame update ────────────────────────────────────────────────────── */ + +void App::update() { + /* Send initial queries on first successful connect */ + bool nowConnected = ws_.isConnected(); + if (nowConnected && !prevConnected_) { + ws_.sendText(BuildGetSources()); + ws_.sendText(BuildGetStats()); + } + prevConnected_ = nowConnected; + + /* Drain WebSocket receive queue */ + ws_.poll([this](const WSMessage& msg) { + if (msg.isBinary) { + handleBinary(msg.data.data(), msg.data.size()); + } else { + std::string json(reinterpret_cast(msg.data.data()), + msg.data.size()); + handleJSON(json); + } + }); + + /* ── Full-screen dockspace ─────────────────────────────────────────── */ + ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(vp->WorkPos); + ImGui::SetNextWindowSize(vp->WorkSize); + + ImGuiWindowFlags wf = ImGuiWindowFlags_NoTitleBar + | ImGuiWindowFlags_NoCollapse + | ImGuiWindowFlags_NoResize + | ImGuiWindowFlags_NoMove + | ImGuiWindowFlags_NoBringToFrontOnFocus + | ImGuiWindowFlags_NoNavFocus + | ImGuiWindowFlags_MenuBar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImGui::Begin("##main", nullptr, wf); + ImGui::PopStyleVar(3); + + renderMenuBar(); + renderToolbar(); + if (showTrigBar_) { renderTriggerBar(); } + + /* Horizontal split: sidebar | plot grid */ + float sidebarW = sidebarOpen_ ? 220.f : 0.f; + float avail = ImGui::GetContentRegionAvail().x; + float plotW = avail - sidebarW; + + if (sidebarOpen_) { + ImGui::BeginChild("##sidebar", ImVec2(sidebarW, 0.f), false, + ImGuiWindowFlags_NoMove); + RenderSourcePanel(*this); + ImGui::EndChild(); + ImGui::SameLine(); + } + + ImGui::BeginChild("##plots", ImVec2(plotW, 0.f), false); + renderPlotGrid(); + ImGui::EndChild(); + + ImGui::End(); + + /* Overlays / modals */ + if (showStats_) { renderStatsModal(); } + if (showAddSrc_) { renderAddSourceModal(); } +} + +/* ── Menu bar ────────────────────────────────────────────────────────────── */ + +void App::renderMenuBar() { + if (!ImGui::BeginMenuBar()) { return; } + + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Quit", "Alt+F4")) { + /* Signal quit via SDL event — handled in main.cpp via flag */ + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("View")) { + ImGui::MenuItem("Sidebar", nullptr, &sidebarOpen_); + ImGui::MenuItem("Trigger Bar", nullptr, &showTrigBar_); + ImGui::MenuItem("Statistics", nullptr, &showStats_); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Sources")) { + if (ImGui::MenuItem(ICON_FA_PLUS " Add Source...")) { showAddSrc_ = true; } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Layout")) { + for (int i = 0; i < static_cast(PlotLayout::kCount); i++) { + bool sel = (static_cast(layout_) == i); + if (ImGui::MenuItem(PlotLayoutNames[i], nullptr, sel)) { + setLayout(static_cast(i)); + } + } + ImGui::EndMenu(); + } + + /* Right-aligned: [host] : [port] [Connect/Reconnect] ● status */ + bool connected = ws_.isConnected(); + const char* btnLbl = connected ? "Reconnect" : "Connect"; + const char* ledLbl = connected ? ICON_FA_CIRCLE " Connected" + : ICON_FA_CIRCLE " Disconnected"; + /* Measure widget widths to right-align the whole group */ + const ImGuiStyle& st = ImGui::GetStyle(); + const float kHostW = 130.f; + const float kPortW = 52.f; + const float kBtnW = ImGui::CalcTextSize(btnLbl).x + st.FramePadding.x * 2.f; + const float kLedW = ImGui::CalcTextSize(ledLbl).x; + const float kGroupW = kHostW + 8.f + kPortW + 8.f + kBtnW + 8.f + kLedW; + ImGui::SetCursorPosX( + ImGui::GetCursorPosX() + + ImGui::GetContentRegionAvail().x - kGroupW); + + ImGui::SetNextItemWidth(kHostW); + ImGui::InputText("##hubhost", hostBuf_, sizeof(hostBuf_)); + ImGui::SameLine(0.f, 2.f); + ImGui::TextUnformatted(":"); + ImGui::SameLine(0.f, 2.f); + ImGui::SetNextItemWidth(kPortW); + ImGui::InputText("##hubport", portBuf_, sizeof(portBuf_), + ImGuiInputTextFlags_CharsDecimal); + ImGui::SameLine(0.f, 6.f); + if (ImGui::Button(btnLbl)) { + ws_.reconnect(hostBuf_, static_cast(atoi(portBuf_))); + } + ImGui::SameLine(0.f, 8.f); + ImVec4 ledColor = connected + ? ImVec4(0.1f, 0.9f, 0.3f, 1.f) + : ImVec4(0.9f, 0.2f, 0.2f, 1.f); + ImGui::TextColored(ledColor, "%s", ledLbl); + + ImGui::EndMenuBar(); +} + +/* ── Toolbar ─────────────────────────────────────────────────────────────── */ + +void App::renderToolbar() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 4.f)); + + /* Layout picker — pictogram popup */ + static const int kIconCols[] = {1,2,1,3,1,2,4,1}; + static const int kIconRows[] = {1,1,2,1,3,2,1,4}; + static const ImVec2 kIconSz = ImVec2(36.f, 24.f); + + if (ImGui::Button(ICON_FA_TABLE_CELLS_LARGE " Layout")) { + ImGui::OpenPopup("##layout_pop"); + } + ImGui::SameLine(); + if (ImGui::BeginPopup("##layout_pop")) { + ImGui::TextDisabled("Select layout"); + ImGui::Separator(); + ImDrawList* ldl = ImGui::GetWindowDrawList(); + for (int li = 0; li < static_cast(PlotLayout::kCount); li++) { + bool sel = (static_cast(layout_) == li); + ImVec2 p = ImGui::GetCursorScreenPos(); + /* Invisible button sized to the icon */ + if (ImGui::InvisibleButton(PlotLayoutNames[li], kIconSz)) { + setLayout(static_cast(li)); + ImGui::CloseCurrentPopup(); + } + DrawLayoutIcon(ldl, p, kIconSz, + kIconCols[li], kIconRows[li], sel); + if (li < static_cast(PlotLayout::kCount) - 1) { + ImGui::SameLine(0.f, 4.f); + } + } + ImGui::EndPopup(); + } + + /* Global pause */ + if (ImGui::Button(globalPaused_ ? ICON_FA_PLAY " Resume" + : ICON_FA_PAUSE " Pause")) { + globalPaused_ = !globalPaused_; + for (int i = 0; i < numPlotSlots(); i++) { + plotPaused_[i] = globalPaused_; + if (!globalPaused_) { liveFollow_[i] = true; } + } + } + ImGui::SameLine(); + + /* Cursors A/B (global: shared & synchronised across all plots) */ + { + if (cursorsOn_) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f)); + } + if (ImGui::Button(ICON_FA_CROSSHAIRS " Cursors")) { + cursorsOn_ = !cursorsOn_; + if (cursorsOn_) { + /* seed cursors at 25% / 75% of the live window */ + const double wallNow = std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()).count(); + const double x0 = wallNow - windowSec_; + cursorA_ = x0 + windowSec_ * 0.25; + cursorB_ = x0 + windowSec_ * 0.75; + } + } + if (cursorsOn_) { ImGui::PopStyleColor(); } + } + ImGui::SameLine(); + + /* Trigger bar toggle */ + if (ImGui::Button(ICON_FA_BOLT " Trigger")) { showTrigBar_ = !showTrigBar_; } + ImGui::SameLine(); + + /* Stats */ + if (ImGui::Button(ICON_FA_CHART_COLUMN " Stats")) { showStats_ = !showStats_; } + ImGui::SameLine(); + + /* Add source */ + if (ImGui::Button(ICON_FA_PLUS " Source")) { showAddSrc_ = true; } + ImGui::SameLine(); + + /* Time window presets (Ctrl+scroll on a plot still adjusts freely) */ + static const double kWinPresets[] = {1.0, 5.0, 10.0, 30.0, 60.0}; + static const char* kWinLabels[] = {"1 s", "5 s", "10 s", "30 s", "60 s"}; + char winPreview[16]; + snprintf(winPreview, sizeof(winPreview), "%.3g s", windowSec_); + ImGui::SetNextItemWidth(70.f); + if (ImGui::BeginCombo("Win", winPreview)) { + for (int wi = 0; wi < 5; wi++) { + bool sel = std::fabs(windowSec_ - kWinPresets[wi]) < 1e-9; + if (ImGui::Selectable(kWinLabels[wi], sel)) { + windowSec_ = kWinPresets[wi]; + } + if (sel) { ImGui::SetItemDefaultFocus(); } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + + /* Max points control */ + ImGui::SetNextItemWidth(80.f); + int mp = static_cast(maxPoints_); + if (ImGui::InputInt("MaxPts", &mp, 0, 0, + ImGuiInputTextFlags_EnterReturnsTrue)) { + if (mp >= 2) { + maxPoints_ = static_cast(mp); + ws_.sendText(BuildSetMaxPoints(maxPoints_)); + } + } + + ImGui::PopStyleVar(); + ImGui::Separator(); +} + +/* ── Plot grid ───────────────────────────────────────────────────────────── */ + +void App::renderPlotGrid() { + int cols, rows; + PlotLayoutDims(layout_, cols, rows); + + /* Per-cell fraction arrays (reset when layout changes) */ + static float rowFrac[8] = {1,1,1,1,1,1,1,1}; + static float colFrac[8] = {1,1,1,1,1,1,1,1}; + static PlotLayout prevLayout = PlotLayout::kCount; + if (layout_ != prevLayout) { + for (int i = 0; i < 8; i++) { rowFrac[i] = 1.f; colFrac[i] = 1.f; } + prevLayout = layout_; + } + + const float kSep = 5.f; /* separator strip width/height in px */ + + float avW = ImGui::GetContentRegionAvail().x; + float avH = ImGui::GetContentRegionAvail().y; + float cellTotalW = avW - kSep * (cols - 1); + float cellTotalH = avH - kSep * (rows - 1); + + /* Normalise fractions */ + float rowSum = 0.f; for (int r = 0; r < rows; r++) rowSum += rowFrac[r]; + float colSum = 0.f; for (int c = 0; c < cols; c++) colSum += colFrac[c]; + if (rowSum < 1e-6f) rowSum = 1.f; + if (colSum < 1e-6f) colSum = 1.f; + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.f, 0.f)); + + for (int r = 0; r < rows; r++) { + float rh = std::max(30.f, cellTotalH * rowFrac[r] / rowSum); + + for (int c = 0; c < cols; c++) { + float cw = std::max(60.f, cellTotalW * colFrac[c] / colSum); + int plotIdx = r * cols + c; + + char cellId[32]; snprintf(cellId, sizeof(cellId), "##cell%d", plotIdx); + ImGui::BeginChild(cellId, ImVec2(cw, rh), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + if (plotIdx < kMaxPlotSlots) { + RenderPlotPanel(*this, plotIdx, plotPaused_[plotIdx]); + } + ImGui::EndChild(); + + /* ── Column separator ── */ + if (c < cols - 1) { + ImGui::SameLine(0.f, 0.f); + char csId[32]; snprintf(csId, sizeof(csId), "##cs%d_%d", r, c); + ImVec2 p0 = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton(csId, ImVec2(kSep, rh)); + if (ImGui::IsItemActive()) { + float dx = ImGui::GetIO().MouseDelta.x; + colFrac[c] += dx * colSum / cellTotalW; + colFrac[c+1] -= dx * colSum / cellTotalW; + colFrac[c] = std::max(0.05f * colSum, colFrac[c]); + colFrac[c+1] = std::max(0.05f * colSum, colFrac[c+1]); + } + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + ImGui::GetWindowDrawList()->AddRectFilled( + p0, ImVec2(p0.x + kSep, p0.y + rh), IM_COL32(49,50,68,255)); + ImGui::SameLine(0.f, 0.f); + } + } /* end col loop */ + + /* ── Row separator ── */ + if (r < rows - 1) { + char rsId[32]; snprintf(rsId, sizeof(rsId), "##rs%d", r); + /* Reposition X to left content edge before drawing full-width strip */ + ImGui::SetCursorPosX(0.f); + ImVec2 p0 = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton(rsId, ImVec2(avW, kSep)); + if (ImGui::IsItemActive()) { + float dy = ImGui::GetIO().MouseDelta.y; + rowFrac[r] += dy * rowSum / cellTotalH; + rowFrac[r+1] -= dy * rowSum / cellTotalH; + rowFrac[r] = std::max(0.05f * rowSum, rowFrac[r]); + rowFrac[r+1] = std::max(0.05f * rowSum, rowFrac[r+1]); + } + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + ImGui::GetWindowDrawList()->AddRectFilled( + p0, ImVec2(p0.x + avW, p0.y + kSep), IM_COL32(49,50,68,255)); + } + } /* end row loop */ + + ImGui::PopStyleVar(); +} + +/* ── Stats modal ─────────────────────────────────────────────────────────── */ + +void App::renderStatsModal() { + ImGui::SetNextWindowSize(ImVec2(700.f, 300.f), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Statistics", &showStats_)) { + RenderStatsPanel(*this); + } + ImGui::End(); +} + +/* ── Add-source modal ────────────────────────────────────────────────────── */ + +void App::renderAddSourceModal() { + ImGui::SetNextWindowSize(ImVec2(360.f, 260.f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (ImGui::Begin("Add Source", &showAddSrc_, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) { + static char label[128]= ""; + static char host[64] = "127.0.0.1"; + static int port = 44500; + static char mcast[64] = ""; + static int dataPort = 0; + + ImGui::InputText("Label", label, sizeof(label)); + ImGui::InputText("Host", host, sizeof(host)); + ImGui::InputInt("Port", &port); + ImGui::InputText("Multicast", mcast, sizeof(mcast)); + ImGui::InputInt("Data Port", &dataPort); + + ImGui::Spacing(); + if (ImGui::Button("Connect", ImVec2(100.f, 0.f))) { + if (host[0] != '\0' && port > 0 && port < 65536) { + char addr[96]; + snprintf(addr, sizeof(addr), "%s:%d", host, port); + ws_.sendText(BuildAddSource(label, addr, mcast, + static_cast( + (dataPort > 0 && dataPort < 65536) + ? dataPort : 0))); + showAddSrc_ = false; + label[0] = '\0'; + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80.f, 0.f))) { + showAddSrc_ = false; + } + } + ImGui::End(); +} + +/* ── Trigger bar ─────────────────────────────────────────────────────────── */ + +void App::renderTriggerBar() { + RenderTriggerPanel(*this); +} + +/* ── Layout ──────────────────────────────────────────────────────────────── */ + +int App::numPlotSlots() const { + int cols, rows; + PlotLayoutDims(layout_, cols, rows); + return cols * rows; +} + +void App::setLayout(PlotLayout l) { + layout_ = l; +} + +/* ── Source helpers ──────────────────────────────────────────────────────── */ + +int App::findSource(const std::string& id) const { + for (int i = 0; i < static_cast(sources_.size()); i++) { + if (sources_[i].id == id) { return i; } + } + return -1; +} + +int App::findSignal(const Source& src, const std::string& name) const { + for (int i = 0; i < static_cast(src.signals.size()); i++) { + if (src.signals[i].meta.name == name) { return i; } + } + return -1; +} + +/* ── WS message dispatch ─────────────────────────────────────────────────── */ + +void App::handleJSON(const std::string& json) { + std::string type = ParseType(json); + + if (type == "sources") { onSources(json); } + else if (type == "config") { onConfig(json); } + else if (type == "stats") { onStats(json); } + else if (type == "triggerState") { onTriggerState(json); } + else if (type == "zoom") { onZoom(json); } + else if (type == "maxPointsUpdated") { onMaxPointsUpdated(json); } + /* pong: ignore */ +} + +void App::handleBinary(const uint8_t* data, size_t len) { + if (len == 0) { return; } + + /* Version 2: trigger capture frame */ + if (data[0] == 2u) { + CaptureFrame cf; + if (ParseCaptureFrame(data, len, cf)) { + capture_ = std::move(cf); + hasCapture_ = true; + trigger_.status = "triggered"; + trigger_.trigTime = capture_.trigTime; + trigger_.hasTrigTime = true; + } + return; + } + + /* Version 1: live push frame. The hub pushes only samples that are new + * since the previous tick (non-overlapping), so push verbatim. */ + DataFrame frame; + if (!ParseBinaryFrame(data, len, frame)) { return; } + + std::lock_guard lk(srcMutex_); + int srcIdx = findSource(frame.sourceId); + if (srcIdx < 0) { return; } + + Source& src = sources_[srcIdx]; + for (const auto& fs : frame.signals) { + int sigIdx = findSignal(src, fs.name); + if (sigIdx < 0) { continue; } + /* Always push: the ring is the "active" buffer; paused plots render + * from their frozen view snapshot, so no data is ever dropped. */ + size_t n = std::min(fs.t.size(), fs.v.size()); + auto& buf = src.signals[sigIdx].buf; + for (size_t i = 0; i < n; i++) { + buf.push(fs.t[i], fs.v[i]); + } + } +} + +/* ── JSON event handlers ─────────────────────────────────────────────────── */ + +void App::onSources(const std::string& json) { + std::vector infos; + ParseSources(json, infos); + + std::lock_guard lk(srcMutex_); + for (const auto& info : infos) { + int idx = findSource(info.id); + if (idx < 0) { + Source s; + s.id = info.id; + s.label = info.label; + s.addr = info.addr; + s.port = info.port; + s.state = info.state; + sources_.push_back(std::move(s)); + } else { + sources_[idx].state = info.state; + sources_[idx].label = info.label; + } + } + + /* Remove sources that are no longer listed */ + sources_.erase(std::remove_if(sources_.begin(), sources_.end(), + [&infos](const Source& s) { + for (const auto& i : infos) { if (i.id == s.id) { return false; } } + return true; + }), sources_.end()); +} + +void App::onConfig(const std::string& json) { + std::string sourceId; + int publishMode = 0; + std::vector metas; + + if (!ParseConfig(json, sourceId, publishMode, metas)) { return; } + + std::lock_guard lk(srcMutex_); + int idx = findSource(sourceId); + if (idx < 0) { return; } + + Source& src = sources_[idx]; + src.configured = true; + src.publishMode = publishMode; + + /* Colour palette — Catppuccin Mocha trace colours */ + static const ImVec4 kPalette[] = { + {0.537f,0.706f,0.980f,1.f}, /* blue */ + {0.651f,0.890f,0.631f,1.f}, /* green */ + {0.953f,0.545f,0.659f,1.f}, /* red */ + {0.980f,0.702f,0.529f,1.f}, /* peach */ + {0.796f,0.651f,0.969f,1.f}, /* mauve */ + {0.580f,0.886f,0.835f,1.f}, /* teal */ + {0.537f,0.863f,0.922f,1.f}, /* sky */ + {0.706f,0.745f,0.996f,1.f}, /* lavender */ + }; + + /* Merge: add new signals, keep existing ring buffers */ + for (size_t m = 0; m < metas.size(); m++) { + int si = findSignal(src, metas[m].name); + if (si < 0) { + Signal sig; + sig.meta = metas[m]; + sig.color = kPalette[src.signals.size() % 8]; + src.signals.push_back(std::move(sig)); + } else { + src.signals[si].meta = metas[m]; + } + } +} + +void App::onStats(const std::string& json) { + std::vector> statsVec; + ParseStats(json, statsVec); + + std::lock_guard lk(srcMutex_); + for (const auto& kv : statsVec) { + int idx = findSource(kv.first); + if (idx >= 0) { + sources_[idx].stats = kv.second; + sources_[idx].state = kv.second.state; + } + } +} + +void App::onTriggerState(const std::string& json) { + TriggerStateMsg msg; + if (!ParseTriggerState(json, msg)) { return; } + + trigger_.status = msg.state; + trigger_.stopped = msg.stopped; + if (msg.hasTrigTime) { + trigger_.trigTime = msg.trigTime; + trigger_.hasTrigTime = true; + } + /* Double-buffer semantics: the last recorded capture stays on display + * (even while re-armed/collecting) and is only replaced when a new + * capture frame has been fully received and parsed (handleBinary v2). */ + if (msg.state == "idle") { + trigger_.hasTrigTime = false; + } +} + +void App::onZoom(const std::string& json) { + ZoomResponse resp; + if (!ParseZoom(json, resp)) { return; } + + /* Route the reply to the plot that requested it */ + for (int i = 0; i < kMaxPlotSlots; i++) { + auto& zc = zoomCache_[i]; + if (zc.pending && zc.reqId == resp.reqId) { + zc.signals = std::move(resp.signals); + zc.t0 = zc.reqT0; + zc.t1 = zc.reqT1; + zc.valid = true; + zc.pending = false; + return; + } + } +} + +void App::requestZoom(int plotIdx, double t0, double t1, + const std::string& signalsCsv) { + if (plotIdx < 0 || plotIdx >= kMaxPlotSlots) { return; } + if (!ws_.isConnected() || signalsCsv.empty()) { return; } + auto& zc = zoomCache_[plotIdx]; + zc.reqId = nextZoomReqId_++; + zc.reqT0 = t0; + zc.reqT1 = t1; + zc.pending = true; + ws_.sendText(BuildZoom(zc.reqId, t0, t1, 2400, signalsCsv)); +} + +std::string App::slotKey(const PlotAssignment& a) const { + if (a.sourceIdx < 0 || a.sourceIdx >= static_cast(sources_.size())) { + return std::string(); + } + const Source& src = sources_[a.sourceIdx]; + if (a.signalIdx < 0 || a.signalIdx >= static_cast(src.signals.size())) { + return std::string(); + } + return src.id + ":" + src.signals[a.signalIdx].meta.name; +} + +void App::onMaxPointsUpdated(const std::string& json) { + uint32_t mp = 0; + ParseMaxPointsUpdated(json, mp); + if (mp >= 2) { + maxPoints_ = mp; + std::lock_guard lk(srcMutex_); + for (auto& src : sources_) { + for (auto& sig : src.signals) { + sig.buf.setCapacity(mp); + } + } + } +} + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/App.h b/Client/streamhub/App.h new file mode 100644 index 0000000..d02bede --- /dev/null +++ b/Client/streamhub/App.h @@ -0,0 +1,319 @@ +/** + * @file App.h + * @brief Top-level application state and frame dispatch. + */ + +#pragma once + +#include "WSClient.h" +#include "Protocol.h" +#include "SignalBuffer.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" + +namespace StreamHubClient { + +/*---------------------------------------------------------------------------*/ +/* Domain types */ +/*---------------------------------------------------------------------------*/ + +/** One signal as known to the client. */ +struct Signal { + SignalMeta meta; + SignalBuffer buf{20000}; + ImVec4 color{0.4f, 0.8f, 1.0f, 1.0f}; + float lineWidth = 1.5f; + int marker = -1; /* ImPlotMarker (-1 = none) */ + bool visible = true; +}; + +/** One connected UDPStreamer source. */ +struct Source { + std::string id; + std::string label; + std::string addr; + std::string state = "disconnected"; + uint32_t port = 0; + bool configured = false; + int publishMode = 0; + std::vector signals; + SourceStats stats; +}; + +/** Trigger configuration and live state (hub-side trigger semantics). */ +struct TriggerState { + /* Config (sent to the hub via setTrigger) */ + std::string signalKey; /* full key "src:sig" or "src:sig[i]" */ + int edge = 0; /* 0=rising 1=falling 2=both */ + double threshold = 0.0; + double windowSec = 0.1; /* 100 µs .. 10 s */ + double prePercent = 20.0; + bool single = false; /* false=normal true=single */ + + /* Live (driven by hub triggerState broadcasts) */ + std::string status = "idle"; /* idle|armed|collecting|triggered */ + bool stopped = false; + bool hasTrigTime = false; + double trigTime = 0.0; +}; + +/** Per-signal vertical scale state (oscilloscope style). */ +struct VScale { + int mode = 0; /* 0=auto 1=range 2=manual */ + double divValue = 1.0; /* units per division (manual mode) */ + double offset = 0.0; /* raw value at center (manual mode) */ + double screenPos = 0.0; /* position offset in divisions from center */ + bool digitalInMixed = false; /* quantize this trace in mixed plot mode */ + /* Resolved each frame (read-only): */ + double resolvedDiv = 1.0; + double resolvedOffset = 0.0; +}; + +/** Assignment of one signal to a plot panel. */ +struct PlotAssignment { + int sourceIdx = -1; + int signalIdx = -1; + VScale vs; +}; + +/*---------------------------------------------------------------------------*/ +/* Plot layouts */ +/*---------------------------------------------------------------------------*/ + +/* Layout set mirrors the web UI: cols×rows */ +enum class PlotLayout { + kLayout1x1 = 0, + kLayout2x1, /* 2 side by side */ + kLayout1x2, /* 2 stacked */ + kLayout3x1, + kLayout1x3, + kLayout2x2, + kLayout4x1, + kLayout1x4, + kCount +}; + +static inline const char* const PlotLayoutNames[] = { + "1×1", "2×1", "1×2", "3×1", "1×3", "2×2", "4×1", "1×4" +}; + +static inline void PlotLayoutDims(PlotLayout l, int& cols, int& rows) { + switch (l) { + case PlotLayout::kLayout1x1: cols=1; rows=1; break; + case PlotLayout::kLayout2x1: cols=2; rows=1; break; + case PlotLayout::kLayout1x2: cols=1; rows=2; break; + case PlotLayout::kLayout3x1: cols=3; rows=1; break; + case PlotLayout::kLayout1x3: cols=1; rows=3; break; + case PlotLayout::kLayout2x2: cols=2; rows=2; break; + case PlotLayout::kLayout4x1: cols=4; rows=1; break; + case PlotLayout::kLayout1x4: cols=1; rows=4; break; + default: cols=1; rows=1; break; + } +} + +/*---------------------------------------------------------------------------*/ +/* App */ +/*---------------------------------------------------------------------------*/ + +class App { +public: + App(); + ~App(); + + /** @brief Initialise and start the WebSocket connection. */ + void init(const std::string& host, uint16_t port); + + /** + * @brief Call once per ImGui frame. + * Drains the WS receive queue, dispatches messages, renders all panels. + */ + void update(); + + /** @brief Disconnect and clean up. */ + void shutdown(); + + /* ---- Accessors used by sub-panels ----------------------------------- */ + + std::vector& sources() { return sources_; } + const std::vector& sources() const { return sources_; } + TriggerState& trigger() { return trigger_; } + WSClient& ws() { return ws_; } + uint32_t maxPoints() const { return maxPoints_; } + + /** @brief Last trigger capture received from the hub (nullptr if none). */ + const CaptureFrame* capture() const { return hasCapture_ ? &capture_ : nullptr; } + void clearCapture() { hasCapture_ = false; } + + /** @brief Get the plot assignment vector for plot index i. */ + std::vector& plotSlot(int i) { return plotSlots_[i]; } + int numPlotSlots() const; + + PlotLayout plotLayout() const { return layout_; } + void setLayout(PlotLayout l); + + /** @brief True if all plots are paused (global pause). */ + bool globalPaused() const { return globalPaused_; } + void setGlobalPaused(bool p) { globalPaused_ = p; } + + /** @brief Live-follow state per plot (true = auto-scroll with newest data). */ + bool& liveFollow(int i) { return liveFollow_[i]; } + + /** @brief Which slot index is "active" (selected) in the given plot (-1 = none). */ + int& activeSlot(int i) { return activeSlot_[i]; } + + /** @brief Time window width in seconds used by live scroll. */ + double windowSec() const { return windowSec_; } + void setWindowSec(double s){ if (s >= 1e-4 && s <= 3600.0) windowSec_ = s; } + + /** @brief Stored X range for non-live (zoom/pan) mode per plot. */ + double plotXMin(int i) const { return plotXMin_[i]; } + double plotXMax(int i) const { return plotXMax_[i]; } + void setPlotX(int i, double mn, double mx) { + if (mx > mn + 1e-9) { plotXMin_[i] = mn; plotXMax_[i] = mx; } + } + /** @brief Call when transitioning live→non-live to seed the stored X range. */ + void initPlotX(int i, double tMax) { + plotXMin_[i] = tMax - windowSec_; + plotXMax_[i] = tMax; + } + + /** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed. */ + int& plotVMode(int i) { return plotVMode_[i]; } + + /* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */ + bool& cursorsOn() { return cursorsOn_; } + double& cursorA() { return cursorA_; } + double& cursorB() { return cursorB_; } + + /* ---- Paused view snapshot (per plot) ---------------------------------- * + * Double-buffer scheme: the per-signal ring buffers stay "active" (always + * written by the WS thread-drain); a paused plot freezes a "view" copy of + * its slots' data at pause time and renders from it, so the picture does + * not drain away while the rings keep rolling. */ + struct PlotSnapshot { + bool valid = false; + std::vector keys; /* slot key "src:sig" */ + std::vector> t, v; + }; + PlotSnapshot& plotSnap(int i) { return plotSnap_[i]; } + + /* ---- Zoom history (per plot) ----------------------------------------- */ + std::vector>& zoomHist(int i) { return zoomHist_[i]; } + /** @brief Push the current stored X range onto the history (capped). */ + void pushZoomHist(int i) { + auto& h = zoomHist_[i]; + if (!h.empty() && h.back().first == plotXMin_[i] && + h.back().second == plotXMax_[i]) { return; } + if (h.size() >= 64) { h.erase(h.begin()); } + h.emplace_back(plotXMin_[i], plotXMax_[i]); + } + + /* ---- Hi-res WS zoom (per plot) ---------------------------------------- */ + struct PlotZoomCache { + bool valid = false; /* signals[] match [t0,t1] */ + bool pending = false; /* request in flight */ + uint32_t reqId = 0; + double t0 = 0.0, t1 = 0.0; /* range of valid data */ + double reqT0 = 0.0, reqT1 = 0.0; /* range of pending request */ + std::vector signals; + }; + PlotZoomCache& zoomCache(int i) { return zoomCache_[i]; } + + /** @brief Send a hub zoom request for plot i over [t0,t1] (2400 pts). */ + void requestZoom(int plotIdx, double t0, double t1, + const std::string& signalsCsv); + + /** @brief Full key "src:sig" for an assignment (empty if invalid). */ + std::string slotKey(const PlotAssignment& a) const; + + /** @brief True if the stats panel is open. */ + bool& showStats() { return showStats_; } + bool& showAddSrc() { return showAddSrc_; } + bool& showTrigBar() { return showTrigBar_; } + + /** @brief Find source index by id (-1 if not found). */ + int findSource(const std::string& id) const; + + /** @brief Find signal index in source by name (-1 if not found). */ + int findSignal(const Source& src, const std::string& name) const; + +private: + /* ---- WS message dispatch ------------------------------------------- */ + void handleJSON(const std::string& json); + void handleBinary(const uint8_t* data, size_t len); + + void onSources(const std::string& json); + void onConfig(const std::string& json); + void onStats(const std::string& json); + void onTriggerState(const std::string& json); + void onZoom(const std::string& json); + void onMaxPointsUpdated(const std::string& json); + + /* ---- Rendering ------------------------------------------------------ */ + void renderMenuBar(); + void renderToolbar(); + void renderTriggerBar(); + void renderSidebar(); + void renderPlotGrid(); + void renderStatsModal(); + void renderAddSourceModal(); + + /* ---- State ---------------------------------------------------------- */ + WSClient ws_; + std::vector sources_; + mutable std::mutex srcMutex_; /* protects sources_ */ + + TriggerState trigger_; + PlotLayout layout_ = PlotLayout::kLayout1x1; + uint32_t maxPoints_ = 20000u; + bool globalPaused_ = false; + + /* Plot slots: one vector of assignments per plot panel */ + static const int kMaxPlotSlots = 8; + std::vector plotSlots_[kMaxPlotSlots]; + bool plotPaused_[kMaxPlotSlots] = {}; + bool liveFollow_[kMaxPlotSlots] = {}; /* true = live-scroll X axis */ + int activeSlot_[kMaxPlotSlots]; /* which slot is "active" (-1 = none) */ + double windowSec_ = 10.0; /* live scroll window width */ + double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */ + double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */ + int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed */ + + /* Cursors (global) */ + bool cursorsOn_ = false; + double cursorA_ = 0.0; + double cursorB_ = 0.0; + + /* Frozen view per plot (valid while the plot is paused) */ + PlotSnapshot plotSnap_[kMaxPlotSlots]; + + /* Zoom history + hi-res zoom cache */ + std::vector> zoomHist_[kMaxPlotSlots]; + PlotZoomCache zoomCache_[kMaxPlotSlots]; + uint32_t nextZoomReqId_ = 1; + + /* UI state */ + bool showStats_ = false; + bool showAddSrc_ = false; + bool showTrigBar_ = false; + bool sidebarOpen_ = true; + + /* Connection fields (populated by init(), editable in menu bar) */ + char hostBuf_[64] = "127.0.0.1"; + char portBuf_[8] = "8090"; + + /* Track connect transition to send initial queries */ + bool prevConnected_ = false; + + /* Last trigger capture (version=2 binary frame; render thread only) */ + CaptureFrame capture_; + bool hasCapture_ = false; +}; + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/CMakeLists.txt b/Client/streamhub/CMakeLists.txt new file mode 100644 index 0000000..275e635 --- /dev/null +++ b/Client/streamhub/CMakeLists.txt @@ -0,0 +1,124 @@ +cmake_minimum_required(VERSION 3.16) +project(StreamHubClient CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# ── Find system packages ────────────────────────────────────────────────────── +find_package(OpenGL REQUIRED) + +# SDL2: try cmake config first, fall back to pkg-config +find_package(SDL2 QUIET CONFIG) +if(NOT SDL2_FOUND) + find_package(PkgConfig REQUIRED) + pkg_check_modules(SDL2 REQUIRED sdl2) + add_library(SDL2::SDL2 INTERFACE IMPORTED) + target_include_directories(SDL2::SDL2 INTERFACE ${SDL2_INCLUDE_DIRS}) + target_link_libraries(SDL2::SDL2 INTERFACE ${SDL2_LIBRARIES}) + target_compile_options(SDL2::SDL2 INTERFACE ${SDL2_CFLAGS_OTHER}) +endif() + +# ── Fetch Dear ImGui ────────────────────────────────────────────────────────── +include(FetchContent) + +FetchContent_Declare( + imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.91.8 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(imgui) + +# ── Fetch ImPlot ────────────────────────────────────────────────────────────── +FetchContent_Declare( + implot + GIT_REPOSITORY https://github.com/epezent/implot.git + GIT_TAG v0.17 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(implot) + +# ── Build ImGui + ImPlot as a static library ────────────────────────────────── +add_library(imgui_lib STATIC + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl2.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp + ${implot_SOURCE_DIR}/implot.cpp + ${implot_SOURCE_DIR}/implot_items.cpp +) +target_include_directories(imgui_lib PUBLIC + ${imgui_SOURCE_DIR} + ${imgui_SOURCE_DIR}/backends + ${implot_SOURCE_DIR} +) +target_link_libraries(imgui_lib PUBLIC SDL2::SDL2 OpenGL::GL) +target_compile_options(imgui_lib PRIVATE -w) # suppress warnings from vendored code + +# ── Bundled resources (fonts, desktop entry, icon) ─────────────────────────── +# Fonts (UI font + Font Awesome 6 Free Solid + glyph-macro header) are vendored +# in resources/fonts — no network access or system fonts required. +set(RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/resources) +set(FONT_DIR ${RESOURCE_DIR}/fonts) + +if(EXISTS ${FONT_DIR}/fa-solid-900.ttf AND EXISTS ${FONT_DIR}/IconsFontAwesome6.h) + set(HAVE_FONT_AWESOME TRUE) + message(STATUS "Font Awesome icons enabled (${FONT_DIR})") +else() + set(HAVE_FONT_AWESOME FALSE) + message(WARNING "Bundled Font Awesome missing — using ASCII icon fallbacks") +endif() + +# Mirror resources next to the built executable so dev runs find them +file(COPY ${RESOURCE_DIR}/fonts DESTINATION ${CMAKE_BINARY_DIR}/resources) + +# ── Application sources ─────────────────────────────────────────────────────── +set(APP_SOURCES + main.cpp + App.cpp + WSClient.cpp + Protocol.cpp + SourcePanel.cpp + PlotPanel.cpp + TriggerPanel.cpp + StatsPanel.cpp +) + +add_executable(StreamHubClient ${APP_SOURCES}) + +target_include_directories(StreamHubClient PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + # Reuse WSFrame.h, SHA1.h, Base64.h from StreamHub without copying + ${CMAKE_CURRENT_SOURCE_DIR}/../../Source/Applications/StreamHub +) + +target_link_libraries(StreamHubClient PRIVATE + imgui_lib + SDL2::SDL2 + OpenGL::GL + pthread +) + +# Source-tree resource dir baked in as last-resort runtime fallback +target_compile_definitions(StreamHubClient PRIVATE + APP_RESOURCE_DIR="${RESOURCE_DIR}" +) +if(HAVE_FONT_AWESOME) + target_include_directories(StreamHubClient PRIVATE ${FONT_DIR}) + target_compile_definitions(StreamHubClient PRIVATE HAVE_FONT_AWESOME) +endif() + +target_compile_options(StreamHubClient PRIVATE + -Wall -Wextra -Wno-unused-parameter +) + +# ── Install ─────────────────────────────────────────────────────────────────── +install(TARGETS StreamHubClient DESTINATION bin) +install(DIRECTORY ${RESOURCE_DIR}/fonts DESTINATION share/streamhub-client) +install(FILES ${RESOURCE_DIR}/streamhub-client.desktop + DESTINATION share/applications) +install(FILES ${RESOURCE_DIR}/icons/streamhub-client.svg + DESTINATION share/icons/hicolor/scalable/apps) diff --git a/Client/streamhub/Icons.h b/Client/streamhub/Icons.h new file mode 100644 index 0000000..267f191 --- /dev/null +++ b/Client/streamhub/Icons.h @@ -0,0 +1,34 @@ +/** + * @file Icons.h + * @brief Icon glyphs for UI labels. + * + * When Font Awesome 6 is available (downloaded by CMake and merged into the + * default font in main.cpp) the ICON_FA_* macros expand to UTF-8 glyphs. + * Otherwise they fall back to plain ASCII so no label ever renders as "?". + */ + +#pragma once + +#ifdef HAVE_FONT_AWESOME +#include "IconsFontAwesome6.h" +#else +#define ICON_FA_TABLE_CELLS_LARGE "" +#define ICON_FA_PAUSE "||" +#define ICON_FA_PLAY ">" +#define ICON_FA_BOLT "" +#define ICON_FA_CHART_COLUMN "" +#define ICON_FA_PLUS "+" +#define ICON_FA_CIRCLE "*" +#define ICON_FA_TRASH_CAN "x" +#define ICON_FA_TOWER_BROADCAST "" +#define ICON_FA_ARROW_ROTATE_LEFT "<" +#define ICON_FA_EXPAND "[]" +#define ICON_FA_CROSSHAIRS "+" +#define ICON_FA_WAVE_SQUARE "~" +#define ICON_FA_STOP "[]" +#define ICON_FA_CHEVRON_LEFT "<" +#define ICON_FA_CHEVRON_RIGHT ">" +#define ICON_FA_ARROW_TREND_UP "/\\" +#define ICON_FA_ARROW_TREND_DOWN "\\/" +#define ICON_FA_ARROWS_UP_DOWN "/\\\\/" +#endif diff --git a/Client/streamhub/PlotPanel.cpp b/Client/streamhub/PlotPanel.cpp new file mode 100644 index 0000000..563ed9a --- /dev/null +++ b/Client/streamhub/PlotPanel.cpp @@ -0,0 +1,767 @@ +/** + * @file PlotPanel.cpp + * @brief ImPlot oscilloscope panel — oscilloscope-style fixed Y axis. + * + * Y axis: always [-4, +4] divisions. Normalisation per plot mode: + * normal — each signal scaled by its VScale (auto / range / manual) + * digital — each signal quantised hi/lo within its own horizontal band + * mixed — per-signal band; quantised or auto-scaled (vs.digitalInMixed) + * + * X axis: live mode follows the wall clock (hub time base is Unix seconds); + * scroll/drag switches to a stored range with zoom history (Back/Fit/Live). + * When zoomed, a hi-res window is fetched from the hub over WS (zoom cmd). + * When a trigger capture exists (and the trigger bar is open) the panel + * renders the capture relative to the trigger instant in [-pre, +post]. + */ + +#include "PlotPanel.h" +#include "App.h" +#include "Protocol.h" +#include "SignalBuffer.h" + +#include "imgui.h" +#include "implot.h" +#include "Icons.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace StreamHubClient { + +/* DnD payload */ +struct DragPayload { int srcIdx; int sigIdx; }; + +static const int kPlotSlotsMax = 8; /* mirrors App::kMaxPlotSlots */ + +/* Live windows at or below this width refresh via hub hi-res zoom fetches + * instead of (in addition to) the decimated push stream. */ +static const double kLiveHiResMaxWin = 2.0; /* seconds */ + +/* ── Helpers ─────────────────────────────────────────────────────────────── */ + +static const char* fmtVal(char* buf, size_t sz, double v) { + if (!std::isfinite(v)) { snprintf(buf, sz, "?"); return buf; } + double abs = std::fabs(v); + if (v == 0.0) snprintf(buf, sz, "0"); + else if (abs >= 1e4 || abs < 1e-3) snprintf(buf, sz, "%.2e", v); + else snprintf(buf, sz, "%.3g", v); + return buf; +} + +/** Resolve the effective divValue and offset for this assignment given raw data. */ +static void resolveVScale(PlotAssignment& a, const Signal& sig, + const std::vector& rawV) { + if (a.vs.mode == 1) { /* range */ + double rmin = sig.meta.rangeMin, rmax = sig.meta.rangeMax; + if (rmax > rmin) { + a.vs.resolvedDiv = std::max((rmax - rmin) / 8.0, 1e-30); + a.vs.resolvedOffset = (rmin + rmax) / 2.0; + return; + } + /* fall through to auto if no valid range */ + } + if (a.vs.mode == 2) { /* manual */ + a.vs.resolvedDiv = std::max(a.vs.divValue, 1e-30); + a.vs.resolvedOffset = a.vs.offset; + return; + } + /* auto: fit data in central 6 of 8 divisions */ + double mn = 1e300, mx = -1e300; + for (double v : rawV) { 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; } + a.vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30); + a.vs.resolvedOffset = (mx + mn) / 2.0; +} + +static double normalizeY(double raw, const VScale& vs) { + return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos; +} + +/** Min/max of a vector (returns false if empty/non-finite). */ +static bool dataMinMax(const std::vector& v, double& mn, double& mx) { + mn = 1e300; mx = -1e300; + for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } } + return mx >= mn; +} + +/** Digital/mixed band normalisation (ports web applyDigitalNorm/applyMixedNorm). + * Band index ki of n; quantize=true → hi/lo threshold mode. */ +static void bandNormalize(const std::vector& raw, std::vector& out, + int ki, int n, bool quantize) { + double bandH = 8.0 / std::max(n, 1); + double centerY = 4.0 - (ki + 0.5) * bandH; + double hi = centerY + bandH * 0.35; + double lo = centerY - bandH * 0.35; + + double mn, mx; + bool haveMM = dataMinMax(raw, mn, mx); + + out.resize(raw.size()); + if (quantize) { + double thr = haveMM ? (mn + mx) / 2.0 : 0.5; + for (size_t i = 0; i < raw.size(); i++) { + double v = raw[i]; + out[i] = !std::isfinite(v) ? NAN : (v >= thr ? hi : lo); + } + } else { + if (!haveMM) { mn = 0.0; mx = 1.0; } + if (mn == mx) { mn -= 1.0; mx += 1.0; } + double range = mx - mn, bandRange = hi - lo; + for (size_t i = 0; i < raw.size(); i++) { + double v = raw[i]; + out[i] = !std::isfinite(v) ? NAN : lo + (v - mn) / range * bandRange; + } + } +} + +/** Nearest sample value at time x (t sorted ascending). Returns NAN if empty. */ +static double sampleAt(const std::vector& t, const std::vector& v, + double x) { + if (t.empty() || v.size() != t.size()) { return NAN; } + auto it = std::lower_bound(t.begin(), t.end(), x); + size_t i = static_cast(it - t.begin()); + if (i >= t.size()) { i = t.size() - 1; } + else if (i > 0 && (x - t[i-1]) < (t[i] - x)) { i--; } + return v[i]; +} + +/* ── Main render ─────────────────────────────────────────────────────────── */ + +void RenderPlotPanel(App& app, int plotIdx, bool& paused) { + auto& slots = app.plotSlot(plotIdx); + auto& sources = app.sources(); + bool& live = app.liveFollow(plotIdx); + int& actSlot = app.activeSlot(plotIdx); + int& vMode = app.plotVMode(plotIdx); + + ImGui::PushID(plotIdx); + + /* Hub timestamps are Unix wall-clock seconds: live window follows the + * local wall clock, not the newest data timestamp. */ + const double wallNow = std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()).count(); + + /* Trigger view: render the hub capture relative to the trigger instant */ + const CaptureFrame* cap = app.capture(); + const bool trigView = (cap != nullptr) && app.showTrigBar(); + + /* Hi-res zoom cache for this plot */ + auto& zc = app.zoomCache(plotIdx); + + /* ── Paused view snapshot (double buffer: active ring / frozen view) ── * + * The rings keep filling while paused; on the pause edge we freeze a + * copy of each slot's data and render from it until resumed. */ + auto& snap = app.plotSnap(plotIdx); + if (paused) { + if (!snap.valid) { + snap.keys.clear(); snap.t.clear(); snap.v.clear(); + for (const auto& a : slots) { + std::vector tt, vv; + if (a.sourceIdx >= 0 && a.sourceIdx < (int)sources.size()) { + (void) sources[a.sourceIdx].signals[a.signalIdx] + .buf.readLast((size_t)app.maxPoints(), tt, vv); + } + snap.keys.push_back(app.slotKey(a)); + snap.t.push_back(std::move(tt)); + snap.v.push_back(std::move(vv)); + } + snap.valid = true; + /* Freeze the X axis where it is and switch to zoom/pan mode */ + if (live) { + app.initPlotX(plotIdx, wallNow); + live = false; + } + } + } else if (snap.valid) { + snap.valid = false; + } + + /* ── Collect raw data & resolve vscale ──────────────────────────────── */ + /* per-slot storage so normalisation pass can reference them */ + static thread_local std::vector> tStore, vStore; + tStore.resize(slots.size()); + vStore.resize(slots.size()); + + /* Live hi-res refresh: with a narrow live window the client ring (built + * from decimated pushes) undersamples the visible range. Periodically + * 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). */ + const bool liveHiRes = !trigView && live && !paused && + app.windowSec() <= kLiveHiResMaxWin && + zc.valid && + (zc.t1 - zc.t0) >= app.windowSec() * 0.9 && + (wallNow - zc.t1) < 3.0; + + const bool useZoomData = !trigView && zc.valid && + (liveHiRes || + (!live && + zc.t0 <= app.plotXMin(plotIdx) + 1e-9 && + zc.t1 >= app.plotXMax(plotIdx) - 1e-9)); + + /* Fill tStore/vStore[si] from the frozen view (paused) or the live ring */ + auto readBase = [&](size_t si, const Signal& sig, const std::string& key) { + if (paused && snap.valid) { + for (size_t k = 0; k < snap.keys.size(); k++) { + if (snap.keys[k] == key) { + tStore[si] = snap.t[k]; + vStore[si] = snap.v[k]; + return; + } + } + } + (void) sig.buf.readLast((size_t)app.maxPoints(), tStore[si], vStore[si]); + }; + + for (size_t si = 0; si < slots.size(); si++) { + auto& a = slots[si]; + tStore[si].clear(); vStore[si].clear(); + if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue; + const auto& sig = sources[a.sourceIdx].signals[a.signalIdx]; + const std::string key = app.slotKey(a); + + if (trigView) { + /* Capture signals carry full keys "src:sig"; t relative to trigTime */ + for (const auto& cs : cap->signals) { + if (cs.key != key) continue; + size_t n = std::min(cs.t.size(), cs.v.size()); + tStore[si].reserve(n); vStore[si].reserve(n); + for (size_t i = 0; i < n; i++) { + tStore[si].push_back(cs.t[i] - cap->trigTime); + vStore[si].push_back(cs.v[i]); + } + break; + } + } else if (useZoomData) { + bool found = false; + for (const auto& zs : zc.signals) { + if (zs.name != key) continue; + tStore[si] = zs.t; + vStore[si] = zs.v; + found = true; + break; + } + if (!found) { + readBase(si, sig, key); + } + } else { + readBase(si, sig, key); + } + resolveVScale(a, sig, vStore[si]); + } + + /* clamp active slot */ + if (actSlot >= (int)slots.size()) actSlot = -1; + + /* ── Badge row ──────────────────────────────────────────────────────── */ + /* Catppuccin accent for active badge border */ + static const ImVec4 kAccent{0.537f,0.706f,0.980f,1.f}; + + for (int i = (int)slots.size()-1; i >= 0; i--) { + auto& a = slots[i]; + if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue; + auto& sig = sources[a.sourceIdx].signals[a.signalIdx]; + + bool isActive = (actSlot == i); + + /* colored badge */ + ImGui::PushStyleColor(ImGuiCol_Button, + isActive ? kAccent : sig.color); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, + isActive ? kAccent : ImVec4(sig.color.x*1.15f,sig.color.y*1.15f, + sig.color.z*1.15f,sig.color.w)); + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(0.067f,0.067f,0.106f,1.f)); + + char badge[80]; + /* show vscale info: resolved div value */ + char dvbuf[16]; + fmtVal(dvbuf, sizeof(dvbuf), a.vs.resolvedDiv); + snprintf(badge, sizeof(badge), "%s %s/div##b%d", + sig.meta.name.c_str(), dvbuf, i); + + if (ImGui::SmallButton(badge)) { + actSlot = (actSlot == i) ? -1 : i; /* toggle active */ + } + ImGui::PopStyleColor(3); + + /* right-click: styling + remove */ + char popId[24]; snprintf(popId, sizeof(popId), "##bp%d_%d", plotIdx, i); + if (ImGui::BeginPopupContextItem(popId)) { + ImGui::TextUnformatted(sig.meta.name.c_str()); + ImGui::Separator(); + + float col[4] = {sig.color.x, sig.color.y, sig.color.z, sig.color.w}; + if (ImGui::ColorEdit4("Color##sc", col, + ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoAlpha)) { + sig.color = ImVec4(col[0], col[1], col[2], col[3]); + } + ImGui::SetNextItemWidth(120.f); + ImGui::SliderFloat("Width##sw", &sig.lineWidth, 0.5f, 5.f, "%.1f"); + + static const char* kMarkerNames[] = + {"None", "Circle", "Square", "Diamond", "Up", "Down", + "Left", "Right", "Cross", "Plus", "Asterisk"}; + int mk = sig.marker + 1; /* -1 (none) → 0 */ + ImGui::SetNextItemWidth(120.f); + if (ImGui::Combo("Marker##sm", &mk, kMarkerNames, 11)) { + sig.marker = mk - 1; + } + + if (vMode == 2) { /* mixed mode: per-trace digital toggle */ + ImGui::Checkbox("Digital (mixed)##sd", &a.vs.digitalInMixed); + } + + ImGui::Separator(); + bool shouldBreak = false; + if (ImGui::MenuItem("Remove from plot")) { + if (actSlot == i) actSlot = -1; + else if (actSlot > i) actSlot--; + slots.erase(slots.begin() + i); + shouldBreak = true; + } + ImGui::EndPopup(); + if (shouldBreak) { ImGui::SameLine(); break; } + } + ImGui::SameLine(); + } + + /* live button (pause & cursors live in the global toolbar) */ + { + static const ImVec4 kLiveOn {0.086f,0.494f,0.267f,1.f}; + static const ImVec4 kLiveOnH{0.114f,0.627f,0.341f,1.f}; + static const ImVec4 kLiveOff{0.192f,0.196f,0.267f,1.f}; + static const ImVec4 kLiveOffH{0.271f,0.278f,0.353f,1.f}; + ImGui::PushStyleColor(ImGuiCol_Button, + live ? kLiveOn : kLiveOff); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, + live ? kLiveOnH : kLiveOffH); + if (ImGui::SmallButton(ICON_FA_TOWER_BROADCAST " Live")) { + live = true; + paused = false; + app.zoomHist(plotIdx).clear(); + zc.valid = false; + } + ImGui::PopStyleColor(2); + } + + /* Back / Fit (zoom history) — only meaningful when not live */ + if (!live && !trigView) { + ImGui::SameLine(); + auto& hist = app.zoomHist(plotIdx); + if (hist.empty()) { ImGui::BeginDisabled(); } + if (ImGui::SmallButton(ICON_FA_ARROW_ROTATE_LEFT " Back##zb")) { + app.setPlotX(plotIdx, hist.back().first, hist.back().second); + hist.pop_back(); + } + if (hist.empty()) { ImGui::EndDisabled(); } + ImGui::SameLine(); + if (ImGui::SmallButton(ICON_FA_EXPAND " Fit##zf")) { + double mn = 1e300, mx = -1e300; + for (size_t si = 0; si < slots.size(); si++) { + if (tStore[si].empty()) continue; + mn = std::min(mn, tStore[si].front()); + mx = std::max(mx, tStore[si].back()); + } + if (mx > mn) { + app.pushZoomHist(plotIdx); + app.setPlotX(plotIdx, mn, mx); + } + } + } + + /* Norm mode + cursors toggles */ + ImGui::SameLine(); + { + static const char* kVModes[] = {"Norm", "Dig", "Mix"}; + ImGui::SetNextItemWidth(58.f); + char vmId[16]; snprintf(vmId, sizeof(vmId), "##vm%d", plotIdx); + ImGui::Combo(vmId, &vMode, kVModes, 3); + } + + /* ── VScale toolbar (shown when an active signal is selected) ───────── */ + if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) { + auto& a = slots[actSlot]; + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f)); + + /* mode buttons */ + static const char* kModeLabels[] = {"Auto","Range","Manual"}; + for (int m = 0; m < 3; m++) { + bool sel = (a.vs.mode == m); + if (sel) { + ImGui::PushStyleColor(ImGuiCol_Button, + ImVec4(0.537f,0.706f,0.980f,0.3f)); + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(0.537f,0.706f,0.980f,1.f)); + } + if (ImGui::SmallButton(kModeLabels[m])) { a.vs.mode = m; } + if (sel) ImGui::PopStyleColor(2); + if (m < 2) ImGui::SameLine(0.f,2.f); + } + ImGui::SameLine(0.f,10.f); + + /* resolved info */ + char rbuf[24], obuf[24]; + fmtVal(rbuf, sizeof(rbuf), a.vs.resolvedDiv); + fmtVal(obuf, sizeof(obuf), a.vs.resolvedOffset); + + if (a.vs.mode == 2) { /* manual: editable */ + ImGui::SetNextItemWidth(70.f); + ImGui::InputDouble("V/div##vd", &a.vs.divValue, 0,0,"%.4g"); + ImGui::SameLine(0.f,4.f); + ImGui::SetNextItemWidth(80.f); + ImGui::InputDouble("Offset##vo", &a.vs.offset, 0,0,"%.4g"); + } else { + ImGui::TextDisabled("%s/div @%s", rbuf, obuf); + } + ImGui::SameLine(0.f,10.f); + ImGui::SetNextItemWidth(50.f); + float sp = (float)a.vs.screenPos; + if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) { + a.vs.screenPos = sp; + } + + ImGui::PopStyleVar(); + } + + /* ── Cursor readouts ─────────────────────────────────────────────────── */ + if (app.cursorsOn()) { + double cA = app.cursorA(), cB = app.cursorB(); + double dT = cB - cA; + char d1[24], d2[24]; + fmtVal(d1, sizeof(d1), dT); + fmtVal(d2, sizeof(d2), (dT != 0.0) ? 1.0 / std::fabs(dT) : 0.0); + ImGui::TextColored(ImVec4(0.980f,0.702f,0.529f,1.f), + "dT=%ss 1/dT=%sHz", d1, d2); + for (size_t si = 0; si < slots.size(); si++) { + auto& a = slots[si]; + if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue; + if (tStore[si].empty()) continue; + const auto& sig = sources[a.sourceIdx].signals[a.signalIdx]; + double vA = sampleAt(tStore[si], vStore[si], cA); + double vB = sampleAt(tStore[si], vStore[si], cB); + char a1[24], a2[24], a3[24]; + fmtVal(a1, sizeof(a1), vA); + fmtVal(a2, sizeof(a2), vB); + fmtVal(a3, sizeof(a3), vB - vA); + ImGui::SameLine(0.f, 14.f); + ImGui::TextColored(sig.color, "%s: A=%s B=%s d=%s", + sig.meta.name.c_str(), a1, a2, a3); + } + } + + ImGui::Separator(); + + /* ── ImPlot ──────────────────────────────────────────────────────────── */ + char plotId[32]; snprintf(plotId, sizeof(plotId), "##plot%d", plotIdx); + + /* Both axes are fully controlled by us — disable ImPlot's native zoom. */ + ImPlotFlags plotFlags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend + | ImPlotFlags_NoInputs; + if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) { + + /* Both axes locked so ImPlot never overrides our explicit limits. */ + ImPlot::SetupAxes(trigView ? "t - trig (s)" : "Time (s)", nullptr, + ImPlotAxisFlags_Lock, + ImPlotAxisFlags_Lock); + + /* Y axis always ±4 div space */ + ImPlot::SetupAxisLimits(ImAxis_Y1, -4.05, 4.05, ImGuiCond_Always); + + /* X axis: trig view → capture window; live → wall clock; else stored */ + double xMin, xMax; + if (trigView) { + xMin = -cap->preSec; xMax = cap->postSec; + } else if (live && !paused) { + if (liveHiRes) { + /* Anchor to the latest fetched hi-res slice so the trace + * fills the window (refreshes at the fetch rate). */ + xMax = zc.t1; xMin = zc.t1 - app.windowSec(); + } else { + xMax = wallNow; xMin = wallNow - app.windowSec(); + } + } else { + xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx); + } + if (trigView || (live && !paused) || !live) { + if (xMax > xMin) { + ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always); + } + } + + /* ── Y axis ticks labelled from active signal (normal mode only) ── */ + static double yTickVals[9] = {-4,-3,-2,-1,0,1,2,3,4}; + static char yTickBufs[9][20]; + static const char* yTickLabels[9]; + + if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) { + const auto& av = slots[actSlot].vs; + for (int d = 0; d < 9; d++) { + double divPos = yTickVals[d]; + double rawVal = av.resolvedOffset + (divPos - av.screenPos) * av.resolvedDiv; + fmtVal(yTickBufs[d], sizeof(yTickBufs[d]), rawVal); + yTickLabels[d] = yTickBufs[d]; + } + } else { + for (int d = 0; d < 9; d++) { + snprintf(yTickBufs[d], sizeof(yTickBufs[d]), "%.0f", yTickVals[d]); + yTickLabels[d] = yTickBufs[d]; + } + } + ImPlot::SetupAxisTicks(ImAxis_Y1, yTickVals, 9, yTickLabels, false); + + /* ── X axis ticks ───────────────────────────────────────────────── */ + if (xMax > xMin) { + double step = (xMax - xMin) / 10.0; + double tickX[11]; + for (int t = 0; t <= 10; t++) tickX[t] = xMin + t * step; + ImPlot::SetupAxisTicks(ImAxis_X1, tickX, 11, nullptr, false); + } + + ImPlot::SetupFinish(); + + /* ── Custom oscilloscope input handling (after SetupFinish) ─────── * + * + * Scroll → Y zoom : active signal V/div × factor + * Ctrl + Scroll → X zoom : windowSec_ (live) or stored X range (non-live) + * Shift + Scroll → Y pan : active signal screenPos shift + * Right-drag → X pan : (non-live) translate stored X range + * + * Right-drag while live → transitions to non-live mode first. */ + + /* With ImPlotFlags_NoInputs ImPlot never updates plot.Hovered, so + * IsPlotHovered()/IsAxisHovered() always return false — test the + * plot rectangle against the mouse ourselves. */ + const ImVec2 ppos = ImPlot::GetPlotPos(); + const ImVec2 psz = ImPlot::GetPlotSize(); + bool overPlot = ImGui::IsWindowHovered( + ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + ImGui::IsMouseHoveringRect( + ppos, ImVec2(ppos.x + psz.x, ppos.y + psz.y)); + + /* Zoom-history debounce: coalesce rapid scroll steps into one entry */ + static double lastHistPush[kPlotSlotsMax] = {}; + + if (overPlot && !trigView) { + ImGuiIO& io2 = ImGui::GetIO(); + const float wheel = io2.MouseWheel; + const bool ctrl = io2.KeyCtrl; + const bool shift = io2.KeyShift; + const double now = ImGui::GetTime(); + + if (wheel != 0.f) { + /* Zoom factor: scroll up = zoom in (×0.8), down = zoom out (×1.25) */ + const double zoomIn = 0.8; + const double zoomOut = 1.25; + double factor = (wheel > 0.f) ? zoomIn : zoomOut; + + if (ctrl) { + /* ── X zoom ─────────────────────────────────────────── */ + if (live) { + app.setWindowSec(app.windowSec() * factor); + } else { + if (now - lastHistPush[plotIdx] > 0.6) { + app.pushZoomHist(plotIdx); + lastHistPush[plotIdx] = now; + } + double cx = (app.plotXMin(plotIdx) + app.plotXMax(plotIdx)) * 0.5; + double half = (app.plotXMax(plotIdx) - app.plotXMin(plotIdx)) * 0.5 * factor; + app.setPlotX(plotIdx, cx - half, cx + half); + } + } else if (shift) { + /* ── Y offset of active signal ───────────────────────── */ + if (actSlot >= 0 && actSlot < (int)slots.size()) { + auto& a = slots[actSlot]; + /* Switch to manual so position is retained */ + if (a.vs.mode != 2) { + a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30); + a.vs.offset = a.vs.resolvedOffset; + a.vs.mode = 2; + } + /* Each scroll step moves by 0.5 screen divisions */ + a.vs.screenPos += (wheel > 0.f) ? 0.5 : -0.5; + } + } else { + /* ── Y zoom of active signal ─────────────────────────── */ + if (actSlot >= 0 && actSlot < (int)slots.size()) { + auto& a = slots[actSlot]; + if (a.vs.mode != 2) { + 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 { + /* No active signal: plain scroll falls back to X zoom + * so the wheel always does something useful. */ + if (live) { + app.setWindowSec(app.windowSec() * factor); + } else { + if (now - lastHistPush[plotIdx] > 0.6) { + app.pushZoomHist(plotIdx); + lastHistPush[plotIdx] = now; + } + double cx = (app.plotXMin(plotIdx) + app.plotXMax(plotIdx)) * 0.5; + double half = (app.plotXMax(plotIdx) - app.plotXMin(plotIdx)) * 0.5 * factor; + app.setPlotX(plotIdx, cx - half, cx + half); + } + } + } + } + + /* Right-drag → X pan (non-live). Transition live→non-live on drag start. */ + if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) { + if (live) { + /* Seed stored range from current live window */ + app.initPlotX(plotIdx, wallNow); + live = false; + lastHistPush[plotIdx] = now; + } + ImVec2 delta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Right, 0.f); + ImGui::ResetMouseDragDelta(ImGuiMouseButton_Right); + ImVec2 pxSize = ImPlot::GetPlotSize(); + if (pxSize.x > 0.f) { + double xRange = app.plotXMax(plotIdx) - app.plotXMin(plotIdx); + double dt = -(double)delta.x / (double)pxSize.x * xRange; + app.setPlotX(plotIdx, + app.plotXMin(plotIdx) + dt, + app.plotXMax(plotIdx) + dt); + } + } + } + + /* ── Hi-res WS zoom requests ────────────────────────────────────── */ + if (!trigView) { + std::string csv; + for (const auto& a : slots) { + std::string k = app.slotKey(a); + if (k.empty()) continue; + if (!csv.empty()) csv += ","; + csv += k; + } + + if (live && !paused && app.windowSec() <= kLiveHiResMaxWin) { + /* Live, zoomed in: continuously refresh the window with a + * fresh hi-res slice from the hub raw ring (throttled). */ + static double lastLiveReq[kPlotSlotsMax] = {}; + const double now = ImGui::GetTime(); + if (!csv.empty() && !zc.pending && + now - lastLiveReq[plotIdx] > 0.25) { + app.requestZoom(plotIdx, wallNow - app.windowSec(), + wallNow, csv); + lastLiveReq[plotIdx] = now; + } + } else if (!live) { + /* Non-live: debounced — every zoom/pan settles into one + * request, so each zoom level gets fresh resolution. */ + static double rangeChangedAt[kPlotSlotsMax] = {}; + static double lastT0[kPlotSlotsMax] = {}, lastT1[kPlotSlotsMax] = {}; + const double now = ImGui::GetTime(); + double t0 = app.plotXMin(plotIdx), t1 = app.plotXMax(plotIdx); + if (t0 != lastT0[plotIdx] || t1 != lastT1[plotIdx]) { + lastT0[plotIdx] = t0; + lastT1[plotIdx] = t1; + rangeChangedAt[plotIdx] = now; + } else if (rangeChangedAt[plotIdx] > 0.0 && + now - rangeChangedAt[plotIdx] > 0.35 && + !zc.pending && + !(zc.valid && zc.t0 == t0 && zc.t1 == t1)) { + if (!csv.empty()) { app.requestZoom(plotIdx, t0, t1, csv); } + rangeChangedAt[plotIdx] = 0.0; + } + } + } + + /* ── Plot each signal ──────────────────────────────────────────── */ + static const size_t kMaxPush = 2400; + static thread_local std::vector tDec, vDec, vNorm; + + int nTraces = 0; + for (const auto& a : slots) { + if (a.sourceIdx >= 0 && a.sourceIdx < (int)sources.size()) nTraces++; + } + + int ki = 0; + for (size_t si = 0; si < slots.size(); si++) { + auto& a = slots[si]; + if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue; + const auto& sig = sources[a.sourceIdx].signals[a.signalIdx]; + int myKi = ki++; + + if (tStore[si].empty()) continue; + + size_t nOut = LTTBDecimate(tStore[si], vStore[si], tDec, vDec, kMaxPush); + if (nOut == 0) continue; + + /* normalise to ±4 div space */ + if (vMode == 1) { /* digital */ + bandNormalize(vDec, vNorm, myKi, nTraces, true); + } else if (vMode == 2) { /* mixed */ + bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed); + } else { + vNorm.resize(nOut); + for (size_t k = 0; k < nOut; k++) { + vNorm[k] = normalizeY(vDec[k], a.vs); + } + } + + ImPlot::SetNextLineStyle(sig.color, sig.lineWidth); + if (sig.marker >= 0) { + ImPlot::SetNextMarkerStyle(sig.marker, 3.f, sig.color); + } + ImPlot::PlotLine(sig.meta.name.c_str(), + tDec.data(), vNorm.data(), (int)nOut); + } + + /* Trigger instant marker (capture view: t = 0) */ + if (trigView) { + double t0m = 0.0; + ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f), + 1.5f, ImPlotDragToolFlags_NoInputs); + } + + /* Cursors A/B — global: same position on every plot, drag anywhere */ + if (app.cursorsOn()) { + static const ImVec4 kCursA{0.980f,0.702f,0.529f,0.9f}; + static const ImVec4 kCursB{0.796f,0.651f,0.969f,0.9f}; + ImPlot::DragLineX(910, &app.cursorA(), kCursA, 1.2f); + ImPlot::DragLineX(911, &app.cursorB(), kCursB, 1.2f); + ImPlot::TagX(app.cursorA(), kCursA, "A"); + ImPlot::TagX(app.cursorB(), kCursB, "B"); + } + + /* Drag-and-drop target */ + if (ImPlot::BeginDragDropTargetPlot()) { + if (const ImGuiPayload* pl = + ImGui::AcceptDragDropPayload("SIGNAL_REF")) { + DragPayload dp; + memcpy(&dp, pl->Data, sizeof(dp)); + bool found = false; + for (const auto& a : slots) { + if (a.sourceIdx == dp.srcIdx && a.signalIdx == dp.sigIdx) { + found = true; break; + } + } + if (!found) { + PlotAssignment pa; + pa.sourceIdx = dp.srcIdx; + pa.signalIdx = dp.sigIdx; + slots.push_back(pa); + } + } + ImPlot::EndDragDropTarget(); + } + + ImPlot::EndPlot(); + } + + ImGui::PopID(); +} + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/PlotPanel.h b/Client/streamhub/PlotPanel.h new file mode 100644 index 0000000..5107817 --- /dev/null +++ b/Client/streamhub/PlotPanel.h @@ -0,0 +1,19 @@ +/** + * @file PlotPanel.h + * @brief ImPlot-based oscilloscope plot panel. + */ + +#pragma once + +namespace StreamHubClient { +class App; + +/** + * @brief Render one oscilloscope plot panel. + * @param app Application state. + * @param plotIdx Which slot in app.plotSlots() to render. + * @param paused Whether data updates are paused for this plot. + */ +void RenderPlotPanel(App& app, int plotIdx, bool& paused); + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/Protocol.cpp b/Client/streamhub/Protocol.cpp new file mode 100644 index 0000000..3815ebd --- /dev/null +++ b/Client/streamhub/Protocol.cpp @@ -0,0 +1,533 @@ +/** + * @file Protocol.cpp + * @brief StreamHub wire protocol implementation. + */ + +#include "Protocol.h" + +#include +#include +#include +#include +#include + +namespace StreamHubClient { + +/*---------------------------------------------------------------------------*/ +/* Binary frame parser */ +/*---------------------------------------------------------------------------*/ + +/* Read a uint32 LE from buf+offset; advance offset by 4. */ +static uint32_t readU32(const uint8_t* buf, size_t& off, size_t len) { + if (off + 4 > len) { return 0u; } + uint32_t v = static_cast(buf[off]) + | (static_cast(buf[off+1]) << 8) + | (static_cast(buf[off+2]) << 16) + | (static_cast(buf[off+3]) << 24); + off += 4; + return v; +} + +/* Read a uint16 LE from buf+offset; advance offset by 2. */ +static uint16_t readU16(const uint8_t* buf, size_t& off, size_t len) { + if (off + 2 > len) { return 0u; } + uint16_t v = static_cast(buf[off]) + | (static_cast(buf[off+1]) << 8); + off += 2; + return v; +} + +/* Read a float64 LE from buf+offset; advance offset by 8. */ +static double readF64(const uint8_t* buf, size_t& off, size_t len) { + if (off + 8 > len) { return 0.0; } + double v; + memcpy(&v, buf + off, 8); + off += 8; + return v; +} + +bool ParseBinaryFrame(const uint8_t* buf, size_t len, DataFrame& out) { + out.sourceId.clear(); + out.signals.clear(); + + size_t off = 0; + if (off >= len) { return false; } + + uint8_t version = buf[off++]; + if (version != 1u) { return false; } + + if (off >= len) { return false; } + uint8_t idLen = buf[off++]; + + if (off + idLen + 4 > len) { return false; } + out.sourceId.assign(reinterpret_cast(buf + off), idLen); + off += idLen; + + uint32_t numSignals = readU32(buf, off, len); + + for (uint32_t s = 0; s < numSignals; s++) { + uint16_t keyLen = readU16(buf, off, len); + if (off + keyLen + 4 > len) { return false; } + + FrameSignal sig; + sig.name.assign(reinterpret_cast(buf + off), keyLen); + off += keyLen; + + uint32_t pairCount = readU32(buf, off, len); + if (off + static_cast(pairCount) * 16u > len) { return false; } + + sig.t.resize(pairCount); + sig.v.resize(pairCount); + for (uint32_t i = 0; i < pairCount; i++) { + sig.t[i] = readF64(buf, off, len); + } + for (uint32_t i = 0; i < pairCount; i++) { + sig.v[i] = readF64(buf, off, len); + } + out.signals.push_back(std::move(sig)); + } + return true; +} + +bool ParseCaptureFrame(const uint8_t* buf, size_t len, CaptureFrame& out) { + out.signals.clear(); + + size_t off = 0; + if (off >= len) { return false; } + + uint8_t version = buf[off++]; + if (version != 2u) { return false; } + + if (off + 8u * 3u + 4u > len) { return false; } + out.trigTime = readF64(buf, off, len); + out.preSec = readF64(buf, off, len); + out.postSec = readF64(buf, off, len); + + uint32_t numSignals = readU32(buf, off, len); + + for (uint32_t s = 0; s < numSignals; s++) { + uint16_t keyLen = readU16(buf, off, len); + if (off + keyLen + 4 > len) { return false; } + + CaptureSignal sig; + sig.key.assign(reinterpret_cast(buf + off), keyLen); + off += keyLen; + + uint32_t pairCount = readU32(buf, off, len); + if (off + static_cast(pairCount) * 16u > len) { return false; } + + sig.t.resize(pairCount); + sig.v.resize(pairCount); + for (uint32_t i = 0; i < pairCount; i++) { + sig.t[i] = readF64(buf, off, len); + } + for (uint32_t i = 0; i < pairCount; i++) { + sig.v[i] = readF64(buf, off, len); + } + out.signals.push_back(std::move(sig)); + } + return true; +} + +/*---------------------------------------------------------------------------*/ +/* JSON helpers */ +/*---------------------------------------------------------------------------*/ + +static bool jsonGetStr(const char* json, const char* key, char* out, size_t outSz) { + char pat[128]; + snprintf(pat, sizeof(pat), "\"%s\":\"", key); + const char* p = strstr(json, pat); + if (!p) { return false; } + p += strlen(pat); + size_t i = 0; + while (*p && *p != '"' && i < outSz - 1) { out[i++] = *p++; } + out[i] = '\0'; + return true; +} + +static bool jsonGetDouble(const char* json, const char* key, double& out) { + char pat[128]; + snprintf(pat, sizeof(pat), "\"%s\":", key); + const char* p = strstr(json, pat); + if (!p) { return false; } + p += strlen(pat); + while (*p == ' ') { p++; } + if (!*p) { return false; } + out = strtod(p, nullptr); + return true; +} + +static bool jsonGetUint64(const char* json, const char* key, uint64_t& out) { + double v = 0.0; + if (!jsonGetDouble(json, key, v)) { return false; } + out = static_cast(v); + return true; +} + +static bool jsonGetInt(const char* json, const char* key, int& out) { + double v = 0.0; + if (!jsonGetDouble(json, key, v)) { return false; } + out = static_cast(v); + return true; +} + +/*---------------------------------------------------------------------------*/ +/* JSON command builders */ +/*---------------------------------------------------------------------------*/ + +std::string BuildPing() { return "{\"type\":\"ping\"}"; } +std::string BuildGetSources() { return "{\"type\":\"getSources\"}"; } +std::string BuildGetStats() { return "{\"type\":\"getStats\"}"; } + +std::string BuildGetConfig(const std::string& sourceId) { + char buf[256]; + snprintf(buf, sizeof(buf), "{\"type\":\"getConfig\",\"sourceId\":\"%s\"}", + sourceId.c_str()); + return buf; +} + +std::string BuildAddSource(const std::string& label, const std::string& addr, + const std::string& multicastGroup, uint16_t dataPort) { + char buf[512]; + int n = snprintf(buf, sizeof(buf), + "{\"type\":\"addSource\",\"label\":\"%s\",\"addr\":\"%s\"", + label.c_str(), addr.c_str()); + if (!multicastGroup.empty() && n > 0 && n < (int)sizeof(buf)) { + n += snprintf(buf + n, sizeof(buf) - n, + ",\"multicastGroup\":\"%s\"", multicastGroup.c_str()); + } + if (dataPort != 0 && n > 0 && n < (int)sizeof(buf)) { + n += snprintf(buf + n, sizeof(buf) - n, + ",\"dataPort\":%u", static_cast(dataPort)); + } + if (n > 0 && n < (int)sizeof(buf)) { + snprintf(buf + n, sizeof(buf) - n, "}"); + } + return buf; +} + +std::string BuildRemoveSource(const std::string& id) { + char buf[256]; + snprintf(buf, sizeof(buf), "{\"type\":\"removeSource\",\"id\":\"%s\"}", + id.c_str()); + return buf; +} + +std::string BuildArm() { return "{\"type\":\"arm\"}"; } +std::string BuildDisarm() { return "{\"type\":\"disarm\"}"; } +std::string BuildRearm() { return "{\"type\":\"rearm\"}"; } + +std::string BuildTrigStop(bool stopped) { + char buf[64]; + snprintf(buf, sizeof(buf), "{\"type\":\"trigStop\",\"stopped\":%s}", + stopped ? "true" : "false"); + return buf; +} + +std::string BuildSaveSources() { return "{\"type\":\"saveSources\"}"; } + +std::string BuildSetTrigger(const std::string& signalKey, const std::string& edge, + double threshold, double windowSec, + double prePercent, const std::string& mode) { + char buf[512]; + snprintf(buf, sizeof(buf), + "{\"type\":\"setTrigger\"," + "\"signal\":\"%s\",\"edge\":\"%s\"," + "\"threshold\":%.9g,\"windowSec\":%.9g," + "\"prePercent\":%.9g,\"mode\":\"%s\"}", + signalKey.c_str(), edge.c_str(), + threshold, windowSec, prePercent, mode.c_str()); + return buf; +} + +std::string BuildZoom(uint32_t reqId, double t0, double t1, int n, + const std::string& signalsCsv) { + char head[256]; + snprintf(head, sizeof(head), + "{\"type\":\"zoom\",\"reqId\":%u," + "\"t0\":%.17g,\"t1\":%.17g,\"n\":%d,\"signals\":\"", + static_cast(reqId), t0, t1, n); + std::string out(head); + out += signalsCsv; + out += "\"}"; + return out; +} + +std::string BuildSetMaxPoints(uint32_t n) { + char buf[128]; + snprintf(buf, sizeof(buf), "{\"type\":\"setMaxPoints\",\"maxPoints\":%u}", + static_cast(n)); + return buf; +} + +/*---------------------------------------------------------------------------*/ +/* JSON event parsers */ +/*---------------------------------------------------------------------------*/ + +std::string ParseType(const std::string& json) { + char val[64] = ""; + jsonGetStr(json.c_str(), "type", val, sizeof(val)); + return val; +} + +/* ---- Sources ------------------------------------------------------------ */ + +bool ParseSources(const std::string& json, std::vector& out) { + out.clear(); + /* Iterate over source objects in the array */ + const char* p = json.c_str(); + while ((p = strstr(p, "\"id\":\"")) != nullptr) { + SourceInfo info; + char tmp[256] = ""; + + jsonGetStr(p - 1, "id", tmp, sizeof(tmp)); info.id = tmp; + jsonGetStr(p - 1, "label", tmp, sizeof(tmp)); info.label = tmp; + jsonGetStr(p - 1, "addr", tmp, sizeof(tmp)); info.addr = tmp; + jsonGetStr(p - 1, "state", tmp, sizeof(tmp)); info.state = tmp; + double portF = 0.0; + jsonGetDouble(p - 1, "port", portF); + info.port = static_cast(portF); + + out.push_back(info); + p++; /* advance past current match */ + } + return !out.empty(); +} + +/* ---- Config ------------------------------------------------------------ */ + +bool ParseConfig(const std::string& json, std::string& sourceId, + int& publishMode, std::vector& signals) { + signals.clear(); + char tmp[256] = ""; + jsonGetStr(json.c_str(), "sourceId", tmp, sizeof(tmp)); + sourceId = tmp; + + double pm = 0.0; + jsonGetDouble(json.c_str(), "publishMode", pm); + publishMode = static_cast(pm); + + /* Iterate over signal objects in the "signals" array */ + const char* p = json.c_str(); + while ((p = strstr(p, "\"name\":\"")) != nullptr) { + SignalMeta m; + + jsonGetStr(p - 1, "name", tmp, sizeof(tmp)); m.name = tmp; + jsonGetStr(p - 1, "unit", tmp, sizeof(tmp)); m.unit = tmp; + + int iv = 0; + jsonGetInt(p - 1, "typeCode", iv); m.typeCode = iv; + jsonGetInt(p - 1, "quantType",iv); m.quantType = iv; + jsonGetInt(p - 1, "timeMode", iv); m.timeMode = iv; + iv = -1; + jsonGetInt(p - 1, "timeSignalIdx", iv); m.timeSignalIdx = iv; + + double dv = 0.0; + jsonGetDouble(p - 1, "samplingRate", dv); m.samplingRate = static_cast(dv); + jsonGetDouble(p - 1, "rangeMin", dv); m.rangeMin = dv; + jsonGetDouble(p - 1, "rangeMax", dv); m.rangeMax = dv; + + double nr = 1.0, nc = 1.0; + jsonGetDouble(p - 1, "numRows", nr); + jsonGetDouble(p - 1, "numCols", nc); + if (nr < 1.0) { nr = 1.0; } + if (nc < 1.0) { nc = 1.0; } + m.numRows = static_cast(nr); + m.numCols = static_cast(nc); + m.numElements = m.numRows * m.numCols; + + signals.push_back(m); + p++; + } + return !sourceId.empty(); +} + +/* ---- Stats ------------------------------------------------------------- */ + +bool ParseStats(const std::string& json, + std::vector>& out) { + out.clear(); + /* Stats JSON: {"type":"stats","sources":{"id":{ ... },...}} */ + /* Parse by finding each nested object key */ + const char* sourcesBlock = strstr(json.c_str(), "\"sources\":{"); + if (!sourcesBlock) { return false; } + sourcesBlock += strlen("\"sources\":{"); + + const char* p = sourcesBlock; + while (*p && *p != '}') { + /* Skip to next key */ + while (*p && *p != '"') { p++; } + if (!*p || *p != '"') { break; } + p++; /* skip opening quote */ + + /* Read source id */ + char id[128] = ""; + size_t i = 0; + while (*p && *p != '"' && i < sizeof(id)-1) { id[i++] = *p++; } + id[i] = '\0'; + if (*p == '"') { p++; } /* skip closing quote */ + if (*p == ':') { p++; } /* skip colon */ + if (*p != '{') { break; } + + /* Find end of this source object */ + const char* objStart = p; + int depth = 0; + while (*p) { + if (*p == '{') { depth++; } + else if (*p == '}') { + depth--; + if (depth == 0) { p++; break; } + } + p++; + } + + /* Parse fields within [objStart, p) */ + std::string objStr(objStart, p - objStart); + const char* o = objStr.c_str(); + + SourceStats st; + char tmp[64] = ""; + jsonGetStr(o, "state", tmp, sizeof(tmp)); st.state = tmp; + + uint64_t u64 = 0; + jsonGetUint64(o, "totalReceived", u64); st.totalReceived = u64; + jsonGetUint64(o, "totalLost", u64); st.totalLost = u64; + + double dv = 0.0; + jsonGetDouble(o, "rateHz", dv); st.rateHz = dv; + jsonGetDouble(o, "rateStdHz", dv); st.rateStdHz = dv; + jsonGetDouble(o, "fragsPerCycle", dv); st.fragsPerCycle = dv; + jsonGetDouble(o, "bytesPerCycle", dv); st.bytesPerCycle = dv; + jsonGetDouble(o, "cycleAvgMs", dv); st.cycleAvgMs = dv; + jsonGetDouble(o, "cycleStdMs", dv); st.cycleStdMs = dv; + jsonGetDouble(o, "cycleMinMs", dv); st.cycleMinMs = dv; + jsonGetDouble(o, "cycleMaxMs", dv); st.cycleMaxMs = dv; + jsonGetDouble(o, "cycleHistMin", dv); st.cycleHistMin = dv; + jsonGetDouble(o, "cycleHistMax", dv); st.cycleHistMax = dv; + + /* cycleHist: array of 20 numbers */ + const char* hArr = strstr(o, "\"cycleHist\":["); + if (hArr) { + hArr += strlen("\"cycleHist\":["); + for (int hb = 0; hb < 20 && *hArr && *hArr != ']'; hb++) { + while (*hArr == ',' || *hArr == ' ') { hArr++; } + if (*hArr == ']') { break; } + char* end = nullptr; + st.cycleHist[hb] = strtod(hArr, &end); + if (end == hArr) { break; } + hArr = end; + } + } + + out.emplace_back(std::string(id), st); + + /* Skip comma between source entries */ + while (*p == ',' || *p == ' ') { p++; } + } + return !out.empty(); +} + +/* ---- Trigger state ---------------------------------------------------- */ + +bool ParseTriggerState(const std::string& json, TriggerStateMsg& out) { + char tmp[64] = ""; + if (!jsonGetStr(json.c_str(), "state", tmp, sizeof(tmp))) { return false; } + out.state = tmp; + + if (jsonGetStr(json.c_str(), "mode", tmp, sizeof(tmp))) { out.mode = tmp; } + + out.stopped = (strstr(json.c_str(), "\"stopped\":true") != nullptr); + + double tt = 0.0; + out.hasTrigTime = jsonGetDouble(json.c_str(), "trigTime", tt); + out.trigTime = tt; + return true; +} + +/* ---- Zoom response ---------------------------------------------------- */ + +bool ParseZoom(const std::string& json, ZoomResponse& out) { + out.signals.clear(); + out.reqId = 0; + double reqIdF = 0.0; + if (jsonGetDouble(json.c_str(), "reqId", reqIdF)) { + out.reqId = static_cast(reqIdF); + } + + /* Parse signals object: {"src:sig":{"t":[...],"v":[...]},...} */ + const char* sigsBlock = strstr(json.c_str(), "\"signals\":{"); + if (!sigsBlock) { return false; } + sigsBlock += strlen("\"signals\":{"); + + const char* p = sigsBlock; + while (*p && *p != '}') { + while (*p && *p != '"') { p++; } + if (!*p) { break; } + p++; + char sigName[128] = ""; + size_t i = 0; + while (*p && *p != '"' && i < sizeof(sigName)-1) { sigName[i++] = *p++; } + sigName[i] = '\0'; + if (*p == '"') { p++; } + if (*p == ':') { p++; } + + ZoomSignal sig; + sig.name = sigName; + + /* Parse t array */ + const char* tArr = strstr(p, "\"t\":["); + if (tArr) { + tArr += 5; + while (*tArr && *tArr != ']') { + while (*tArr == ',' || *tArr == ' ') { tArr++; } + if (*tArr == ']') { break; } + char* end = nullptr; + double val = strtod(tArr, &end); + if (end == tArr) { break; } + sig.t.push_back(val); + tArr = end; + } + } + + /* Parse v array */ + const char* vArr = strstr(p, "\"v\":["); + if (vArr) { + vArr += 5; + while (*vArr && *vArr != ']') { + while (*vArr == ',' || *vArr == ' ') { vArr++; } + if (*vArr == ']') { break; } + char* end = nullptr; + double val = strtod(vArr, &end); + if (end == vArr) { break; } + sig.v.push_back(val); + vArr = end; + } + } + + out.signals.push_back(std::move(sig)); + + /* Advance past this signal's object */ + int depth = 0; + while (*p) { + if (*p == '{') { depth++; } + else if (*p == '}') { + depth--; + if (depth == 0) { p++; break; } + } + p++; + } + while (*p == ',' || *p == ' ') { p++; } + } + return true; +} + +/* ---- maxPointsUpdated ------------------------------------------------- */ + +bool ParseMaxPointsUpdated(const std::string& json, uint32_t& maxPoints) { + double v = 0.0; + if (!jsonGetDouble(json.c_str(), "maxPoints", v)) { return false; } + maxPoints = static_cast(v); + return true; +} + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/Protocol.h b/Client/streamhub/Protocol.h new file mode 100644 index 0000000..bcd4e21 --- /dev/null +++ b/Client/streamhub/Protocol.h @@ -0,0 +1,193 @@ +/** + * @file Protocol.h + * @brief StreamHub wire protocol: binary frame parser + JSON builder/parser. + */ + +#pragma once + +#include "SignalBuffer.h" + +#include +#include +#include +#include + +namespace StreamHubClient { + +/* ── Signal metadata (mirrors UDPSSignalDescriptor fields used by UI) ─────── */ + +struct SignalMeta { + std::string name; + std::string unit; + int typeCode = 0; + int quantType = 0; + int timeMode = 0; + int timeSignalIdx = -1; + float samplingRate = 0.f; + double rangeMin = 0.0; + double rangeMax = 0.0; + uint32_t numRows = 1u; + uint32_t numCols = 1u; + uint32_t numElements = 1u; /* numRows × numCols */ +}; + +/* ── Per-source statistics (hub "stats" broadcast, Go field names) ────────── */ + +struct SourceStats { + std::string state = "disconnected"; + uint64_t totalReceived = 0; + uint64_t totalLost = 0; + double rateHz = 0.0; + double rateStdHz = 0.0; + double fragsPerCycle = 0.0; + double bytesPerCycle = 0.0; + double cycleAvgMs = 0.0; + double cycleStdMs = 0.0; + double cycleMinMs = 0.0; + double cycleMaxMs = 0.0; + double cycleHistMin = 0.0; + double cycleHistMax = 0.0; + double cycleHist[20] = {}; +}; + +/* ── Source descriptor ────────────────────────────────────────────────────── */ + +struct SourceInfo { + std::string id; + std::string label; + std::string addr; + std::string state; + uint32_t port = 0; +}; + +/* ── Decoded push-frame signal data ──────────────────────────────────────── */ + +struct FrameSignal { + std::string name; + std::vector t; + std::vector v; +}; + +struct DataFrame { + std::string sourceId; + std::vector signals; +}; + +/* ── Zoom response ────────────────────────────────────────────────────────── */ + +struct ZoomSignal { + std::string name; /* full key "src:sig" */ + std::vector t; + std::vector v; +}; + +struct ZoomResponse { + uint32_t reqId = 0; + std::vector signals; +}; + +/* ── Trigger capture (binary frame version=2) ─────────────────────────────── */ + +struct CaptureSignal { + std::string key; /* full key "src:sig" */ + std::vector t; + std::vector v; +}; + +struct CaptureFrame { + double trigTime = 0.0; + double preSec = 0.0; + double postSec = 0.0; + std::vector signals; +}; + +/* ── Trigger state event ──────────────────────────────────────────────────── */ + +struct TriggerStateMsg { + std::string state; /* idle|armed|collecting|triggered */ + std::string mode = "normal"; /* normal|single */ + bool stopped = false; + bool hasTrigTime = false; + double trigTime = 0.0; +}; + +/*---------------------------------------------------------------------------*/ +/* Binary frame parser */ +/*---------------------------------------------------------------------------*/ + +/** + * @brief Parse one StreamHub binary push frame. + * + * Format: + * [1] version | [1] idLen | [idLen] sourceId | [4] numSignals + * per signal: [2] keyLen | [key] | [4] pairCount | [N×8] t | [N×8] v + * + * @return false if the frame is malformed. + */ +bool ParseBinaryFrame(const uint8_t* buf, size_t len, DataFrame& out); + +/** + * @brief Parse a version=2 trigger-capture binary frame. + * + * Format: + * [1] version=2 | [8] trigTime | [8] preSec | [8] postSec | [4] nSig + * per signal: [2] keyLen | [key] | [4] N | [N×8] t | [N×8] v + * + * @return false if the frame is malformed or version != 2. + */ +bool ParseCaptureFrame(const uint8_t* buf, size_t len, CaptureFrame& out); + +/*---------------------------------------------------------------------------*/ +/* JSON command builders */ +/*---------------------------------------------------------------------------*/ + +std::string BuildPing(); +std::string BuildGetSources(); +std::string BuildGetConfig(const std::string& sourceId); +std::string BuildGetStats(); +std::string BuildAddSource(const std::string& label, const std::string& addr, + const std::string& multicastGroup = std::string(), + uint16_t dataPort = 0); +std::string BuildRemoveSource(const std::string& id); +std::string BuildArm(); +std::string BuildDisarm(); +std::string BuildRearm(); +std::string BuildTrigStop(bool stopped); +std::string BuildSaveSources(); +/** edge: "rising" | "falling" | "both"; mode: "normal" | "single". */ +std::string BuildSetTrigger(const std::string& signalKey, const std::string& edge, + double threshold, double windowSec, + double prePercent, const std::string& mode); +/** signalsCsv: comma-separated full keys "src:sig,src:sig2"; n<=0 → raw. */ +std::string BuildZoom(uint32_t reqId, double t0, double t1, int n, + const std::string& signalsCsv); +std::string BuildSetMaxPoints(uint32_t n); + +/*---------------------------------------------------------------------------*/ +/* JSON event parsers */ +/*---------------------------------------------------------------------------*/ + +/** @brief Extract the "type" field from a JSON text frame. */ +std::string ParseType(const std::string& json); + +/** @brief Parse a "sources" event. */ +bool ParseSources(const std::string& json, std::vector& out); + +/** @brief Parse a "config" event (one source). */ +bool ParseConfig(const std::string& json, std::string& sourceId, + int& publishMode, std::vector& signals); + +/** @brief Parse a "stats" event. */ +bool ParseStats(const std::string& json, + std::vector>& out); + +/** @brief Parse a "triggerState" event. */ +bool ParseTriggerState(const std::string& json, TriggerStateMsg& out); + +/** @brief Parse a "zoom" response. */ +bool ParseZoom(const std::string& json, ZoomResponse& out); + +/** @brief Parse a "maxPointsUpdated" event. */ +bool ParseMaxPointsUpdated(const std::string& json, uint32_t& maxPoints); + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/SignalBuffer.h b/Client/streamhub/SignalBuffer.h new file mode 100644 index 0000000..2c7d645 --- /dev/null +++ b/Client/streamhub/SignalBuffer.h @@ -0,0 +1,169 @@ +/** + * @file SignalBuffer.h + * @brief Per-signal ring buffer with LTTB decimation. + * + * Header-only. Uses STL (this is not a MARTe2 component). + */ + +#pragma once + +#include +#include +#include +#include + +namespace StreamHubClient { + +/** + * @brief Thread-safe circular buffer of (time, value) float64 pairs. + */ +struct SignalBuffer { + explicit SignalBuffer(size_t cap = 20000) + : capacity(cap), head(0), count(0) { + t.resize(cap); + v.resize(cap); + } + + void setCapacity(size_t cap) { + t.assign(cap, 0.0); + v.assign(cap, 0.0); + capacity = cap; + head = 0; + count = 0; + } + + /** Append a (time, value) pair; overwrites oldest when full. */ + void push(double time, double val) { + t[head] = time; + v[head] = val; + head = (head + 1) % capacity; + if (count < capacity) { count++; } + } + + /** + * @brief Copy the last `n` points into tOut/vOut (oldest → newest order). + * @return Number of points written. + */ + size_t readLast(size_t n, std::vector& tOut, std::vector& vOut) const { + size_t actual = std::min(n, count); + if (actual == 0) { return 0; } + tOut.resize(actual); + vOut.resize(actual); + + size_t startIdx = (head + capacity - actual) % capacity; + for (size_t i = 0; i < actual; i++) { + size_t idx = (startIdx + i) % capacity; + tOut[i] = t[idx]; + vOut[i] = v[idx]; + } + return actual; + } + + /** + * @brief Read all points in [t0, t1] into tOut/vOut. + * @return Number of points written. + */ + size_t readRange(double t0, double t1, + std::vector& tOut, std::vector& vOut) const { + tOut.clear(); + vOut.clear(); + if (count == 0) { return 0; } + + size_t startIdx = (head + capacity - count) % capacity; + for (size_t i = 0; i < count; i++) { + size_t idx = (startIdx + i) % capacity; + if (t[idx] >= t0 && t[idx] <= t1) { + tOut.push_back(t[idx]); + vOut.push_back(v[idx]); + } + } + return tOut.size(); + } + + size_t size() const { return count; } + + void clear() { head = 0; count = 0; } + + size_t capacity; + std::vector t; + std::vector v; + size_t head; + size_t count; +}; + +/*---------------------------------------------------------------------------*/ +/* LTTB — Largest Triangle Three Buckets */ +/*---------------------------------------------------------------------------*/ + +/** + * @brief Decimate tIn/vIn to at most `threshold` points using LTTB. + * + * Always preserves first and last points. + * @return Number of output points. + */ +inline size_t LTTBDecimate( + const std::vector& tIn, const std::vector& vIn, + std::vector& tOut, std::vector& vOut, + size_t threshold) +{ + const size_t nIn = tIn.size(); + if (nIn <= threshold || threshold < 2) { + tOut = tIn; + vOut = vIn; + return nIn; + } + + tOut.resize(threshold); + vOut.resize(threshold); + + tOut[0] = tIn[0]; + vOut[0] = vIn[0]; + tOut[threshold - 1] = tIn[nIn - 1]; + vOut[threshold - 1] = vIn[nIn - 1]; + + /* Bucket size (middle threshold-2 buckets cover points 1..nIn-2) */ + double bucketSize = static_cast(nIn - 2) / static_cast(threshold - 2); + + size_t a = 0; /* index of last selected point */ + for (size_t i = 0; i < threshold - 2; i++) { + /* Calculate average of next bucket */ + size_t avgRangeStart = static_cast((i + 1) * bucketSize) + 1; + size_t avgRangeEnd = static_cast((i + 2) * bucketSize) + 1; + if (avgRangeEnd >= nIn) { avgRangeEnd = nIn - 1; } + + double avgT = 0.0, avgV = 0.0; + size_t avgLen = avgRangeEnd - avgRangeStart; + for (size_t j = avgRangeStart; j < avgRangeEnd; j++) { + avgT += tIn[j]; + avgV += vIn[j]; + } + if (avgLen > 0) { + avgT /= static_cast(avgLen); + avgV /= static_cast(avgLen); + } + + /* Select point in current bucket with max triangle area */ + size_t rangeStart = static_cast(i * bucketSize) + 1; + size_t rangeEnd = static_cast((i + 1) * bucketSize) + 1; + if (rangeEnd >= nIn) { rangeEnd = nIn - 1; } + + double maxArea = -1.0; + size_t maxIdx = rangeStart; + for (size_t j = rangeStart; j < rangeEnd; j++) { + double area = std::fabs( + (tIn[a] - avgT) * (vIn[j] - vIn[a]) - + (tIn[a] - tIn[j]) * (avgV - vIn[a]) + ) * 0.5; + if (area > maxArea) { + maxArea = area; + maxIdx = j; + } + } + tOut[i + 1] = tIn[maxIdx]; + vOut[i + 1] = vIn[maxIdx]; + a = maxIdx; + } + return threshold; +} + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/SourcePanel.cpp b/Client/streamhub/SourcePanel.cpp new file mode 100644 index 0000000..9f50a20 --- /dev/null +++ b/Client/streamhub/SourcePanel.cpp @@ -0,0 +1,105 @@ +/** + * @file SourcePanel.cpp + * @brief Source browser sidebar implementation. + */ + +#include "SourcePanel.h" +#include "App.h" +#include "Protocol.h" + +#include "imgui.h" +#include "implot.h" +#include "Icons.h" + +#include +#include + +namespace StreamHubClient { + +void RenderSourcePanel(App& app) { + auto& sources = app.sources(); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 2.f)); + + if (ImGui::CollapsingHeader("Sources", ImGuiTreeNodeFlags_DefaultOpen)) { + for (int srcIdx = 0; srcIdx < static_cast(sources.size()); srcIdx++) { + Source& src = sources[srcIdx]; + + /* State color indicator */ + ImVec4 stateColor = (src.state == "connected") + ? ImVec4(0.1f, 0.9f, 0.3f, 1.f) + : ImVec4(0.8f, 0.3f, 0.3f, 1.f); + + ImGui::PushID(srcIdx); + ImGui::TextColored(stateColor, ICON_FA_CIRCLE); + ImGui::SameLine(); + + std::string treeLabel = src.label.empty() ? src.id : src.label; + bool nodeOpen = ImGui::TreeNodeEx(treeLabel.c_str(), + ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth); + + /* Per-source context menu: remove */ + if (ImGui::BeginPopupContextItem("##srccmenu")) { + if (ImGui::MenuItem(ICON_FA_TRASH_CAN " Remove source")) { + app.ws().sendText(BuildRemoveSource(src.id)); + } + ImGui::EndPopup(); + } + + if (nodeOpen) { + for (int sigIdx = 0; sigIdx < static_cast(src.signals.size()); sigIdx++) { + Signal& sig = src.signals[sigIdx]; + ImGui::PushID(sigIdx); + + /* Color swatch */ + ImVec4 col = sig.color; + if (ImGui::ColorButton("##col", col, + ImGuiColorEditFlags_NoTooltip | + ImGuiColorEditFlags_NoBorder, + ImVec2(12.f, 12.f))) { + ImGui::OpenPopup("##sigcolor"); + } + if (ImGui::BeginPopup("##sigcolor")) { + ImGui::ColorPicker4("Signal Color", reinterpret_cast(&sig.color), + ImGuiColorEditFlags_NoAlpha); + ImGui::EndPopup(); + } + ImGui::SameLine(); + + /* Unit / type label */ + char label[128]; + if (sig.meta.numElements > 1) { + snprintf(label, sizeof(label), "%s [%u]", + sig.meta.name.c_str(), sig.meta.numElements); + } else { + snprintf(label, sizeof(label), "%s", sig.meta.name.c_str()); + } + if (!sig.meta.unit.empty()) { + strncat(label, " (", sizeof(label) - strlen(label) - 1); + strncat(label, sig.meta.unit.c_str(), sizeof(label) - strlen(label) - 1); + strncat(label, ")", sizeof(label) - strlen(label) - 1); + } + + ImGui::Selectable(label, false, 0, ImVec2(0, 0)); + + /* Drag-and-drop source: payload = (srcIdx, sigIdx) */ + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + struct DragPayload { int srcIdx; int sigIdx; }; + DragPayload payload{srcIdx, sigIdx}; + ImGui::SetDragDropPayload("SIGNAL_REF", &payload, sizeof(payload)); + ImGui::TextUnformatted(sig.meta.name.c_str()); + ImGui::EndDragDropSource(); + } + + ImGui::PopID(); + } + ImGui::TreePop(); + } + ImGui::PopID(); + } + } + + ImGui::PopStyleVar(); +} + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/SourcePanel.h b/Client/streamhub/SourcePanel.h new file mode 100644 index 0000000..e84ef90 --- /dev/null +++ b/Client/streamhub/SourcePanel.h @@ -0,0 +1,17 @@ +/** + * @file SourcePanel.h + * @brief Left sidebar: source tree with draggable signal leaves. + */ + +#pragma once + +namespace StreamHubClient { +class App; + +/** + * @brief Render the source browser sidebar. + * Must be called inside an ImGui child window. + */ +void RenderSourcePanel(App& app); + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/StatsPanel.cpp b/Client/streamhub/StatsPanel.cpp new file mode 100644 index 0000000..ac6919a --- /dev/null +++ b/Client/streamhub/StatsPanel.cpp @@ -0,0 +1,93 @@ +/** + * @file StatsPanel.cpp + * @brief Statistics table implementation. + */ + +#include "StatsPanel.h" +#include "App.h" + +#include "imgui.h" +#include "implot.h" + +#include + +namespace StreamHubClient { + +void RenderStatsPanel(App& app) { + const auto& sources = app.sources(); + + ImGuiTableFlags flags = + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollX | + ImGuiTableFlags_SizingFixedFit; + + if (!ImGui::BeginTable("##statstbl", 9, flags)) { return; } + + ImGui::TableSetupScrollFreeze(1, 1); + ImGui::TableSetupColumn("Source"); + ImGui::TableSetupColumn("State"); + ImGui::TableSetupColumn("Cycles Rx"); + ImGui::TableSetupColumn("Lost"); + ImGui::TableSetupColumn("Rate Hz"); + ImGui::TableSetupColumn("Frags/cyc"); + ImGui::TableSetupColumn("Bytes/cyc"); + ImGui::TableSetupColumn("Cycle ms (avg±std)"); + ImGui::TableSetupColumn("Cycle ms (min/max)"); + ImGui::TableHeadersRow(); + + for (const auto& src : sources) { + const SourceStats& st = src.stats; + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::TextUnformatted(src.id.c_str()); + + /* State cell with color */ + ImGui::TableNextColumn(); + ImVec4 stColor = (st.state == "connected") + ? ImVec4(0.2f,0.9f,0.3f,1.f) + : ImVec4(0.9f,0.3f,0.3f,1.f); + ImGui::TextColored(stColor, "%s", st.state.c_str()); + + ImGui::TableNextColumn(); ImGui::Text("%llu", (unsigned long long)st.totalReceived); + ImGui::TableNextColumn(); ImGui::Text("%llu", (unsigned long long)st.totalLost); + ImGui::TableNextColumn(); ImGui::Text("%.2f ± %.2f", st.rateHz, st.rateStdHz); + ImGui::TableNextColumn(); ImGui::Text("%.1f", st.fragsPerCycle); + ImGui::TableNextColumn(); ImGui::Text("%.0f", st.bytesPerCycle); + ImGui::TableNextColumn(); ImGui::Text("%.3f ± %.3f", st.cycleAvgMs, st.cycleStdMs); + ImGui::TableNextColumn(); ImGui::Text("%.3f / %.3f", st.cycleMinMs, st.cycleMaxMs); + } + + ImGui::EndTable(); + + /* ── Cycle-time histograms (20 bins, mirrors web UI) ────────────── */ + for (const auto& src : sources) { + const SourceStats& st = src.stats; + if (st.cycleHistMax <= st.cycleHistMin) { continue; } + + char title[160]; + snprintf(title, sizeof(title), "Cycle time %s##hist_%s", + src.id.c_str(), src.id.c_str()); + if (ImPlot::BeginPlot(title, ImVec2(-1.f, 140.f), + ImPlotFlags_NoMouseText | ImPlotFlags_NoMenus)) { + const double binW = + (st.cycleHistMax - st.cycleHistMin) / 20.0; + double xs[20]; + for (int b = 0; b < 20; b++) { + xs[b] = st.cycleHistMin + (b + 0.5) * binW; + } + ImPlot::SetupAxes("cycle ms", "count", + ImPlotAxisFlags_AutoFit, + ImPlotAxisFlags_AutoFit); + ImPlot::PlotBars("##bars", xs, st.cycleHist, 20, binW * 0.9); + ImPlot::EndPlot(); + } + } + + ImGui::Spacing(); + if (ImGui::Button("Refresh")) { + app.ws().sendText(BuildGetStats()); + } +} + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/StatsPanel.h b/Client/streamhub/StatsPanel.h new file mode 100644 index 0000000..cf1abb6 --- /dev/null +++ b/Client/streamhub/StatsPanel.h @@ -0,0 +1,16 @@ +/** + * @file StatsPanel.h + * @brief Per-source statistics table panel. + */ + +#pragma once + +namespace StreamHubClient { +class App; + +/** + * @brief Render the statistics table (call inside an ImGui::Begin/End window). + */ +void RenderStatsPanel(App& app); + +} /* namespace StreamHubClient */ diff --git a/Client/streamhub/TriggerPanel.cpp b/Client/streamhub/TriggerPanel.cpp new file mode 100644 index 0000000..53ff3db --- /dev/null +++ b/Client/streamhub/TriggerPanel.cpp @@ -0,0 +1,184 @@ +/** + * @file TriggerPanel.cpp + * @brief Trigger configuration bar — hub-side trigger semantics. + * + * The trigger runs in the C++ StreamHub: this panel only edits the config + * (signal/edge/threshold/window/pre%/mode), sends setTrigger + arm/disarm/ + * rearm/trigStop commands, and reflects the hub triggerState broadcasts. + */ + +#include "TriggerPanel.h" +#include "App.h" +#include "Protocol.h" + +#include "imgui.h" +#include "Icons.h" + +#include +#include +#include + +namespace StreamHubClient { + +/* Window presets (mirrors the web UI: 100 µs .. 10 s) */ +static const double kWinVals[] = {1e-4, 1e-3, 1e-2, 1e-1, 1.0, 10.0}; +static const char* kWinLabels[] = {"100 µs", "1 ms", "10 ms", "100 ms", "1 s", "10 s"}; +static const int kNumWins = 6; + +static const char* kEdgeLabels[] = {ICON_FA_ARROW_TREND_UP " Rising", + ICON_FA_ARROW_TREND_DOWN " Falling", + ICON_FA_ARROWS_UP_DOWN " Both"}; +static const char* kEdgeWire[] = {"rising", "falling", "both"}; + +/* Send the current config to the hub. */ +static void sendTrigConfig(App& app) { + auto& trig = app.trigger(); + if (trig.signalKey.empty()) { return; } + app.ws().sendText(BuildSetTrigger( + trig.signalKey, kEdgeWire[trig.edge], trig.threshold, + trig.windowSec, trig.prePercent, + trig.single ? "single" : "normal")); +} + +void RenderTriggerPanel(App& app) { + auto& trig = app.trigger(); + auto& sources = app.sources(); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.15f, 0.15f, 0.20f, 1.f)); + ImGui::BeginChild("##trigbar", ImVec2(0.f, 90.f), true); + ImGui::PopStyleColor(); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(6.f, 4.f)); + + bool cfgChanged = false; + + /* ── Signal selector (full keys "src:sig") ─────────────────────────── * + * Vector signals are flattened time-series on the hub: one key per + * signal; the trigger engine checks every decoded sample of it. */ + ImGui::SetNextItemWidth(180.f); + if (ImGui::BeginCombo("Signal##trig", trig.signalKey.empty() + ? "