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>
24 KiB
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 theudps_signal_ttable intoSignalStore, 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.his used verbatim. It is header-only and framework-free, and the Qt client already reuses it this way.../streamhub/resources/supplies the fonts andIcons.h.../../Common/Client/c/udps_client.cis compiled directly into a CMakeudpsclientstatic 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:
FULL_ARRAYwith a declared time signal — per-element timestamps read from the referenced time signal's array, offset onto wall clock by the calibration below.FIRST_SAMPLE/LAST_SAMPLEwith a declared time signal — anchor from element 0 of the time signal, then± k / sampling_rateper element, forwards forFIRST_SAMPLEand backwards forLAST_SAMPLE.- Accumulated scalar (
num_samples > 1, one element per sample) — base from the packet's embedded producerhrt, which is sampled at acquisition and so immune to the burst-delivery problem, thenbase + k × dt.dtis1 / sampling_ratewhen declared; otherwise thehrtgap 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. PACKETburst 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.- 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
preSecof 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.
normalre-arms immediately;singlewaits 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_maxthe 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 viaCONFIGthen a pkg-config fallback.FetchContentfor ImGui v1.91.8 and ImPlot v0.17, built into animgui_libstatic 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_DIRpoints at the source tree as the final fallback. - Install rules for the binary, fonts,
.desktopentry 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
hrtrate fit converges to a known tick rate; the burst-delivery case produces monotonic evenly spaced timestamps rather than a sawtooth; aPACKETburst 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.