Files
MARTe-Integrated-Components/docs/superpowers/specs/2026-08-27-udpscope-direct-udps-imgui-scope-design.md
T
Martino FerrariandClaude Opus 4.6 52958cf8bc docs: design for UDPScope, a direct-to-streamer ImGui oscilloscope
A bench scope that attaches straight to one UDPStreamer through the
standalone C client, so it can be dropped on a machine with no StreamHub,
no Go and no browser. Records the decisions that are easy to get wrong:
the time-base rules must follow UDPSourceSession rather than the C
library's arrival-time estimate, decimation must be min/max rather than
LTTB so glitches survive, and the ring must be sized past the trigger
window by an explicit harvest margin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 11:28:54 +02:00

24 KiB
Raw Blame History

UDPScope — direct-to-streamer ImGui oscilloscope

Date: 2026-08-27 Status: design approved

1. Purpose

A bench oscilloscope that attaches straight to a single UDPStreamer and needs nothing else: no StreamHub, no Go toolchain, no browser, no web server. One binary plus the fonts it ships with, dropped on a machine that may have none of the project's other infrastructure installed.

The data path is UDPStreamer → Common/Client/c → UDPScope. Everything the Go hub and the C++ StreamHub do — ring buffering, decimation, the trigger — the scope does locally, for one source.

Success criterion: deployment simplicity. The feature bar is "what a bench scope needs", not parity with the StreamHub client.

2. Scope

In:

  • Single UDPS source, unicast or multicast, with the library's reconnect behaviour.
  • Free-grid multi-plot view built by splitting panes.
  • Normal and Single trigger modes, plus an always-on live view.
  • Cursors and window measurements.
  • Layout and settings persistence.
  • CSV export of the displayed capture.

Out:

  • Auto and Roll trigger modes.
  • X/Y (Lissajous) plotting.
  • Multiple simultaneous sources.
  • History-on-disk / zooming beyond the buffered window.

3. Architecture

Two threads and one shared object.

3.1 Receiver thread

Owns the udps_client_t and does nothing but

while (!stop) { udps_client_poll(cli, 20); }

The library creates no threads and performs all work inside poll(), so one thread owning one client is its intended use. All three callbacks fire on this thread.

  • on_config — copies the udps_signal_t table into SignalStore, resizes rings, resets the time base.
  • on_data — reconstructs per-element timestamps (§5), appends samples to the rings, and runs the trigger detector (§6) over the trigger signal's samples.
  • on_event — records connection state and the last error for the status bar.

Frame memory belongs to the library and is invalid once the callback returns, so the receiver copies on the spot. It does that anyway when it appends to a ring.

The alternative — polling inside the render loop, as udps_dump does — was rejected. SDL_GL_SwapWindow blocks on vsync, so for up to ~16 ms per frame nothing drains the socket, and a slow frame (window resize, CSV export) stretches that to hundreds of milliseconds. At 1 MSps a 200 ms stall is ~3 MB of datagrams that must survive in SO_RCVBUF or be lost, and loss shows up as silent holes in a scope trace.

A lock-free SPSC staging queue between the two threads was also considered and rejected as speculative: at 60 Hz the GUI holds the store lock for microseconds per read, so there is no measured contention to remove. Revisit only if profiling shows otherwise.

3.2 GUI thread

main.cpp's SDL2 + OpenGL3 + ImGui + ImPlot loop, same shape as Client/streamhub/main.cpp: same context setup, same Catppuccin Mocha styling, same executable-relative font resolution (installed → build tree → source tree).

Each frame it locks the store briefly, reads the windows it needs, unlocks, then decimates and draws from its own copies. No drawing happens under the lock.

3.3 Shared state

SignalStore is the entire shared surface: the signal metadata table, one SignalBuffer per signal, the measured per-signal sample rate, the trigger state, and the connection status. A single std::mutex guards all of it.

Keeping the shared surface to one named class is deliberate — there is exactly one place to look for a race. Note that SignalBuffer.h's doc comment claims the buffer is thread-safe; it is not, it has no locks. SignalStore provides all the locking.

3.4 Module layout

Client/udpscope/
  CMakeLists.txt        SDL2 + OpenGL + FetchContent(ImGui, ImPlot, GoogleTest)
  main.cpp              SDL/ImGui/ImPlot bootstrap, style, fonts, event loop
  App.{h,cpp}           owns Receiver/SignalStore/PaneTree/UI state; update()
  Receiver.{h,cpp}      the thread, the udps_client_t, the three C callbacks
  SignalStore.{h,cpp}   signal table + rings + trigger state; the one mutex
  TimeBase.{h,cpp}      producer-clock to wall-clock reconstruction
  Trigger.{h,cpp}       edge detector and FSM
  PaneTree.{h,cpp}      BSP split tree: split/close/layout/hit-test
  PaneView.cpp          ImPlot rendering of one pane
  SignalList.cpp        side panel, drag source
  TriggerBar.cpp        trigger controls and status badge
  Measure.{h,cpp}       cursor deltas and window statistics
  Decimate.{h,cpp}      min/max envelope decimation
  Settings.{h,cpp}      layout and config persistence
  Export.{h,cpp}        CSV writer
  tests/                unit tests for the framework-free modules

PaneTree, Trigger, TimeBase, Measure, Decimate, Settings and Export are framework-free C++17 with no ImGui and no UDPS dependency. Each is unit-testable without a window or a socket. That is where the fiddly logic lives and none of it should need a GUI to exercise.

3.5 Reuse

  • ../streamhub/SignalBuffer.h is used verbatim. It is header-only and framework-free, and the Qt client already reuses it this way.
  • ../streamhub/resources/ supplies the fonts and Icons.h.
  • ../../Common/Client/c/udps_client.c is compiled directly into a CMake udpsclient static target, so building the scope does not require running the C library's own Makefile first.

Nothing under Client/streamhub/ is modified.

3.6 Decimation

SignalBuffer.h ships LTTBDecimate and the scope does not use it. LTTB selects representative points and will silently drop a one-sample glitch; on a scope that glitch is usually the thing being looked for. Decimate.{h,cpp} implements a min/max envelope instead — for each screen column, emit the column's minimum and maximum in time order — which preserves extremes exactly. This is the same conclusion the Go hub reached when it replaced LTTB with minMaxDecimate.

The emitted pair must stay in time order, not value order, or downstream binary-searching by time breaks.

4. Signal model

4.1 Identity

Signals are identified by name everywhere in the UI and in persisted settings, never by index. A reconnect or a streamer reconfiguration may reorder or renumber signals; pane assignments must survive that.

On a CONFIG update:

  • Signals present before and after keep their ring and their pane assignments.
  • New signals appear in the signal list, unassigned.
  • Signals that vanished keep their pane assignment, drawn greyed and labelled missing. A transient reconnect must not destroy a layout the user built.

4.2 Array handling

A UDPS signal may be a scalar or an num_rows × num_cols array. An array is either a packed time burst (N consecutive samples, unrolled onto the time axis) or a true vector (a profile, plotted against element index for the most recent frame).

time_mode decides, and only one case is genuinely ambiguous:

time_mode elements treated as
FULL_ARRAY, FIRST_SAMPLE, LAST_SAMPLE > 1 burst, always
PACKET > 1 burst by default, user-switchable to vector
any 1 scalar

PACKET means the producer stamped the whole datagram with one time, which is what a genuine vector looks like and what a burst carrying no time metadata looks like. The default is burst, matching UDPSourceSession's handling so the scope and StreamHub agree on the same stream; the signal list offers a per-signal "profile" toggle for the case where it really is a vector. sampling_rate is not used as the discriminator — a PACKET burst frequently declares no rate, and §5 reconstructs its spacing from inter-packet timing instead.

A vector-mode signal is excluded from trigger capture and from the shared X axis. A pane holds either time signals or vector signals, not both; dropping across the two is rejected with a tooltip.

5. Time base

The C library's udps_frame_element_time() is explicitly an arrival-anchored estimate. It is not sufficient: the kernel frequently delivers several queued datagrams in one burst, so two packets are processed microseconds apart even though each represents ~10 ms of signal, and arrival-time interpolation then crams a packet's samples into that tiny gap. The trace renders as a sawtooth. Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and solves it; the scope reproduces the same rules on top of the C API, all of whose inputs are present in udps_frame_t.

Per signal, in priority order:

  1. FULL_ARRAY with a declared time signal — per-element timestamps read from the referenced time signal's array, offset onto wall clock by the calibration below.
  2. FIRST_SAMPLE / LAST_SAMPLE with a declared time signal — anchor from element 0 of the time signal, then ± k / sampling_rate per element, forwards for FIRST_SAMPLE and backwards for LAST_SAMPLE.
  3. Accumulated scalar (num_samples > 1, one element per sample) — base from the packet's embedded producer hrt, which is sampled at acquisition and so immune to the burst-delivery problem, then base + k × dt. dt is 1 / sampling_rate when declared; otherwise the hrt gap to the previous packet divided by that packet's sample count, which is exactly one cycle period because the flushes carry contiguous RT cycles; otherwise 1 ms until the first gap is known.
  4. PACKET burst with no time signal — elements span (lastPacketTime, thisPacketTime], i.e. dated backwards from arrival rather than forwards from it. The samples were acquired before the packet arrived, and spanning backwards keeps ring time strictly monotonic under jitter, where forward extrapolation would let one packet overlap the next. The first packet after connect is dropped rather than stored with wrongly spaced timestamps.
  5. Plain scalar, or no usable time reference at all — packet arrival wall time (recv_time).

Time-signal values convert with the protocol's documented units: 1e-9 s per count for UDPS_T_UINT64, 1e-6 otherwise.

Calibration. A time signal or hrt gives a producer clock, not wall clock. TimeBase maintains an offset per producer clock, established once from the first packet (offset = recv_time producerSeconds) and thereafter only corrected if the residual drifts beyond a threshold, so the trace does not jitter with network delay.

hrt tick rate. StreamHub converts hrt using the local MARTe HighResolutionTimer frequency, which is only valid when the client runs on the producer's host. The scope cannot assume that, so TimeBase estimates ticks-per-second by least-squares fit of hrt against recv_time over a rolling window of packets, yielding both rate and offset. Until the fit has enough samples (default 32 packets) rule 5 is used.

Display epoch. The X axis shows seconds relative to the first sample of the session, so it reads 0.000 … N s rather than 1.7e9. Absolute wall-clock time is shown in the readout and written to CSV exports.

6. Trigger

Runs in the receiver thread, over every sample as it is appended. An edge detector that only ran at 60 Hz would see the signal through the frame rate, quantise the trigger time to the frame, and miss edges whenever the GUI hitched.

6.1 Configuration

Field Meaning
signalName which signal is watched
edge rising / falling / both
threshold crossing level, in signal units
hysteresis signal must first leave the band threshold ± hysteresis; 0 disables
windowSec total capture span
prePercent share of the window before the trigger instant
mode normal / single

There is no holdoff setting. The capture window is its own holdoff: the FSM cannot re-arm until the current capture has been harvested.

6.2 State machine

Idle ──arm──> Armed ──edge──> Collecting ──post filled──> Held
  ^             ^                                          │
  │             └────────────── mode == normal ────────────┤
  └──────────────────── disarm ─────────────────────────────┘
  • Armed additionally requires the ring to already hold preSec of samples — otherwise the capture would come back short. Until it does, the status badge shows a fill percentage. This is a real state users see, not an internal detail.
  • Collecting ends when the trigger signal's newest sample time reaches tTrig + postSec + kHarvestMarginSec.
  • Held freezes the display on the capture. normal re-arms immediately; single waits for a manual re-arm.

6.3 Edge detection

Consecutive samples (t0,v0) → (t1,v1) cross rising when v0 < threshold and v1 >= threshold. The trigger instant is linearly interpolated: tTrig = t0 + (threshold v0) / (v1 v0) × (t1 t0), giving sub-sample resolution so the capture is stable on screen rather than jittering by one sample period.

With hysteresis > 0, a rising cross is only accepted if the signal has been below threshold hysteresis since the previous trigger; symmetrically for falling. both alternates direction.

6.4 Harvest

Once Collecting completes, the GUI thread extracts [tTrig preSec, tTrig + postSec] from every displayed burst signal's ring into a capture snapshot — a plain, non-circular copy owned by the GUI thread. Panes render the snapshot, frozen, until the next capture replaces it. The snapshot is what CSV export writes and what measurements are computed over.

kHarvestMarginSec covers late or out-of-order packets only; unlike the Go hub there is no push-tick latency, because the receiver knows precisely when samples land. Default max(0.05 s, 2 × observed frame span).

7. Buffering

One SignalBuffer per signal, sized by

capacityPoints = ceil(measuredRate × windowSec × kRingMargin)   // kRingMargin = 4

clamped to [4096, maxPointsPerSignal] (--max-mpts, default 8 Mpts ≈ 128 MB).

measuredRate is observed (samples appended ÷ elapsed), not the declared sampling_rate, which may be 0.

Why the margin is explicit. A capture is not read out when its last sample arrives but kHarvestMarginSec later. A ring holding exactly the window has already overwritten the front of its own capture by then, and every shot comes back missing its head. That is precisely the defect just fixed in Common/Client/go/wshub (captureLagSec), and the constant carries this comment so it cannot recur silently. A factor of 4 covers the window itself, the harvest margin, and rate-estimation error, with headroom for panning the live view.

Resizing is triggered by a CONFIG change, a trigger-window change, or the measured rate drifting more than 2× from the sizing assumption. SignalBuffer:: setCapacity clears the buffer, which would blank the display; SignalStore instead reads out the newest min(count, newCapacity) points and re-pushes them after the resize, so the live view survives. Resizing never happens while a capture is being harvested.

8. User interface

8.1 Screen layout

┌────────────────────────────────────────────────────────────┐
│ menu bar: File  View  Help              [connection badge] │
├────────────────────────────────────────────────────────────┤
│ trigger bar: signal | edge | thr | hyst | win | pre% |     │
│              Norm/1x | [ARMED 62%] | Arm   Disarm          │
├──────────────┬─────────────────────────────────────────────┤
│ signal list  │                                             │
│  (drag       │            pane tree                        │
│   source)    │                                             │
│              │                                             │
├──────────────┴─────────────────────────────────────────────┤
│ status: packets, MB, frames, gaps, dropped, reconnects     │
└────────────────────────────────────────────────────────────┘

The status bar reports udps_stats_t directly; counter_gaps and fragments_dropped are the honest indication that the scope is not seeing everything, and are highlighted when non-zero.

8.2 Pane tree

struct PaneNode {
    bool leaf;
    // leaf:
    std::vector<Assignment> signals;   // name, colour, line width, VScale
    // split:
    Orientation orient;                // Columns (side by side) | Rows (stacked)
    double ratio;                      // first child's share, 0..1
    std::unique_ptr<PaneNode> a, b;
};

Operations, all in PaneTree and all testable without a GUI: splitLeaf(leaf, orient), closeLeaf(leaf) (the parent is replaced by the surviving sibling), layout(rect) → [(leaf*, rect)], hitTestSplitter(pos), hitTestHandle(pos).

Splitting. Hovering a pane reveals four inset split handles at the midpoints of its edges plus an ✕ in the top-right corner. Clicking the left or right handle splits into columns; top or bottom splits into rows. The new pane is empty and takes half the space.

Handles are used rather than "click anywhere on the pane border" because a pane in the middle of a 3×3 layout touches no window edge, and a rule keyed on window edges would leave such panes unsplittable. Insetting the handles also keeps them clear of the splitter drag zone.

Resizing. The shared border between two panes is a drag target that adjusts the parent split's ratio. Minimum pane size 80 px; a drag that would go below it clamps.

Assignment. The signal list on the left is an ImGui drag source; dropping a signal on a pane appends it to that pane's list, overlaid on the existing traces. Right-clicking a trace's legend entry removes it or opens its colour, line width and vertical scale.

8.3 Axes

All time panes share one X axis: pan or zoom in any pane moves all of them, which is what makes comparing signals across panes meaningful. In live mode the X range follows the newest sample; any manual pan or zoom detaches it, and a "Live" button re-attaches.

Vertical scale is per assignment, with three modes:

  • Auto — fit to the data visible in the current X range.
  • Range — use the range_min/range_max the CONFIG packet already carries, which is free and is the physically meaningful full scale.
  • Manual — volts-per-division and offset, as on a bench scope.

8.4 Cursors and measurements

Two global vertical cursors A and B, toggled from the View menu. Readout shows tA, tB, Δt, 1/Δt, and per displayed signal the value at each cursor and ΔV.

Per pane, a collapsible readout gives each signal's min, max, peak-to-peak, mean and RMS over the current X range. All statistics are computed from the undecimated ring or capture data, never from the decimated screen points, so a narrow spike is counted even when it is not individually drawn.

9. Persistence

Settings writes a line-based recursive format, chosen over JSON because the pane tree is nested (so INI is awkward) and vendoring a JSON header for one file works against the single-binary goal. Roughly 100 lines of writer plus recursive descent parser.

udpscope 1
source host=127.0.0.1 port=44500 multicast= iface= dataport=0
trigger signal=Voltage edge=rising thr=0.5 hyst=0.01 win=0.1 pre=20 mode=normal
cursors on 0.0123 0.0456
tree
  split cols 0.5
    leaf
      sig Voltage color=#89b4fa width=1.5 vs=auto
      sig Current color=#fab387 width=1.5 vs=manual div=0.2 off=0.0
    leaf
      sig Temp color=#a6e3a1 width=1.5 vs=range

Location: $XDG_CONFIG_HOME/udpscope/session.conf, falling back to ~/.config/udpscope/session.conf, overridable with --config PATH. Saved on clean exit and from File → Save Layout. A malformed or version-mismatched file is reported in the status bar and ignored, never partially applied.

10. CSV export

File → Export writes the current capture snapshot (or, when not triggered, the visible live window) in long format:

signal,time_s,wallclock_s,value
Voltage,0.000000,1756291200.123456,0.4981
Voltage,0.000001,1756291200.123457,0.4993

Long format rather than one column per signal because signals carry independent timestamps, so a wide format would require resampling and would silently misrepresent the data. Export covers all signals assigned to any pane, or a single pane on request.

11. Command line

Mirrors example/udps_dump.c so the two are interchangeable:

udpscope [--host ADDR] [--port N] [--multicast GROUP] [--iface ADDR]
         [--data-port N] [--silence SEC] [--config PATH] [--max-mpts N]

Long -- options only. Defaults 127.0.0.1:44500, matching udps_dump.

Precedence: an explicitly given command-line option wins over the same field in the settings file; anything not given on the command line comes from the settings file; anything in neither takes the built-in default. Connecting somewhere on the command line therefore does not silently rewrite the saved session, but saving the layout afterwards does record the new source.

12. Build

Client/udpscope/CMakeLists.txt, following Client/streamhub/CMakeLists.txt:

  • find_package(OpenGL REQUIRED); SDL2 via CONFIG then a pkg-config fallback.
  • FetchContent for ImGui v1.91.8 and ImPlot v0.17, built into an imgui_lib static target compiled with -w.
  • add_library(udpsclient STATIC ../../Common/Client/c/udps_client.c) compiled as C99 with the library's own warning flags.
  • App compiled with -Wall -Wextra -Wno-unused-parameter.
  • Fonts copied next to the binary; APP_RESOURCE_DIR points at the source tree as the final fallback.
  • Install rules for the binary, fonts, .desktop entry and icon.
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build

13. Testing

UDPSCOPE_BUILD_TESTS (default ON) adds GoogleTest via FetchContent and a single udpscope_tests binary covering the framework-free modules:

  • PaneTree — split produces the expected geometry; close promotes the sibling and reclaims its space; ratios survive a round trip through layout(); hit-testing distinguishes handle from splitter; minimum size clamps.
  • Trigger — rising, falling and both edges; interpolated trigger time on a known ramp; hysteresis suppresses a noisy re-cross; the fill gate refuses to arm without a pre-window; normal re-arms and single does not.
  • TimeBase — time-signal conversion at both unit scales; offset calibration; the hrt rate fit converges to a known tick rate; the burst-delivery case produces monotonic evenly spaced timestamps rather than a sawtooth; a PACKET burst stream stays strictly monotonic under jittered arrivals, with no overlap between consecutive packets.
  • Decimate — extremes are preserved; output stays in time order; input shorter than the budget passes through unchanged.
  • Settings — write/parse round trip reproduces the tree exactly; malformed input is rejected without partial application.
  • Export — long-format output for a known snapshot.

Receiver and SignalStore are exercised indirectly: a test feeds synthetic udps_frame_t structures to the receiver's data-callback logic, which is factored out of the C callback into a plain method so it can be called without a socket.

Manual verification: run Test/Configurations/streamhub_demo.cfg's streamer and point the scope at it, alongside udps_dump for cross-checking.

14. Documentation

  • Docs/UDPScope.md — user guide: CLI, pane interaction, trigger, measurements, export, settings file format.
  • A row in the README component table and in the documentation index.
  • A line in CLAUDE.md's build section, next to the ImGui and Qt clients.