Initial commit

This commit is contained in:
Martino Ferrari committed 2026-10-01 15:42:48 +02:00
commit 8059b90de7
29 files changed
+7893

No files matched your search

+1
View File
@@ -0,0 +1 @@
build/
+3
View File
@@ -0,0 +1,3 @@
[submodule "deps/plotter"]
path = deps/plotter
url = https://git.ferrarinas.it/martino/plotter.git
+245
View File
@@ -0,0 +1,245 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build and run
```sh
git submodule update --init --recursive # deps/plotter is a submodule
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
./build/udpscope --server 127.0.0.1 --port 8000
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
```
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (1..4).
`deps/plotter` is built from source with only the GTK4 adapter enabled
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
vendored C99 source pair compiled into a static lib.
`deps/plotter` is upstream's own repository and this app's figure support was added
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
Changing those means committing in the submodule and moving its pin.
## Verification
There is no unit test suite. The only automated check is the app's own headless
self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against
a live UDPStreamer source:
```sh
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8000
```
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture.
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
same display and the second gets no GL context — it dumps `layout over 0 frames` and
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
also outlives its `--duration`, so kill it after each run or the leftovers pile up and
hold ports.
Two lines of the self-check output are assertions in disguise:
- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single
value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
two after the first measured frame, and the figure serves its floor out of the label
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
converges over two layouts rather than one. A `y-gutter 44..74` dragging
`axes.x 67..80`/`axes.w 546..559` with it is that settle, not a twitch — confirm it
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
the plot being re-measured every frame. **`x-desync` must read 0**: it counts frames
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
frames the x link group did not reach. `x-ticks` is content, not layout, and
legitimately varies by one as a live
window slides over a round number. The companion `axes.w changes: N (last at frame F,
side panel up=S)` line separates the two readings: `0`, or a handful near the start,
is a settled layout; a count near the frame count is the twitch. Runs far longer than
the documented probes have shown one ~320 px step — the side panel's width — that
reverts; it is intermittent and unexplained, so read `changes` before calling a range
the twitch bug.
- `[trig-probe] ... trigger=on ... display=...` (stderr, `UDPSCOPE_TRIG_TRACE_MS`) —
with `trigger=on`, `display` must read `event(frozen)` or `nothing`, **never**
`live`, except for the single interval that contains the toggle itself — recognise
it by `latches=0` *and* the next probe reading `nothing` with the same frozen
`axis=`. And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
the post-trigger share: that is the "post window is empty" defect's observable.
**`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what
the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a
series that breaks the "gather emits x relative to `x0()`" contract shows up here as
the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left
of its axis, invisible once the view is narrower than the shift, while the crosshair
keeps reporting correct values.
- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)`
(stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and
`dx_ratio` must stay within about a percent of `1.0`. `got` is the index window
`Renderer::draw_traces` gathers, recomputed here from its own `(x - x0()) / dx()`
*uniform grid* formula; `want` is the same window found by binary search over the real
time ring, which is what `x_at()`/`neighbours()` — and so the crosshair — use. A
`dx_ratio` above 1 scales every index up, `a` lands past the first visible sample, and
the samples in between sit in the buffer, readable by the crosshair, and are never
drawn: the live view's older part goes blank while its newest part looks fine. Also
read `axis=` in the companion line against the `window dropdown:` line in the dump —
a live axis narrower than the requested window is the same symptom from the other
cause (see the `clamp_to_data()` invariant below). Both need a *saturated* ring to
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
`want` and `N` must be exactly the window times the stream's real rate, plus one
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
means the edge was accepted before the history reached back a full pre window (the
gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched
early or the cut lost the newest samples. An `N` one or two short of the exact count
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
value ring's `produced()`, so publishing before each channel of the latching sample
has been pushed silently drops it.
The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
## Architecture
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
including deliberate deviations from the spec and known limitations — read it before
concluding something is a bug.
### Threads
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
`event_`, `generation_`).
### Data path
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
within the byte budget. The producer always writes it, whatever the trigger is doing —
that is what guarantees a full pre-trigger history on the *first* latch after arming.
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
immutable copy of the captured window; see README for why two live rings were dropped.
In trigger mode `RowSet::bind()` binds each trace to the snapshot and to nothing else
(the last event, or nothing before the first). **Never the live ring**, not even while
the post window is filling — SPECS:37 says the view must not refresh until the next
event, and `trigger_pending()` is informational only, for the status line's wording.
Layers to keep straight:
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
to either a `ChannelBuffer` or an `EventSeries`.
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to 4 rows, each a
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
everything the rows have to agree on: the x window (one x link group every row joins),
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
the `tick()` frame function that binds, updates the trigger state and rewrites the
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
far the largest file.
### Invariants worth knowing before editing
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
thing that forces a rebuild. Cursors are parked outside the axis range instead of
removed.
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
state. That makes one path for "a row's traces changed" and "the row count changed",
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
`pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode
would otherwise throw the frozen view onto the stream, where nothing moves it off
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
never a row: the area keeps a reference to the plot it was created with, and a rebuild
destroys every row.
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
or a new CONFIG renumbers the channel list, so an index would silently re-point a
trace at another signal.
- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an
enable/disable, or a calibration change. Calibration is applied at ingest, which is
why it re-partitions.
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
`gather()` emits x relative to `x0()` because absolute epoch seconds do not survive
`f32`.
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries`
included, and the renderer adds `x0()` back. An event's stamps are small (edge
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and
does not look like a bug — it just draws the whole capture `x0()` = one pre-trigger
window to the left of the axis. That was the "half the plot is empty / the marker sits
at the end of the data / nothing is drawn but the crosshair reads values" defect: the
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=`
is the guard.
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
the published window to the value ring's base and both rings keep sliding afterwards —
their capacities are equal, so there is no slack — which means the oldest few samples
of a saturated window routinely point at time slots the producer has already
overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable*
slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which
is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the
ratio of elapsed stream time to the retained window and clipped the older part of
every live view. The library's `StreamSeries` is immune for a reason worth copying:
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
*y* value, never the geometry. Clamping buys the same bargain here.
- **`clamp_to_data()` may not shrink the view below the requested window.** It runs
every frame and cannot distinguish "the user zoomed out past the data" from "the ring
has only just started filling", so it clamps against `max(retained, window_s_)`: a
partly filled window is left empty on its left, which is what a scope that just
started should look like. Clamping to `retained` alone turned a requested 2 s window
into the ~0.02 s the first frames held, permanently — nothing there grows a span back.
Its pan clamping is likewise gated on the data being wider than the view, or the empty
left-hand side drags the live edge off the right-hand edge.
- **Layout must not depend on content.** The y gutter is reserved at the widest label
`TickFormat::Si` can produce and only ever grows; the status label is ellipsized so
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
plotter for this), not on the rows: `Figure::layout()` clears every row's
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
rows survives exactly until the next layout.
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
signal nobody acquires can never fire, so selecting/arming enables it.
- **The trigger's event window is separate from the display window**, and
`pretrigger_pct` is a share of the *event* span, not of the ring.
- **The event window is counted in stream seconds end to end; nothing converts it to
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
search over the time ring. There used to be a measured samples-per-second estimate
in `Acquisition` doing that conversion, and it is precisely why the post window came
out empty: any stream whose time signal advances in large steps drove it to its
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
itself. **Do not reintroduce a rate estimate on this path.**
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
for a ±1 s request is correct there rather than a defect.
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
and index-aligned by construction instead of by two truncations that can disagree —
`EventSeries::gather()` indexes both at the same `i`.
- **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text.
- Every source file carries an SPDX MIT header; match it in new files.
+49
View File
@@ -0,0 +1,49 @@
cmake_minimum_required(VERSION 3.24)
project(udpscope LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()
find_package(Threads REQUIRED)
# --- deps/plotter: built from the submodule, GTK4 adapter only ----------------
set(PLOT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(PLOT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(PLOT_BUILD_GLFW OFF CACHE BOOL "" FORCE)
set(PLOT_BUILD_IMGUI OFF CACHE BOOL "" FORCE)
set(PLOT_BUILD_QT6 OFF CACHE BOOL "" FORCE)
set(PLOT_BUILD_GTK4 ON CACHE BOOL "" FORCE)
set(PLOT_INSTALL OFF CACHE BOOL "" FORCE)
add_subdirectory(deps/plotter EXCLUDE_FROM_ALL)
# --- deps/udpsclient: vendored C99 receiver ----------------------------------
add_library(udpsclient STATIC deps/udpsclient/udps_client.c)
target_include_directories(udpsclient PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/deps/udpsclient)
set_target_properties(udpsclient PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON)
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(udpsclient PRIVATE -Wall -Wextra)
endif()
# --- the application ----------------------------------------------------------
add_executable(udpscope
src/main.cpp
src/config.cpp
src/channel.cpp
src/model.cpp
src/trigger.cpp
src/acquisition.cpp
src/plot_host.cpp
src/ui.cpp
)
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
endif()
+402
View File
@@ -0,0 +1,402 @@
# udpscope
A GTK4 oscilloscope-style viewer for a UDPStreamer source: it receives one stream
with [`deps/udpsclient`](deps/udpsclient), buffers it in two identical large
circular buffers, and draws it with [`deps/plotter`](deps/plotter).
`SPECS.md` is the requirement document; this file describes what was built.
## Build and run
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
./build/udpscope --server 127.0.0.1 --port 8000 # unicast
./build/udpscope --server 10.0.0.5 --port 8000 \
--multicast 239.1.2.3 --interface 10.0.0.9 # multicast
```
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
(size of the sample buffer, default 200), `--max-window` (largest time
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
`--recv-buffer`, `--plots` (initial number of stacked plots, 1..4).
Every CONFIG is dumped to stderr once -- publish mode and, per signal, its name,
element count, rate, time mode and time-signal index -- because a signal that
refuses to plot is almost always a signal the stream described in an unexpected
way. The status line carries the same story live: `plot 1: 2 trace(s), 1400
samples`, and `time x3` when the shared clock is decimated.
Headless self-check (used by the smoke tests): `UDPSCOPE_EXIT_AFTER_MS=<ms>`
switches trigger mode on, waits for an event, then leaves trigger mode the way the
"Go live" button does, closes the window, and prints on stdout the status line in
both states, the frame and per-channel sample counts, each plot's painted-frame
count, which channel each trace slot holds, and the allocated size and reveal
state of both side panels.
## Layout
```
src/config.* command line
src/model.* the sample buffer, the partition plan, the signal/channel lists
src/channel.* ChannelBuffer (a ring pair as a plot::data::Series) and SlotSeries
src/acquisition.* the UDPStreamer client, the producer thread, frame decoding
src/trigger.* the trigger configuration and the producer-side state machine
src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
```
Threading: one producer thread owns the `udps_client_t` and calls
`udps_client_poll()`; the GTK main thread owns every widget and the GL contexts.
They share `ScopeModel` under one mutex, which the producer takes once per DATA
frame and the GUI thread takes once per frame around binding the traces.
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
single consumer), never through the lock.
## How the spec maps onto the code
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value
ring per channel (N enabled signals -- see the limitation on multi-element signals
below). The spec's second buffer is the latched `EventSnapshot`, which is already
an immutable copy of the captured window (see "Trigger" below); a second live ring
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
200 MB.
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
them with the rest of the budget. When a channel's declared sample rate is lower
than the fastest channel's, it is decimated by the integer ratio
(`Partition::stride`); when even the target window does not fit the budget, the
shared clock is decimated too (`Partition::time_stride`). Because the split is by
*time span* rather than by bytes, every channel and the time base retain the same
history -- which is what makes one shared time slice meaningful. With no
decimation the two rules coincide and every slice is the same size.
The ring is always filling, whatever the trigger is doing. The pre-trigger history
has to already be there when an edge arrives; while acquisition followed the
trigger state, the first latch after arming opened on however little had
accumulated since.
**Circular, time from the streamed data.** Each ring overwrites its oldest
sample. Timestamps come from the stream's time signal when the CONFIG names one
(`UDPS_TIME_FULL_ARRAY` / `FIRST_SAMPLE` / `LAST_SAMPLE`), and otherwise from an
arrival-anchored estimate that accounts for `udps_frame_t::lost` by dividing the
elapsed wall time over the cycles the gap covers. `recv_time` is used only as the
fallback when the stream provides no usable time signal (it is then the sample's x,
via the arrival estimate); it is never used when the stream's own time signal
carries a span.
**Trigger: what freezes is the view, not acquisition.** `TriggerEngine` runs the
edge/hold-off/normal-single state machine on the selected channel, and once the
requested post-trigger share of the *event span* (the event-window control, not the
whole ring) has been collected, `ScopeModel::publish_event()` copies that window out
of the live ring into an immutable `EventSnapshot` and publishes it atomically.
Pre-trigger history is what the ring already holds, so the percentage is a share of
the event window and not of the buffer.
In trigger mode a trace is bound to the snapshot and to nothing else: the last
event latched, or nothing at all before the first one. Never the live ring, not
even while the post window is filling — a picture that changes only on an event is
the whole point of trigger mode (SPECS: "data visualisation should not be refreshed
till next event"), and binding the live ring mid-capture made the plot scroll for
most of every event cycle.
**The whole trigger path measures the window in stream seconds, and never in
samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock
has advanced past the post window; `publish_event()` then finds the slice covering
`[edge - pre, edge + post]` by binary search over the time ring. The two are the same
clock the x axis reads, so a capture covers the span that was asked for by
construction.
It did not used to. The engine took a post-trigger window in *samples*, converted
from seconds by a measured estimate of the trigger channel's cadence, and that
estimate is what made captures wrong — most visibly as the reported defect *"only the
pre window is filled, the post window is completely empty"*. Any stream whose time
signal advances in large steps (one sample per stream second, say) drove the estimate
to its 1 S/s floor; the sample count then came out as 1, which the engine read as "no
post window requested" and latched on the edge sample itself. The event held a single
sample, its span was zero, and the UI fell back to pinning a round `[-1, +1]` axis
that nothing filled past x = 0 — an axis that looked right, which is why the defect
read as a drawing bug rather than a capture bug. Removing the conversion removes the
whole failure class: there is no longer a rate estimate to be wrong.
An edge is refused until the buffer reaches back a full pre-trigger window behind it,
so a capture cannot open on a half-filled pre window. The gate compares the oldest
buffered stamp against the edge rather than timing how long the trigger has been
armed: the ring fills whether or not the trigger is on, so the history is normally
already there and waiting would delay the first event for nothing. It only bites just
after a rebuild — a CONFIG, enabling the signal, or a calibration change all drop the
history — which is exactly when the first event used to latch on an almost empty pre
window and draw half a plot.
The event is published on the sample that latched it, but only after every channel of
that sample has reached its ring: the cut is clamped to the shortest value ring, so
publishing from the middle of the channel loop dropped the newest sample — the very one
the post window had been waiting for. That cost one sample of twenty thousand on a
10 kS/s stream, and the whole post side on a one-sample-per-second one.
The axis is still pinned to what the snapshot actually captured, read off its own
stamps rather than off the request: the two can differ by a sample at each end, and
pinning the request would claim coverage the data does not have. The window's bounds
are rounded to the time ring's own `f32` before being compared against it, so a sample
sitting exactly on a bound is kept whatever the stream's absolute time happens to be —
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and
a third of the capture at 1 S/s.
**Where the capture is drawn was a separate bug from what it contained**, and it
outlived the fixes above. The plotting library's `gather()` emits x *relative to*
`x0()` and the renderer adds `x0()` back; the event series emitted absolute stamps, so
the renderer added `x0()` twice and drew the whole capture one pre-trigger window to the
left of the axis it had just been pinned to. Nothing about the numbers looked wrong —
the snapshot held the right samples over the right span, and the crosshair (which reads
`x_at()`, never `gather()`) reported correct values — but with a 50/50 split the right
half of the plot was empty, zooming out showed the trace ending at the x = 0 marker, and
zooming in past the shift showed nothing at all. The self-check now prints `data=` and
`drawn=` side by side for the bound trace so the contract is checked rather than
assumed.
**The live view had two clipping bugs of its own**, both wearing the same face as that
one: the older part of the trace blank, the newest part fine, and the crosshair still
reading values in the blank part. Neither is a data bug — the samples were always there.
The first is the requested time window being quietly redefined. `clamp_to_data()` stops a
zoom-out from running off the end of the ring, but it runs every frame and cannot tell
that apart from a ring that has only just started filling: the first frames after a
signal is enabled hold a couple of hundred samples, a 2 s window was clamped to the
~0.02 s they covered, and nothing there ever grows a span back. The view then stayed
pinned to the newest 20 ms of a 6.5 s buffer for the rest of the session. It now clamps
against the larger of the retained span and the requested window, so a partly filled
window simply has an empty left-hand side.
The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value,
stamp) pair split across two rings of equal capacity, and the published window is pinned
by `acquire()` while both rings keep sliding under it — so the oldest samples of the
window point at time slots the producer has since overwritten. Those used to read back as
`0`, a perfectly plausible coordinate, which landed in `x_bounds()` and from there in
`dx()` and `x0()`. That is the renderer's entire index mapping: it converts the visible x
range into indices with `(x - x0()) / dx()`, so *one* unreadable slot inflated `dx()` by
the ratio of elapsed stream time to the retained window, scaled every index up with it,
and the gather began well inside the visible range. The stamp is now clamped to the oldest
readable slot, which costs at most a short plateau of equal stamps at the very left edge
— the same bargain the library's own `StreamSeries` documents, where an overwritten slot
costs a y value and never the geometry. The self-check prints `dx_ratio` and the
renderer's index window beside the binary-searched one, so both are checked rather than
assumed.
**The panels and the toolbar.** The header bar carries only what you reach for
*while watching a signal*: the time window, the plot layout (1..4 rows),
pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those
four buttons are icons with tooltips; nine text buttons was a wall of words to read
past every time you wanted the one you needed. The overflow menu holds **Fit to
data**, **Cursors**, **Reset cursors** and the **Signal list** toggle — the things
you set up once. The side column and the two panel toggles stay hidden until a
CONFIG arrives — there is nothing to configure before the stream describes itself —
and the first CONFIG reveals the signal panel (the trigger panel stays behind its
own toggle).
The trigger panel selects the **signal** (every signal the CONFIG described, not
only the acquired ones, so the list is never empty while a stream is known), the
edge, level, pre-trigger percentage, hold-off and mode, and arms the trigger.
**The event window is its own control**, separate from the display window: it is
how much of the stream one captured event covers, and the pre-trigger percentage
is a share of *that*. Tying it to the display window was a mistake — with the
600 s default a latch took 300 s (half of it) to fill, so the view sat frozen for
minutes and the trigger looked dead. With the default 50 ms event window a latch
completes 25 ms after the edge. When an event latches and the plot is zoomed out
wider than the event window, the display zooms to the event so the captured
waveform fills the plot; the window dropdown follows.
**Hold-off is the quiet time after an event has been captured** — measured from
the end of the capture, not from the edge, because the capture itself already
blocks re-arming.
Choosing a signal, switching trigger mode on, or arming the trigger all enable
that signal's acquisition — a trigger on a signal nobody acquires could never
fire.
**Trigger mode is the switch between streaming and triggering**, and the status
line always names the state it is in:
| status line | meaning |
|---|---|
| `[live]` | trigger mode off: the view follows the stream continuously |
| `[trigger: armed, waiting for an event]` | watching for an edge; the view shows the previous state |
| `[trigger: EVENT HELD …]` | an event was latched; the view is frozen on it |
| `[trigger: disarmed (view frozen)]` | armed and waiting are not the same thing — nothing will arrive until it is armed again |
While an event is on screen a **red vertical line marks the trigger instant** on
every row, at the edge's own stream time — the latch may complete a post-window
later, but the line marks the edge, so the pre/post split can be read straight off
the plot. It is not one of the user's cursors: it has its own colour, ignores the
cursor show/hide toggle, and is parked (invisible) when no event is held.
Switching the header bar's **trigger** button off returns to `[live]` and resumes
drawing the stream; the trigger panel carries the same action as a
**Go live (trigger off)** button, so the way out is inside the panel that froze
the view. Switching trigger mode on arms it immediately (arming is what "wait for
an event" means), and in single-shot mode the trigger disarms itself after a
latch, which the status line reports.
The signal panel is one line per signal: a colour chip naming the trace it is drawn
as, a checkbox that acquires it, its name, its unit, and a `⋯` menu holding the
information popup and the gain/offset calibration popup. A `GtkSwitch` and two
always-visible buttons made a row about twice as tall (46 px against 26 px now),
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
button and list-row minimums, not the widgets themselves, set that height, so the
row carries a `.udpscope-compact` style class that relaxes them.
**Signals start disabled**, because enabling one is what starts acquiring it.
Enable a signal with its checkbox, or drag it onto a plot: the drop enables the
signal if needed, binds it to that row's first free trace slot, and rebuilds the
figure so the legend name is right (there is one widget, so the row a drop lands
on is `PlotArea::row_at()`). The status line reports the outcome of a drop
(`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action
that changes nothing still says why. Each plot's y axis has a right-click menu
with auto scale, a fixed range, and one **Remove '<signal>' from plot N** entry per
occupied trace slot (a plot with nothing on it says so instead).
**The plot does not twitch.** Two independent causes were fixed, both of which
showed up as the x axis moving a few pixels every frame — including while paused,
which is how the second one was found:
1. *The gutter followed the y tick labels.* `TickFormat::Si` changes their width
as the auto-fitted range moves (`1.5` → `15` → `150` → `1.5k`), and
`set_min_y_gutter()` is only a floor — the library still takes the larger of
measurement and floor. `RowSet` therefore reserves the width of the widest
label the formatter can produce, measured once with the plot's own monospaced
font, and hands it to the *figure* (`Figure::set_min_y_gutter()`, added for
this): `Figure::layout()` clears every row's own floor each frame to measure
what its labels need, so a floor written onto the rows would survive exactly
until the next layout. The figure seeds its common gutter — the maximum over
the rows, which is also what aligns their axes — with that reservation.
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
text, and the status line is rewritten every frame; as its counters and
messages changed width they moved the paned divider, which resized the plot,
which re-laid out the axes and their ticks. The label is now ellipsized, so its
minimum is “…”; the plot keeps the width it was given.
The self-check reports both. `viewport.w` must collapse to a single value, and
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
lands a few frames after the first measured frame, and because the figure serves
its floor out of the label strip the reservation converges over two layouts, so
their ranges show that settle and nothing after it. The companion `axes.w
changes: N (last at frame F)` line is what separates a settle from the twitch — a
handful of changes near the start is settled, a count near the frame count is the
bug. `x-ticks` is content, and varies by one as a live window slides over a round
number:
```
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627,
y-gutter 44..74, x-ticks 20..21, x-desync 0
axes.w changes: 2 (last at frame 35, side panel up=1)
```
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
headless run, so the numbers are measured on a still picture.
The app also logs, once per second, one line per acquired channel with the number
of samples stored and the rate that implies, next to the rate the CONFIG declared:
```
[udpscope] ch 1 'ch2_2kHz' stored=8500 (+4603 kS/s, declared 0 S/s) stride=1 clock=1 y=[-0.98, 0.98]
```
The app no longer decimates (`stride=1 clock=1`), so a stored rate far below the
declared one is the datasource's own wire rate, not something the viewer did.
**Cursors start hidden** and are switched on from the overflow menu; **Reset cursors**
switches them on as well, because a reset nobody can see reads as a dead button.
The two panels collapse vertically (`SLIDE_DOWN`), so hiding one gives its space
back instead of leaving an invisible gap in the column.
The window dropdown offers the standard ladder up to `--max-window`, capped to
what one buffer actually holds (the cap is recomputed when a CONFIG changes the
partition, and never on a mere enable or calibration change). A span that is not
on the ladder — `Fit` frames whatever the ring holds, which is rarely a round
number — gets its own entry rather than being rounded to a neighbour, so the
dropdown reads what is on screen instead of approximating it.
**Fit to data** (overflow menu) does what the plot's own `f` key does: auto-scales every row's y
axis and re-frames the x window on the data the ring holds, propagated to every
row. The status line acknowledges it (`fitted to the buffered data (1.25 s)`).
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
A gesture changes the row it landed on; `Figure::layout()` adopts that change
into the group and pushes it to the others, so panning or zooming any row moves
them all with nothing to propagate by hand. The live follow is the *group's*
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
own axis still follows, because a following axis would reinterpret the pushed
range as its own span and offset behind its own edge, which is per-row drift by
construction. The figure also owns the left gutter and the crosshair, so a
vertical feature lands in the same pixel column in every row.
The self-check's layout line carries `x-desync N`, the number of frames in which
any row's x range differed from row 0's by more than a pixel of data. **It must
read 0.**
`Figure`'s row count is fixed at construction, so there is one structural path:
a changed row count, or a changed legend name (the one thing a `Plot` cannot
express), builds a new figure and the area is handed it. That costs no GL state —
the widget keeps its context. A displayed event survives the rebuild because the
pinned `[-pre, +post]` window is restated rather than re-derived from the live
edge.
## Review findings not (yet) implemented
These are known deviations, found by a spec/code review, that are deliberate for
now — they are listed so they are not mistaken for anything else:
- **There is one live buffer, not two.** The spec's second buffer is served by the
latched `EventSnapshot` — an immutable copy of the captured window, which is
exactly what "the buffer the view is frozen on" needs to be. Two live rings would
also mean acquisition had to follow the trigger state, which is what left the first
latch after arming with no pre-trigger history.
- **The buffer is allocated at the first CONFIG, not at initialisation.**
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
*budget*, and the power-of-two ring rounding means the final
allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG,
enable/disable and calibration re-partitions (and drops the history) because the
buffer layout *is* the channel list.
- **Channels are not decimated at ingest.** The spec's `window * rate > buffer`
rule was implemented as `stride = round(rate_fastest / rate_channel)`, which for
a 2 kHz sine on a 1 MHz clock is exactly one sample per period: every kept
sample sat at the same phase and the channel plotted as a flat line. Channels
are now stored at the shared clock's rate and the LOD min/max pyramid preserves
the extremes at draw time. When the requested window does not fit the budget the
*shared clock* is decimated instead (the status line's `time xN`).
- **Pre-trigger % is a share of the *event* window**, not of the retained history —
see the trigger section above for why (the spec's sentence predates the event
window being a separate control).
- **A plot holds at most four signals** (`RowSet::kSlotsPerRow`), because a `Plot`
cannot give up a trace and the slots are therefore created up front.
- **One toggle drives both cursor orientations.** The vertical markers and the
horizontal ruler exist separately but share one show/hide and one reset.
- **The time reference is the first *enabled* channel's signal.** A CONFIG whose
time signal belongs to a signal that is disabled contributes no stream time, and
the app falls back to arrival-based timing (with a warning on stderr).
## Limitations
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
`udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per
DATA packet for such a signal, regardless of the publish mode, so it cannot be
decimated on its declared rate. Each element becomes one channel sampled on the
shared clock's own cadence with the packet's value held until the next packet —
exactly faithful in `strict` mode, and the only reading of the wire that does
not invent samples in `accumulate` mode. The info popup and the CONFIG dump on
stderr report the element counts.
- **The event window can be shorter than the buffer.** The trigger latches a window
of the event-span selection; the ring holds far more, so with trigger mode off the
rest is scrollable history.
- **Calibration is applied at ingest**, so changing gain or offset rebuilds the
buffers (and drops the history). Applying it at read time would need the LOD
pyramid to be gain-aware.
- **Cursors are per row** and not synchronised with each other; show/hide parks
them outside the axis range rather than removing them, because `Plot` has no
`remove_cursor`. The trigger marker is the one exception: it has its own cursor
and is independent of the cursor toggle.
- **Verified headless only.** Acquisition, partitioning, the trigger state
machine and its integration with the buffers, and rendering under Xvfb
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
the right-click menu) were not driven by an automated test.
+38
View File
@@ -0,0 +1,38 @@
- Initialize 2 identical large buffer at initialization (default 200MB each, configurable as cli argument) to use as sample buffer for the rest of the application
- Uses `deps/plotter` for plotting
- Uses `deps/udpsclient/udps_client.h` to connect to one stream source
- the application should be composed of:
1. main toolbar that contain:
- time window selection
- plot layout selection
- pause/resume button
- enable/disable trigger mode
- show/hide and reset cursors [horizontal and or vertical]
2. when in trigger mode a collapsable right panel to configure trigger logic:
- select signal to use as trigger
- select edge detection type (rising, falling, both)
- select trigger level
- select pre trigger window size (in %)
- select trigger hold off time (default 10ms)
- select trigger mode [normal], [single]
- arm/disarm trigger button
3. a collapsable signal panel:
- list with all available signals
- enable/disable signal to acquire
- calibrate signal: popup to select offset and gain for signal
- drag and drop signal on plot to start plotting it
- button to show signal info in a popup (unit, range, rate, type, quantisation etc)
4. main view with plot:
- multiples plots depending from selected layout
- each plot have it x-axis sync
- each plot can have y-axis with [right click menu]:
1. auto scale (default)
2. fixed range (user definable)
- each plot can show one or more signals
- signal acqusition:
- the memory buffer should be split in N+1 (where N is the number of enabled signals) and +1 is for the shared time base
- only if the time window \* signal sample rate is greater than size of signal buffer decimate input signal
- the buffer should be used as circular buffer
- the time should be computed from the time associated with the signal (not from the client but from the streamed data)
- in trigger mode data visualisation should not be refreshed till next event, that's why 2 identical buffer, one is the currently
viewed event, the other used to acquire live data for next event
Vendored Submodule
+1
Submodule deps/plotter added at 98cfc5805b.
+46
View File
@@ -0,0 +1,46 @@
# UDPS C client library — standalone, no MARTe2, no external dependencies.
#
# make build libudpsclient.a and the example
# make example build only the example
# make cxxcheck verify the header is usable from C++
# make clean
CC ?= cc
CXX ?= c++
AR ?= ar
CFLAGS ?= -O2 -g
WARN = -Wall -Wextra -Wpedantic
STD = -std=c99
CPPFLAGS += -I.
# Old glibc (< 2.17) keeps clock_gettime in librt; harmless to add there.
LDLIBS ?=
LIB = libudpsclient.a
OBJ = udps_client.o
EXAMPLE = udps_dump
.PHONY: all example cxxcheck clean
all: $(LIB) $(EXAMPLE)
$(LIB): $(OBJ)
$(AR) rcs $@ $^
udps_client.o: udps_client.c udps_client.h
$(CC) $(STD) $(WARN) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
example: $(EXAMPLE)
$(EXAMPLE): example/udps_dump.c $(LIB)
$(CC) $(STD) $(WARN) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(LIB) $(LDLIBS)
# The header is C++-safe; this target keeps it that way.
cxxcheck: udps_client.h
echo '#include "udps_client.h"' > .cxxcheck.cpp
echo 'int main() { udps_client_config_t c; udps_client_config_init(&c); return 0; }' >> .cxxcheck.cpp
$(CXX) -std=c++11 -Wall -Wextra $(CPPFLAGS) -o .cxxcheck .cxxcheck.cpp $(LIB) $(LDLIBS)
./.cxxcheck && rm -f .cxxcheck .cxxcheck.cpp
clean:
rm -f $(LIB) $(OBJ) $(EXAMPLE) .cxxcheck .cxxcheck.cpp
+1230
View File
File diff suppressed because it is too large. Load diff
+340
View File
@@ -0,0 +1,340 @@
#ifndef UDPS_CLIENT_H
#define UDPS_CLIENT_H
/**
* @file udps_client.h
* @brief Standalone UDPS (UDPStreamer) receiver library — C99, no MARTe2.
*
* Depends only on libc and BSD sockets, so it can be dropped into any C or C++
* program that needs to consume a UDPStreamer / DebugService stream. The wire
* format is specified in Docs/Protocol.md; the library reference (and a worked
* example) is Docs/UDPS-C-Client.md.
*
* Usage in one paragraph: fill a udps_client_config_t, create a client, install
* callbacks, then call udps_client_poll() in a loop. The client owns the
* connection state machine — it sends CONNECT, reassembles fragmented packets,
* decodes CONFIG and DATA, sends keepalives, and reconnects when the server
* goes silent. Nothing is done behind your back: no threads are created and
* every callback runs inside your call to udps_client_poll().
*
* Threading: a udps_client_t must be used from one thread at a time.
*/
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*---------------------------------------------------------------------------*/
/* Protocol constants */
/*---------------------------------------------------------------------------*/
/** Magic number: ASCII 'UDPS' stored little-endian. */
#define UDPS_MAGIC 0x53504455u
/** Size of the packed packet header on the wire. */
#define UDPS_HEADER_SIZE 17u
/** Size of one serialised signal descriptor in a CONFIG payload. */
#define UDPS_SIGNAL_DESC_SIZE 136u
/** Value of udps_signal_t::time_signal_idx when the signal has no time reference. */
#define UDPS_NO_TIME_SIGNAL 0xFFFFFFFFu
/** Upper bound on elements per signal; larger descriptors are rejected. */
#define UDPS_MAX_ELEMENTS (1u << 20)
/** Packet types (udps_header_t::type). */
enum {
UDPS_PKT_DATA = 0, /**< Server -> client: signal samples. */
UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */
UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */
UDPS_PKT_CONNECT = 3, /**< Client -> server: open a session. */
UDPS_PKT_DISCONNECT = 4 /**< Either direction: close a session. */
};
/** Sample type codes (udps_signal_t::type_code). */
enum {
UDPS_T_UINT8 = 0,
UDPS_T_INT8 = 1,
UDPS_T_UINT16 = 2,
UDPS_T_INT16 = 3,
UDPS_T_UINT32 = 4,
UDPS_T_INT32 = 5,
UDPS_T_UINT64 = 6,
UDPS_T_INT64 = 7,
UDPS_T_FLOAT32 = 8,
UDPS_T_FLOAT64 = 9,
UDPS_T_UNKNOWN = 255
};
/** Quantisation codes (udps_signal_t::quant_type). */
enum {
UDPS_QUANT_NONE = 0, /**< Raw values in the signal's own type. */
UDPS_QUANT_UINT8 = 1, /**< [range_min, range_max] mapped onto uint8. */
UDPS_QUANT_INT8 = 2,
UDPS_QUANT_UINT16 = 3,
UDPS_QUANT_INT16 = 4
};
/** Time-reference modes (udps_signal_t::time_mode). */
enum {
UDPS_TIME_PACKET = 0, /**< No per-element time; use packet arrival. */
UDPS_TIME_FULL_ARRAY = 1, /**< The time signal carries one stamp per element. */
UDPS_TIME_FIRST_SAMPLE = 2, /**< Time signal (scalar) stamps element 0. */
UDPS_TIME_LAST_SAMPLE = 3 /**< Time signal (scalar) stamps element N-1. */
};
/** Publishing modes (udps_frame_t::publish_mode). */
enum {
UDPS_PUBLISH_STRICT = 0, /**< One packet per RT cycle. */
UDPS_PUBLISH_ACCUMULATE = 1, /**< A batch of cycles per packet. */
UDPS_PUBLISH_DECIMATE = 2 /**< One packet every N cycles. */
};
/*---------------------------------------------------------------------------*/
/* Data model */
/*---------------------------------------------------------------------------*/
/** Decoded 17-byte packet header. */
typedef struct {
uint32_t magic;
uint8_t type;
uint32_t counter; /**< Same for every fragment of one update. */
uint16_t fragment_idx;
uint16_t total_fragments; /**< 1 when the update fits in one datagram. */
uint32_t payload_bytes;
} udps_header_t;
/** Metadata for one streamed signal, as carried by the CONFIG payload. */
typedef struct {
char name[65]; /**< NUL-terminated. */
uint8_t type_code; /**< UDPS_T_*. */
uint8_t quant_type; /**< UDPS_QUANT_*. */
uint8_t num_dimensions; /**< 0 scalar, 1 vector, 2 matrix. */
uint32_t num_rows;
uint32_t num_cols;
double range_min; /**< Physical range, used to dequantise. */
double range_max;
uint8_t time_mode; /**< UDPS_TIME_*. */
double sampling_rate; /**< Hz; 0 when unknown. */
uint32_t time_signal_idx;/**< Index into the signal list, or UDPS_NO_TIME_SIGNAL. */
char unit[33]; /**< NUL-terminated. */
} udps_signal_t;
/**
* @brief Decoded values of one signal within a frame.
*
* Values are always physical doubles: quantised signals are already expanded
* back onto [range_min, range_max]. @c count is @c num_samples for a scalar
* signal in Accumulate mode (one value per batched cycle) and the signal's
* element count in every other case.
*/
typedef struct {
const double *values;
uint32_t count;
} udps_signal_values_t;
/** One fully decoded DATA packet. */
typedef struct {
uint32_t counter; /**< Packet counter; gaps mean lost datagrams. */
/**
* DATA packets missing immediately before this one, from the counter.
*
* Needed to space samples correctly: the elapsed time since the previous
* frame covers the lost packets' cycles too, so dividing it by this
* frame's sample count alone gives a period too long by exactly
* @c lost + 1, which walks the samples past their own end and into the
* range the next frame claims.
*/
uint32_t lost;
uint64_t hrt; /**< Producer's high-resolution timer at send. */
double recv_time; /**< Wall-clock seconds (CLOCK_REALTIME) at arrival. */
uint8_t publish_mode; /**< UDPS_PUBLISH_*. */
uint32_t num_samples; /**< Batched cycles; 1 unless Accumulate. */
uint32_t num_signals;
const udps_signal_t *signals; /**< num_signals entries, CONFIG order. */
const udps_signal_values_t *values; /**< num_signals entries, same order. */
} udps_frame_t;
/** Connection lifecycle events reported through udps_event_cb. */
typedef enum {
UDPS_EVENT_CONNECTED, /**< Sockets are up and CONNECT was sent. */
UDPS_EVENT_DISCONNECTED, /**< Session dropped; the client will retry. */
UDPS_EVENT_ERROR /**< Recoverable problem; detail says what. */
} udps_event_t;
/** Cumulative counters, never reset. */
typedef struct {
uint64_t packets_received; /**< Datagrams (and TCP frames) accepted. */
uint64_t bytes_received;
uint64_t frames_delivered; /**< DATA packets decoded and handed to you. */
uint64_t config_updates;
uint64_t fragments_dropped; /**< Duplicate, stale or unplaceable fragments. */
uint64_t counter_gaps; /**< DATA packets missing from the sequence. */
/**
* DATA packets dropped for not advancing the counter: reordered or
* duplicated on the wire. Delivering one would stamp its values with a
* time base older than data already handed over.
*/
uint64_t stale_packets;
uint64_t reconnects;
} udps_stats_t;
/*---------------------------------------------------------------------------*/
/* Client */
/*---------------------------------------------------------------------------*/
typedef struct udps_client udps_client_t;
/** Called whenever a CONFIG packet redefines the signal set. */
typedef void (*udps_config_cb)(const udps_signal_t *signals,
uint32_t num_signals,
uint8_t publish_mode,
void *user);
/**
* @brief Called for every decoded DATA packet.
*
* The frame and everything it points at are owned by the client and are only
* valid until the callback returns — copy anything you need to keep.
*/
typedef void (*udps_data_cb)(const udps_frame_t *frame, void *user);
/** Called on connection state changes and on recoverable errors. */
typedef void (*udps_event_cb)(udps_event_t event, const char *detail, void *user);
/**
* @brief Transport configuration.
*
* Zero-initialise with udps_client_config_init(), then override what you need.
* Set @c multicast_group to switch from unicast to multicast: in unicast the
* client sends CONNECT over UDP and receives everything on its ephemeral port;
* in multicast it joins the group for DATA and opens a TCP control connection
* to @c server_port for CONNECT and CONFIG.
*/
typedef struct {
const char *server_addr; /**< IPv4 dotted quad. Required. */
uint16_t server_port; /**< UDP port (unicast) or TCP port (multicast). Required. */
const char *multicast_group;/**< IPv4 group; NULL selects unicast. */
const char *interface_addr; /**< Local IPv4 of the interface to join on. NULL = default route. */
uint16_t data_port; /**< Multicast data port; 0 means server_port + 1. */
double silence_timeout_s; /**< Reconnect after this long without data. 0 disables. */
double reconnect_delay_s; /**< Wait between reconnect attempts. */
double keepalive_interval_s;/**< Unicast ACK period. 0 disables. */
uint32_t recv_buffer_bytes; /**< SO_RCVBUF; large bursts need a large value. */
uint32_t max_packet_bytes; /**< Ceiling on one reassembled payload. */
} udps_client_config_t;
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
void udps_client_config_init(udps_client_config_t *cfg);
/**
* @brief Creates a client. No socket is opened until the first poll.
* @return NULL if @p cfg is invalid or memory ran out.
*/
udps_client_t *udps_client_create(const udps_client_config_t *cfg);
/** Closes the session (sending DISCONNECT if connected) and frees the client. */
void udps_client_destroy(udps_client_t *client);
/** Installs the callbacks. Any of them may be NULL. */
void udps_client_set_callbacks(udps_client_t *client,
udps_config_cb on_config,
udps_data_cb on_data,
udps_event_cb on_event,
void *user);
/**
* @brief Drives the client: connects if needed, then waits for and processes
* packets for at most @p timeout_ms milliseconds.
*
* Callbacks fire from inside this call. A negative @p timeout_ms blocks until
* something happens. Call it in a loop; it is the only function that does work.
*
* @return the number of packets processed (0 on timeout), or -1 if the session
* broke. -1 is not fatal: the next call retries after reconnect_delay_s.
*/
int udps_client_poll(udps_client_t *client, int timeout_ms);
/** Non-zero once the sockets are up (which does not yet imply CONFIG arrived). */
int udps_client_is_connected(const udps_client_t *client);
/**
* @brief The current signal set, or NULL before the first CONFIG.
* @param num_signals Out; may be NULL.
*/
const udps_signal_t *udps_client_signals(const udps_client_t *client,
uint32_t *num_signals);
/** The publishing mode from the last CONFIG (UDPS_PUBLISH_*). */
uint8_t udps_client_publish_mode(const udps_client_t *client);
/** Copies the counters into @p out. */
void udps_client_stats(const udps_client_t *client, udps_stats_t *out);
/** Human-readable description of the last failure. Never NULL. */
const char *udps_client_last_error(const udps_client_t *client);
/*---------------------------------------------------------------------------*/
/* Stateless helpers */
/*---------------------------------------------------------------------------*/
/** Elements in one sample of @p signal (rows x cols, at least 1). */
uint32_t udps_signal_num_elements(const udps_signal_t *signal);
/** Short name of a type code, e.g. "float32". Never NULL. */
const char *udps_type_name(uint8_t type_code);
/**
* @brief Decodes a packet header.
* @return 0 on success, -1 if @p len is too small or the magic is wrong.
*/
int udps_parse_header(const void *buf, size_t len, udps_header_t *out);
/**
* @brief Decodes a reassembled CONFIG payload.
* @param signals Out array of at most @p max_signals entries.
* @param num_signals Out; the number actually written.
* @param publish_mode Out; may be NULL.
* @return 0 on success, -1 if the payload is malformed or does not fit.
*/
int udps_parse_config(const void *payload,
size_t len,
udps_signal_t *signals,
uint32_t max_signals,
uint32_t *num_signals,
uint8_t *publish_mode);
/**
* @brief One value out of a frame.
* @param sample Accumulate batch slot; ignored for non-scalar signals.
* @param elem Element within the sample; ignored for accumulated scalars.
* @return the value, or 0.0 if any index is out of range.
*/
double udps_frame_value(const udps_frame_t *frame,
uint32_t signal_idx,
uint32_t sample,
uint32_t elem);
/**
* @brief Arrival-anchored estimate of the wall-clock time of one element.
*
* Exact only for streams that declare a sampling rate: the packet is assumed to
* have arrived as its last element was produced, and earlier elements are dated
* backwards by 1/sampling_rate. Signals with UDPS_TIME_PACKET, or without a
* sampling rate, all report the arrival time. When the stream carries a time
* signal (time_signal_idx != UDPS_NO_TIME_SIGNAL) that signal is the accurate
* source — read it like any other signal instead of using this helper.
*/
double udps_frame_element_time(const udps_frame_t *frame,
uint32_t signal_idx,
uint32_t elem);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* UDPS_CLIENT_H */
+476
View File
@@ -0,0 +1,476 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "acquisition.hpp"
#include <udps_client.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <stdexcept>
#include <string>
namespace udpscope {
namespace {
SignalInfo to_info(const udps_signal_t& s) {
SignalInfo info;
info.name = s.name;
info.unit = s.unit;
info.type_code = s.type_code;
info.quant_type = s.quant_type;
info.rows = s.num_rows;
info.cols = s.num_cols;
info.range_min = s.range_min;
info.range_max = s.range_max;
info.time_mode = s.time_mode;
info.sampling_rate = s.sampling_rate;
info.time_signal_idx = s.time_signal_idx;
return info;
}
} // namespace
Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), cfg_(cfg) {}
Acquisition::~Acquisition() { stop(); }
void Acquisition::start() {
udps_client_config_t c;
udps_client_config_init(&c);
c.server_addr = cfg_.server.c_str();
c.server_port = cfg_.port;
c.multicast_group = cfg_.multicast.empty() ? nullptr : cfg_.multicast.c_str();
c.interface_addr = cfg_.interface_addr.empty() ? nullptr : cfg_.interface_addr.c_str();
c.recv_buffer_bytes = static_cast<std::uint32_t>(cfg_.recv_buffer_bytes);
c.silence_timeout_s = 2.0;
c.reconnect_delay_s = 0.5;
c.keepalive_interval_s = 1.0;
client_ = udps_client_create(&c);
if (client_ == nullptr) {
throw std::runtime_error("could not create the UDPS client for " + cfg_.server + ":" +
std::to_string(cfg_.port));
}
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
&Acquisition::event_trampoline, this);
started_ = std::chrono::steady_clock::now();
running_.store(true, std::memory_order_release);
thread_ = std::thread([this] { run(); });
}
void Acquisition::stop() {
if (!running_.exchange(false, std::memory_order_acq_rel)) {
if (client_ != nullptr) {
udps_client_destroy(client_);
client_ = nullptr;
}
return;
}
if (thread_.joinable()) thread_.join();
if (client_ != nullptr) {
udps_client_destroy(client_);
client_ = nullptr;
}
}
void Acquisition::run() {
while (running_.load(std::memory_order_acquire)) {
udps_client_poll(client_, 50);
udps_stats_t s;
udps_client_stats(client_, &s);
StreamStats& out = model_.stats();
out.packets.store(s.packets_received, std::memory_order_relaxed);
out.frames.store(s.frames_delivered, std::memory_order_relaxed);
out.gaps.store(s.counter_gaps, std::memory_order_relaxed);
out.reconnects.store(s.reconnects, std::memory_order_relaxed);
out.connected.store(udps_client_is_connected(client_) != 0, std::memory_order_relaxed);
}
}
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
std::uint8_t publish_mode) {
std::vector<SignalInfo> info;
info.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) info.push_back(to_info(signals[i]));
// One line per CONFIG on stderr: what the stream says it is. This is the first
// thing to look at when a signal refuses to plot.
static const char* const kModes[] = {"strict", "accumulate", "decimate"};
std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n", count,
publish_mode < 3 ? kModes[publish_mode] : "?");
for (const SignalInfo& sig : info) {
std::fprintf(stderr,
"[udpscope] '%s' %ux%u (%u elem) rate=%g Hz time=%s idx=%u unit='%s'\n",
sig.name.c_str(), sig.rows, sig.cols, sig.elements(), sig.sampling_rate,
sig.time_mode == UDPS_TIME_PACKET ? "packet"
: sig.time_mode == UDPS_TIME_FULL_ARRAY ? "full-array"
: sig.time_mode == UDPS_TIME_FIRST_SAMPLE ? "first-sample"
: "last-sample",
sig.time_signal_idx, sig.unit.c_str());
}
std::fflush(stderr);
model_.stats().have_config.store(true, std::memory_order_relaxed);
model_.set_signals(std::move(info));
}
void Acquisition::on_event(int event, const char* detail) {
(void)detail;
if (event == UDPS_EVENT_CONNECTED) {
model_.stats().connected.store(true, std::memory_order_relaxed);
} else if (event == UDPS_EVENT_DISCONNECTED) {
model_.stats().connected.store(false, std::memory_order_relaxed);
}
}
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
// The frame's span in base ticks: a scalar signal carries `num_samples` values
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
// element block. The largest of those is how many base ticks this frame spans --
// in `strict` mode `num_samples` is 1 while a 1x1000 block carries 1000 samples.
std::uint32_t n = frame->num_samples;
for (std::uint32_t i = 0; i < frame->num_signals; ++i) {
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
}
stamps_.assign(n, 0.0);
double period = model_.partition().base_dt;
if (!(period > 0.0)) period = 1.0e-3;
bool used_stream_time = false;
if (time_signal >= 0 && time_signal < static_cast<int>(frame->num_signals)) {
const udps_signal_t& ts = frame->signals[time_signal];
// How the client indexes a signal's values depends on its shape
// (`udps_frame_value`): a scalar carries one value per batched cycle, so the
// stamp of sample k is (sample=k, elem=0); a multi-element signal carries one
// *sample* of `elements` values per packet, so the stamp of sample k is
// (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time
// signals -- the only mode that carries a stamp per element -- read as
// element 0 for every sample and were then judged degenerate.
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
auto value_of = [&](std::uint32_t k) {
return scalar_time
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
};
switch (ts.time_mode) {
case UDPS_TIME_FULL_ARRAY:
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = value_of(k);
used_stream_time = true;
break;
case UDPS_TIME_FIRST_SAMPLE: {
const double anchor = value_of(0);
const double step = n > 1 ? (value_of(n - 1) - anchor) / double(n - 1) : period;
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * step;
used_stream_time = true;
break;
}
case UDPS_TIME_LAST_SAMPLE: {
const double anchor = value_of(n > 0 ? n - 1 : 0);
for (std::uint32_t k = 0; k < n; ++k)
stamps_[k] = anchor - double(n - 1 - k) * period;
used_stream_time = true;
break;
}
default:
break;
}
}
if (used_stream_time && n > 0) {
// A time signal that does not actually vary -- a vector time signal, whose
// values the client reads one element at a time, or a producer that leaves it
// zero -- would put every sample at one x and collapse the axis to a point.
// The arrival estimate is a worse clock but a usable one; a degenerate one is
// not a time base at all.
bool usable = std::isfinite(stamps_[0]);
double lo = stamps_[0];
double hi = stamps_[0];
for (std::uint32_t k = 1; usable && k < n; ++k) {
if (!std::isfinite(stamps_[k])) {
usable = false;
break;
}
lo = std::min(lo, stamps_[k]);
hi = std::max(hi, stamps_[k]);
}
if (usable && hi > lo) {
// The stamps must also ADVANCE across packets. A producer that restarts its
// stamps every packet (or a signal mislabelled as time) keeps handing back
// the same values; the max-clamp would then pin every sample at one x, and
// the trigger's hold-off clock with it -- the engine waited forever and no
// second event ever fired.
if (have_stream_anchor_ && !(stamps_[n - 1] > last_stream_anchor_)) {
usable = false;
} else {
last_stream_anchor_ = stamps_[n - 1];
have_stream_anchor_ = true;
}
}
if (!usable || !(hi > lo)) {
used_stream_time = false; // no span to time with; fall back to arrival
static bool warned = false;
if (!warned) {
warned = true;
std::fprintf(stderr,
"[udpscope] warning: the stream's time signal carries no usable span "
"(%g..%g) or does not advance across packets; timing samples by "
"arrival instead. Check the time signal's mode and index in the "
"CONFIG dump above.\n",
lo, hi);
}
}
}
if (!used_stream_time) {
// Arrival-anchored: the packet is assumed to have arrived as its last element
// was produced, and the earlier ones are dated backwards. A gap (`lost`)
// shows up as extra elapsed time between arrivals, which is exactly the
// spacing the samples had.
const double end = frame->recv_time;
if (!have_arrival_epoch_) {
arrival_epoch_ = end;
have_arrival_epoch_ = true;
}
const double base = end - arrival_epoch_;
double step = period;
if (have_recv_ && n > 0) {
const double elapsed = end - last_recv_;
const double cycles = double(n) * double(frame->lost + 1);
if (elapsed > 0.0 && cycles > 0.0) step = elapsed / cycles;
}
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = base - double(n - 1 - k) * step;
}
// A stream-time signal may be declared in a time unit rather than seconds
// ('ns' is the common one): scale it to seconds so the x axis is in seconds
// either way. The scale applies ONLY to stream time: the arrival fallback
// already produces seconds off the socket clock -- scaling those by the time
// signal's unit shrank the whole time base by 1e9 on 'ns' streams, which
// froze the trigger's hold-off clock and pinned the axis at ~0.
if (used_stream_time && time_signal >= 0 &&
time_signal < static_cast<int>(model_.signals().size())) {
double unit_scale = 1.0;
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
if (unit == "ns") unit_scale = 1e-9;
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
else if (unit == "ms") unit_scale = 1e-3;
if (unit_scale != 1.0) {
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
}
}
last_recv_ = frame->recv_time;
have_recv_ = true;
return n;
}
void Acquisition::on_data(const udps_frame_t* frame) {
if (paused_.load(std::memory_order_relaxed)) return;
std::lock_guard<std::mutex> lock(model_.mutex());
if (!running_.load(std::memory_order_relaxed)) return;
const Partition& part = model_.partition();
const std::vector<ChannelInfo>& channels = model_.channels();
if (part.time_capacity == 0 || channels.empty()) return;
// The time reference, in order of preference: the signal the first channel's
// CONFIG entry names via `time_signal_idx`, or -- when the stream instead
// carries its time base as an explicit full-array signal -- that signal.
int time_signal = -1;
{
const SignalInfo& sig = model_.signals()[channels[0].signal];
if (sig.time_signal_idx != 0xFFFFFFFFu && sig.time_signal_idx < model_.signals().size()) {
time_signal = static_cast<int>(sig.time_signal_idx);
}
}
if (time_signal < 0) {
// No channel names a time signal: look for the standalone timestamp signal
// (full-array, more than one element, no declared rate).
for (std::size_t i = 0; i < model_.signals().size(); ++i) {
const SignalInfo& sig = model_.signals()[i];
if (sig.time_mode == UDPS_TIME_FULL_ARRAY && sig.elements() > 1 &&
sig.sampling_rate == 0.0) {
time_signal = static_cast<int>(i);
break;
}
}
}
const std::uint32_t n = build_stamps(frame, time_signal);
if (n == 0) return;
if (model_.epoch() == 0.0) {
model_.set_epoch_if_unset(stamps_[0]);
std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
model_.epoch(), frame->recv_time, n);
std::fflush(stderr);
}
const double epoch = model_.epoch();
TriggerConfig& tc = model_.trigger();
// One ring, always filling, whatever the trigger is doing: the pre-trigger
// history has to already be there when an edge arrives, and the first latch after
// arming would otherwise open on however little had accumulated since. What
// freezes in trigger mode is the *view*, not acquisition -- the latch copies the
// event window out into an immutable `EventSnapshot`.
BufferStore& store = model_.live();
const u64 g = part.time_stride;
// Calibration is read per frame; it only changes under the same lock.
TriggerEngine& engine = engine_;
const int trig_channel = tc.channel;
// The post-trigger window straight through in seconds. There used to be a measured,
// smoothed samples-per-second estimate here, converting the window into a sample
// count for the engine; the estimate is what sized captures wrong. Its floor
// (`inst >= 1.0`) turned any stream whose time signal advances in large steps into
// 1 S/s, the count came out as 1, and the engine latched on the edge sample itself
// -- an event with no post-trigger data, which is exactly the "post window is
// empty" defect. The engine is fed stream time per sample anyway, so it can count
// the window itself and no conversion has to exist.
//
// The oldest buffered stamp goes with it, so the engine can refuse an edge the
// history does not reach back far enough behind. Read from the ring rather than
// timed from the arm: the ring fills with the trigger off too, so the pre window is
// usually already there and a timer would delay the first event for nothing.
const double event_span = model_.event_span();
const double post_want = tc.post_span_s(event_span);
const double oldest_s =
store.time().size() > 0 ? double(store.time()[0]) : last_stamp_;
engine.configure(tc, post_want, event_span - post_want, oldest_s);
// The engine's armed state follows the panel's, both ways: without the disarm
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
// re-entering trigger mode kept the stale prev/edge/hold-off state.
if (tc.enabled && tc.armed) {
if (!engine.armed()) {
engine.arm(last_stamp_);
model_.trigger_armed().store(true, std::memory_order_relaxed);
}
} else if (engine.armed()) {
engine.disarm();
model_.trigger_armed().store(false, std::memory_order_relaxed);
}
double last_stamp_used = last_stamp_;
// The latch is handled on the sample it happens on, and with the edge the engine
// latched *on* -- after that sample's channels have all been pushed (see the loop).
//
// It used to set a flag and publish once after the loop, reading the edge back from
// the engine. But the loop keeps feeding the engine after a latch, so any further
// edge in the rest of the packet -- the hold-off is 10 ms and a packet can be a long
// block of samples -- overwrote that edge before it was read. The capture was then
// cut around an edge whose post window had not been collected yet, so the event came
// out all pre-trigger with the marker and x = 0 at the right-hand end of the data.
// Publishing here also means the ring provably holds the post window: the latch
// condition is that stream time has passed `edge + post`, and every sample up to
// this one has already been pushed.
const double span = std::max(1.0e-9, model_.event_span());
const double post_s = tc.post_span_s(span);
const auto latch = [&](double edge) {
model_.publish_event(span - post_s, post_s, edge);
model_.trigger_latches().fetch_add(1, std::memory_order_relaxed);
model_.trigger_time().store(edge, std::memory_order_relaxed);
model_.trigger_time_valid().store(true, std::memory_order_relaxed);
// The captured window is reported from the snapshot itself, not from the request:
// `want` versus `got` is what distinguishes "the engine did not wait" from "the
// cut lost the data", which is otherwise guesswork from the picture alone.
auto snap = model_.event();
std::fprintf(stderr,
"[udpscope] trigger: edge at %.3f s, latched at %.3f s of stream time "
"(want [-%.4f..%.4f] got [-%.4f..%.4f] n=%llu, frame recv=%.3f, "
"ticks_so_far=%llu, wall=%.3f s)\n",
edge, last_stamp_, span - post_s, post_s, snap ? snap->pre_s : 0.0,
snap ? snap->post_s : 0.0,
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0),
frame->recv_time, static_cast<unsigned long long>(tick_),
std::chrono::duration<double>(std::chrono::steady_clock::now() - started_)
.count());
std::fflush(stderr);
if (tc.mode == TriggerMode::Single) tc.armed = false;
};
for (std::uint32_t k = 0; k < n; ++k) {
const u64 tick = tick_++;
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
last_stamp_used = rel;
last_stamp_ = rel;
if (tick % g == g - 1) store.time().push(static_cast<f32>(rel));
// The latch belongs to this sample, but it cannot be published from inside the
// channel loop: `publish_event()` clamps the slice to every value ring's
// `produced()`, and the channels *after* the trigger channel have not pushed this
// sample yet, so the newest sample -- the one the post window was waiting for --
// would be clamped away. One sample is invisible in a 20 000-sample capture (it is
// the `n=19999` and the `0.9999` instead of `1.0000` in the probe's output), and at
// one sample per second it is the entire post side: `got [-0.9997..0.0000]`, which
// reads exactly like the "post window is empty" defect.
bool latch_here = false;
double latch_edge = 0.0;
for (std::size_t i = 0; i < channels.size(); ++i) {
const u64 period = g * part.stride[i];
if (tick % period != period - 1) continue;
const ChannelInfo& ch = channels[i];
// A signal's columns are consecutive samples of the same channel: a scalar
// (one element) carries one value per batched cycle, a block signal carries
// the cycle's sample at element `k`. Either way element 0 is the channel.
const double raw =
ch.elements > 1
? udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), 0,
std::min<std::uint32_t>(k, ch.elements - 1))
: udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), k, 0);
const f32 value = static_cast<f32>(raw * ch.gain + ch.offset);
store.values(i).push(value);
if (static_cast<int>(i) == trig_channel) {
if (engine.process(value, rel)) {
latch_here = true;
latch_edge = engine.last_trigger_time();
}
}
}
if (latch_here) latch(latch_edge);
}
last_stamp_ = last_stamp_used;
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
model_.set_trigger_pending(engine.pending());
static std::int64_t next_engine_log = 0;
const std::int64_t now_e = static_cast<std::int64_t>(frame->recv_time * 1.0e6);
if (now_e >= next_engine_log) {
next_engine_log = now_e + 1000000;
// `elapsed/post_want` is the capture's progress in the only unit that matters now
// (stream seconds); `accum` is how many samples that window has collected, which
// is what says whether a capture is thin because the window is short or because
// the stream is slow.
std::fprintf(stderr,
"[udpscope] trig engine: ch=%d armed=%d pending=%d elapsed=%.4f/%.4f s "
"accum=%llu history=%.4f/%.4f s pre_pct=%.0f v=%.4g now_t=%.3f "
"holdoff_until=%.3f\n",
trig_channel, int(engine.armed()), int(engine.pending()),
engine.post_elapsed(), engine.post_seconds(),
static_cast<unsigned long long>(engine.post_accum()), engine.history(),
engine.pre_seconds(), tc.pretrigger_pct, engine.last_value(),
engine.last_t(), engine.holdoff_until());
std::fflush(stderr);
}
}
// Static trampolines: the client's C ABI cannot call a member function directly.
void Acquisition::config_trampoline(const udps_signal_t* signals, std::uint32_t count,
std::uint8_t publish_mode, void* user) {
static_cast<Acquisition*>(user)->on_config(signals, count, publish_mode);
}
void Acquisition::data_trampoline(const udps_frame_t* frame, void* user) {
static_cast<Acquisition*>(user)->on_data(frame);
}
void Acquisition::event_trampoline(udps_event_t event, const char* detail, void* user) {
static_cast<Acquisition*>(user)->on_event(static_cast<int>(event), detail);
}
} // namespace udpscope
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "config.hpp"
#include "model.hpp"
#include "trigger.hpp"
#include <udps_client.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <thread>
#include <vector>
namespace udpscope {
/// Owns the UDPStreamer client and the producer thread that feeds the model's
/// acquisition buffer. Everything it touches from the thread is serialised on
/// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame.
class Acquisition {
public:
Acquisition(ScopeModel& model, const Config& cfg);
~Acquisition();
Acquisition(const Acquisition&) = delete;
Acquisition& operator=(const Acquisition&) = delete;
/// Opens the client (no socket until the first poll) and starts the thread.
/// Throws std::runtime_error when the client cannot be created.
void start();
/// Stops the thread and destroys the client. Idempotent.
void stop();
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
private:
void run();
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
void on_data(const udps_frame_t* frame);
void on_event(int event, const char* detail);
static void config_trampoline(const udps_signal_t* signals, std::uint32_t count,
std::uint8_t publish_mode, void* user);
static void data_trampoline(const udps_frame_t* frame, void* user);
static void event_trampoline(udps_event_t event, const char* detail, void* user);
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
/// and returns how many were written (`frame->num_samples` unless the frame is
/// malformed).
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
ScopeModel& model_;
Config cfg_;
udps_client* client_ = nullptr;
std::thread thread_;
std::atomic<bool> running_{false};
std::atomic<bool> paused_{false};
TriggerEngine engine_;
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
std::uint64_t last_tick_ = ~std::uint64_t{0};
double last_stamp_ = 0.0; ///< last relative time written (monotonic guard)
double last_recv_ = 0.0;
bool have_recv_ = false;
/// Last accepted stream-time stamp: a "time signal" whose values do not advance
/// across packets (a producer that restarts them per packet, or a mislabelled
/// signal) would freeze the whole time base -- the trigger's hold-off clock
/// included -- so the stream source is dropped in favour of arrival time.
double last_stream_anchor_ = 0.0;
bool have_stream_anchor_ = false;
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
double arrival_epoch_ = 0.0;
bool have_arrival_epoch_ = false;
std::vector<double> stamps_;
std::chrono::steady_clock::time_point started_{};
};
} // namespace udpscope
+317
View File
@@ -0,0 +1,317 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "channel.hpp"
#include <algorithm>
#include <cmath>
namespace udpscope {
namespace {
/// Raw samples read per chunk on the gather path, so it never allocates.
constexpr u64 kChunk = 256;
/// The pyramid handed out for a slot with nothing bound to it. The renderer never
/// asks an empty series a bucket question, but the reference must be valid.
const plot::data::MinMaxPyramid& null_pyramid() {
static const plot::data::MinMaxPyramid empty;
return empty;
}
} // namespace
// --- ChannelBuffer ----------------------------------------------------------
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride,
f64 epoch, f64 nominal_dt)
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
epoch_(epoch), dt_(nominal_dt) {}
void ChannelBuffer::acquire() noexcept {
const u64 produced = values_->produced();
if (produced <= published_) {
// Nothing new; still allow the window to slide if another reader dropped it.
return;
}
// A producer that lapped this ring -- a long trigger wait, or a store that was
// not being published for a while -- makes the incremental append meaningless:
// the samples between `published_` and `produced_` are no longer in the ring.
// Rebuilding from the retained window is O(capacity) once, and it is the only
// way the published window stays coherent (an empty or garbled window is what
// "the trace disappeared" looks like from outside).
if (produced - published_ > values_->capacity()) {
published_ = 0;
pyramid_.clear();
}
f32 chunk[kChunk];
for (u64 i = published_; i < produced;) {
const u64 take = std::min<u64>(kChunk, produced - i);
values_->copy_from(i, take, chunk);
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
i += take;
}
published_ = produced;
// The pyramid's window follows the ring's: sample index `base` is the oldest
// one still retained. drop_before() is monotonic and cheap.
pyramid_.drop_before(values_->base());
}
void ChannelBuffer::invalidate() noexcept {
published_ = 0;
pyramid_.clear();
}
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// Channel sample `j` is produced on the tick that also pushes time slot
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
// time ring every `time_stride` ticks and the channel every `stride` of those,
// so the last tick of the group is the one that carries the channel's sample.
const u64 index = absolute_index * stride_ + (stride_ - 1);
// The time ring is circular, so an index stays readable while it is *retained*
// and `copy_from()` masks it. Testing it against `capacity()` would reject every
// index past the first wrap -- which is every sample once the ring has filled,
// and would silently stamp them all with x = 0.
//
// An index the ring has already dropped is **clamped to the oldest readable slot,
// never answered with a sentinel**. The published window is pinned by `acquire()`
// to the value ring's base, and both rings keep sliding afterwards (their
// capacities are equal, so there is no slack at all): once the ring saturates, the
// few oldest samples of the window routinely point at slots the producer has
// overwritten since. Returning 0 for those put a stamp of 0 into `x_bounds()`, and
// from there into `dx()` and `x0()` -- which is the renderer's entire index
// mapping, `(x - x0()) / dx()`. So *one* unreadable slot at the front inflated
// `dx()` by the ratio of the whole elapsed stream time to the retained window and
// pushed every index up with it: `first` landed well past the first visible
// sample, and the samples in between were in the buffer, readable by the crosshair
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel),
// and never drawn. That is the "live trace is clipped, only the most recent part
// is visible, but the crosshair still reads values there" defect.
//
// Clamping degrades the way the library's own `StreamSeries` documents for this
// contract: a snapshot bounds the sample *count*, not the slots, so the oldest few
// samples of a saturated window may read a newer value than the window names. One
// frame's worth of samples at the very left edge is then drawn on a short plateau
// of equal stamps -- monotone, so the binary searches still hold -- instead of the
// whole trace's geometry collapsing.
const u64 base = times_->base();
const u64 produced = times_->produced();
if (produced == 0) return 0.0f;
u64 slot = index < base ? base : index;
if (slot >= produced) slot = produced - 1;
f32 out = 0.0f;
times_->copy_from(slot, 1, &out);
return out;
}
f64 ChannelBuffer::dx() const {
const u64 n = pyramid_.size();
if (n < 2) return dt_ > 0.0 ? dt_ : 1.0;
const plot::Range b = x_bounds();
const double span = b.max - b.min;
if (!(span > 0.0) || !std::isfinite(span)) return dt_ > 0.0 ? dt_ : 1.0;
return span / f64(n - 1);
}
f64 ChannelBuffer::x0() const {
if (pyramid_.size() == 0) return epoch_;
return epoch_ + f64(time_of(absolute(0)));
}
f64 ChannelBuffer::x_at(u64 i) const {
if (i >= pyramid_.size()) return x0();
return epoch_ + f64(time_of(absolute(i)));
}
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
const u64 n = pyramid_.size();
if (n == 0) return false;
if (x < x_at(0) || x > x_at(n - 1)) return false;
// The time ring is monotonic, so the bracketing pair is a binary search rather
// than a grid walk: after a lost packet the two samples either side of `x` are
// not `x`/`dx` apart, and a grid would name the wrong pair.
u64 lo = 0;
u64 hi = n - 1;
while (lo < hi) {
const u64 mid = lo + (hi - lo) / 2;
if (x_at(mid) < x) {
lo = mid + 1;
} else {
hi = mid;
}
}
i1 = lo;
i0 = lo;
if (x_at(lo) > x && lo > 0) i0 = lo - 1;
return true;
}
u64 ChannelBuffer::snap_index(f64 x) const {
u64 i0 = 0;
u64 i1 = 0;
if (!neighbours(x, i0, i1)) {
if (pyramid_.size() == 0) return 0;
return x < x_at(0) ? 0 : pyramid_.size() - 1;
}
if (i0 == i1) return i0;
return (x - x_at(i0)) <= (x_at(i1) - x) ? i0 : i1;
}
plot::Range ChannelBuffer::x_bounds() const {
const u64 n = pyramid_.size();
if (n == 0) return plot::Range{epoch_, epoch_};
return plot::Range{epoch_ + f64(time_of(absolute(0))),
epoch_ + f64(time_of(absolute(n - 1)))};
}
plot::Range ChannelBuffer::y_bounds() const {
const u64 n = pyramid_.size();
if (n == 0) return plot::Range{0.0, 0.0};
return reduce(0, n);
}
void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
const u64 n = pyramid_.size();
const u64 b = std::min(begin, n);
const u64 e = std::min(end, n);
const u64 count = e > b ? e - b : 0;
if (count > 0) values_->copy_from(absolute(b), count, out);
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
}
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
plot::data::GatherMode mode) const {
const u64 n = pyramid_.size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
const u64 origin = pyramid_.window_base();
const f64 base = x0(); // x is emitted relative to this (see the header)
const auto emit_raw = [&](u64 from, u64 to) {
for (u64 i = from; i < to; ++i) {
f32 y = 0.0f;
raw(i, i + 1, &y);
out_xy.push_back(static_cast<f32>(x_at(i) - base));
out_xy.push_back(y);
}
};
const auto rel = [&](u64 i) { return static_cast<f32>(x_at(i) - base); };
if (level == 0) {
emit_raw(b, e);
return;
}
level = std::min(level, plot::data::kMaxPyramidLevel);
// One geometry element per *complete* bucket wholly inside the range. `first` is
// a ceiling and `last` a floor, so no aggregate can reach outside [b, e) -- a
// floor on `first` or a `last` that includes the bucket holding `e-1` would emit
// aggregates covering samples the caller did not ask for (the library's B3 fix,
// which this mirrors). The spans left at the two edges are drawn from raw
// samples, so the geometry still covers the range exactly and a partial bucket is
// drawn once, not twice.
const u64 span = u64{plot::data::kBucket} << level;
const u64 lbase = pyramid_.level_base(level);
const u64 lend = lbase + pyramid_.level_size(level);
const u64 ceil_div = (origin + b) / span + ((origin + b) % span == 0 ? u64{0} : u64{1});
const u64 first = std::max(ceil_div, lbase);
const u64 last = std::min((origin + e) / span, lend);
if (first >= last) {
emit_raw(b, e);
return;
}
const u64 lead = first * span - origin;
if (b < lead) emit_raw(b, std::min(lead, e));
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyramid_.bucket_count(level, bi);
const u64 fs = bi * span - origin; // >= 0: a stored bucket starts inside the window
switch (mode) {
case plot::data::GatherMode::Nearest: {
const u64 centre = bi * span + span / 2 - origin;
f32 y = 0.0f;
raw(centre, centre + 1, &y);
out_xy.push_back(rel(centre));
out_xy.push_back(y);
break;
}
case plot::data::GatherMode::Mean: {
if (count == 0) break; // no finite sample: no mean to draw
const u64 centre = bi * span + span / 2 - origin;
out_xy.push_back(rel(centre));
out_xy.push_back(static_cast<f32>(pyramid_.bucket_sum(level, bi) / f64(count)));
break;
}
default: { // Envelope
if (count == 0) break;
const plot::data::MinMax m = pyramid_.bucket(level, bi);
const u64 ls = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(rel(fs));
out_xy.push_back(m.min);
out_xy.push_back(rel(ls));
out_xy.push_back(m.max);
break;
}
}
}
const u64 trail = last * span - origin;
if (trail < e) emit_raw(trail, e);
}
// --- SlotSeries -------------------------------------------------------------
plot::Range SlotSeries::x_bounds() const {
if (event_ != nullptr) return event_->x_bounds();
return buffer_ != nullptr ? buffer_->x_bounds() : plot::Range{0.0, 1.0};
}
plot::Range SlotSeries::y_bounds() const {
if (event_ != nullptr) return event_->y_bounds();
return buffer_ != nullptr ? buffer_->y_bounds() : plot::Range{0.0, 0.0};
}
const plot::data::MinMaxPyramid& SlotSeries::pyramid() const {
if (event_ != nullptr) return event_->pyramid();
return buffer_ != nullptr ? buffer_->pyramid() : null_pyramid();
}
f64 SlotSeries::x_at(u64 i) const {
if (event_ != nullptr) return event_->x_at(i);
return buffer_ != nullptr ? buffer_->x_at(i) : 0.0;
}
bool SlotSeries::neighbours(f64 x, u64& i0, u64& i1) const {
if (event_ != nullptr) return event_->neighbours(x, i0, i1);
return buffer_ != nullptr && buffer_->neighbours(x, i0, i1);
}
u64 SlotSeries::snap_index(f64 x) const {
if (event_ != nullptr) return event_->snap_index(x);
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
}
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
plot::data::GatherMode mode) const {
if (event_ != nullptr) {
event_->gather(level, begin, end, out_xy, mode);
return;
}
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode);
}
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
if (event_ != nullptr) {
event_->raw_values(begin, end, out);
return;
}
if (buffer_ != nullptr) {
buffer_->raw(begin, end, out);
return;
}
for (u64 i = 0; i < end - begin; ++i) out[i] = 0.0f;
}
} // namespace udpscope
+161
View File
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
#include "event_series.hpp"
#include <memory>
#include <vector>
namespace udpscope {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// One channel's view of a (value, time) ring pair.
///
/// The sample grid the library's `Series` contract assumes -- `x = x0 + i*dx` --
/// does not hold here, because x is a *streamed timestamp*, not an index: a lost
/// datagram leaves a gap and the samples either side of it are not one grid step
/// apart. `dx()` and `x0()` therefore report a nominal grid (what the LOD level
/// and the snap use), while `gather()` and `x_bounds()` report the real
/// timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read
/// only y, so they are exact either way.
///
/// The time ring is shared by every channel of one buffer (spec: the +1 slice);
/// channel sample `j` is stamped by base tick `j * stride`. A stride of 1 is the
/// undecimated case and the common one.
class ChannelBuffer final : public plot::data::Series {
public:
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch,
f64 nominal_dt);
ChannelBuffer(const ChannelBuffer&) = delete;
ChannelBuffer& operator=(const ChannelBuffer&) = delete;
/// Consumer thread: publishes everything the producer pushed since the previous
/// call into the LOD pyramid and slides the window. O(samples produced).
void acquire() noexcept;
/// Consumer thread: forgets the published window (after a rebind or a reset).
void invalidate() noexcept;
void set_nominal_dt(f64 dt) noexcept { dt_ = dt; }
/// Re-bases the x axis: `x0()`/`x_at()` become `epoch + timestamp`. Used to make
/// the axis read seconds from the trigger event instead of from the stream.
void set_epoch(f64 e) noexcept { epoch_ = e; }
u64 stride() const noexcept { return stride_; }
// --- plot::data::Series ---------------------------------------------------
u64 size() const override { return pyramid_.size(); }
f64 x0() const override;
/// The *mean* spacing of the retained window. The renderer converts a visible x
/// range into an index range with `(x - x0()) / dx()`, so this must describe the
/// samples that are actually retained: reporting the nominal `stride * dt` made
/// that index range exceed the series by the decimation factor, the gather
/// clamped to nothing, and the trace drew empty -- which is exactly what a fast
/// channel looked like next to a slow one, because only the slow one is
/// recognisable from the few samples a wrong range still happens to cover.
f64 dx() const override;
/// The authoritative x of a sample: the streamed timestamp, not a grid. `dx()`
/// is only the mean spacing (the LOD level and a coarse snap use it); a lost
/// datagram leaves a gap, so index times must come from the time ring.
f64 x_at(u64 i) const override;
/// Bracketing samples by binary search over the time ring, which is monotonic,
/// so a crosshair value is interpolated between the two samples actually either
/// side of the pointer -- a grid walk would land on the wrong pair after a gap.
bool neighbours(f64 x, u64& i0, u64& i1) const override;
u64 snap_index(f64 x) const override;
plot::Range x_bounds() const override;
plot::Range y_bounds() const override;
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
/// x is emitted **relative to `x0()`** (the library's contract): staging an
/// absolute epoch time as f32 would quantise it far coarser than a pixel.
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
/// `copy_raw`, published so the retargetable trace series can forward the
/// protected hook of another object.
void raw(u64 begin, u64 end, f32* out) const;
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override { raw(begin, end, out); }
private:
/// Absolute (channel) index of retained-window index `i`.
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
f32 time_of(u64 absolute_index) const noexcept;
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
std::shared_ptr<const plot::data::RingBuffer<f32>> times_;
u64 stride_ = 1;
f64 epoch_ = 0.0;
f64 dt_ = 1.0;
u64 published_ = 0;
plot::data::MinMaxPyramid pyramid_;
};
/// A trace's series: one stable object whose channel can be rebound.
///
/// `Plot` has no `set_trace_series()` and no `remove_trace()`, so a trace's series
/// pointer is fixed for the plot's life. Everything that would otherwise need a
/// new pointer -- which signal a trace plots, and the switch between the live ring
/// and a frozen event -- is therefore expressed here as a rebind.
class SlotSeries final : public plot::data::Series {
public:
/// Takes shared ownership: a structural change in the model may drop its
/// `BufferStore` while a trace still points here, and the renderer reads the
/// buffer after this frame's bind has returned.
void bind(std::shared_ptr<ChannelBuffer> buffer) noexcept {
buffer_ = std::move(buffer);
event_ = nullptr;
}
/// Binds the captured event (edge-relative stamps). Binding an event clears
/// the ring binding and vice versa: the slot shows exactly one source.
void bind_event(std::shared_ptr<const EventSnapshot> snap, std::size_t channel) {
if (snap == nullptr || channel >= snap->values.size()) {
event_ = nullptr;
return;
}
if (event_ == nullptr || !event_->same(snap, channel)) {
event_ = std::make_unique<EventSeries>(std::move(snap), channel);
}
buffer_ = nullptr;
}
ChannelBuffer* bound() const noexcept { return buffer_.get(); }
const EventSeries* bound_event() const noexcept { return event_.get(); }
bool empty() const noexcept {
if (event_ != nullptr) return event_->size() == 0;
return buffer_ == nullptr || buffer_->size() == 0;
}
u64 size() const override { return event_ != nullptr ? event_->size() : buffer_ != nullptr ? buffer_->size() : 0; }
f64 x0() const override { return event_ != nullptr ? event_->x0() : buffer_ != nullptr ? buffer_->x0() : 0.0; }
f64 dx() const override { return event_ != nullptr ? event_->dx() : buffer_ != nullptr ? buffer_->dx() : 1.0; }
f64 x_at(u64 i) const override;
bool neighbours(f64 x, u64& i0, u64& i1) const override;
u64 snap_index(f64 x) const override;
plot::Range x_bounds() const override;
plot::Range y_bounds() const override;
const plot::data::MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
std::shared_ptr<ChannelBuffer> buffer_;
std::unique_ptr<EventSeries> event_; ///< the captured event, when held
};
} // namespace udpscope
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "config.hpp"
#include <cstdio>
#include <cstdlib>
#include <stdexcept>
#include <string>
namespace udpscope {
namespace {
[[noreturn]] void fail(const std::string& what) { throw std::runtime_error(what); }
/// `--name=value` / `--name value`; returns the value and advances `i` past both
/// spellings. `argv[i]` must start with `--name`.
std::string value_of(int argc, char** argv, int& i, const char* name) {
const std::string arg = argv[i];
const std::string prefix = std::string(name) + "=";
if (arg.rfind(prefix, 0) == 0) return arg.substr(prefix.size());
if (i + 1 >= argc) fail(std::string("missing value for ") + name);
return argv[++i];
}
double parse_double(const char* name, const std::string& v) {
try {
std::size_t used = 0;
const double d = std::stod(v, &used);
if (used != v.size()) fail(std::string("not a number: ") + name + " " + v);
return d;
} catch (const std::exception&) {
fail(std::string("not a number: ") + name + " " + v);
}
}
} // namespace
void print_usage(std::FILE* out, const char* argv0) {
std::fprintf(out,
"usage: %s --server ADDR --port PORT [options]\n"
"\n"
" --server ADDR stream source IPv4 address (default 127.0.0.1)\n"
" --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n"
" --multicast GROUP join GROUP for DATA instead of unicast\n"
" --interface ADDR local IPv4 of the interface to join on\n"
" --buffer-mb MB size of each of the two sample buffers (default 200)\n"
" --max-window SEC largest time window the toolbar offers (default 600)\n"
" --holdoff SEC default trigger hold-off (default 0.010)\n"
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
" --plots N initial number of stacked plots, 1..4 (default 1)\n"
" -h, --help this text\n",
argv0);
}
Config parse_args(int argc, char** argv) {
Config c;
for (int i = 1; i < argc; ++i) {
const std::string a = argv[i];
if (a == "-h" || a == "--help") {
print_usage(stdout, argv[0]);
std::exit(0);
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
c.server = value_of(argc, argv, i, "--server");
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--port");
const long p = std::strtol(v.c_str(), nullptr, 10);
if (p <= 0 || p > 65535) fail("port out of range: " + v);
c.port = static_cast<std::uint16_t>(p);
} else if (a == "--multicast" || a.rfind("--multicast=", 0) == 0) {
c.multicast = value_of(argc, argv, i, "--multicast");
} else if (a == "--interface" || a.rfind("--interface=", 0) == 0) {
c.interface_addr = value_of(argc, argv, i, "--interface");
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
} else if (a == "--holdoff" || a.rfind("--holdoff=", 0) == 0) {
c.trigger_holdoff_s = parse_double("--holdoff", value_of(argc, argv, i, "--holdoff"));
if (c.trigger_holdoff_s < 0.0) fail("--holdoff must not be negative");
} else if (a == "--recv-buffer" || a.rfind("--recv-buffer=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--recv-buffer");
const long n = std::strtol(v.c_str(), nullptr, 10);
if (n <= 0) fail("--recv-buffer must be positive: " + v);
c.recv_buffer_bytes = static_cast<int>(n);
} else if (a == "--plots" || a.rfind("--plots=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--plots");
const long n = std::strtol(v.c_str(), nullptr, 10);
if (n < 1 || n > 4) fail("--plots must be 1..4: " + v);
c.plots = static_cast<int>(n);
} else {
fail("unknown argument: " + a);
}
}
return c;
}
} // namespace udpscope
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstdint>
#include <string>
namespace udpscope {
/// Everything the command line can say. Defaults are the ones the spec names
/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them.
struct Config {
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
std::string multicast; ///< Empty selects unicast.
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
/// Size of *each* of the two sample buffers, in megabytes. The application
/// always allocates two identical buffers (spec item 1).
double buffer_mb = 200.0;
/// Largest time window the toolbar offers, in seconds. The buffer partition is
/// sized to hold at least this much time (spec: decimation).
double max_window_s = 600.0;
double trigger_holdoff_s = 0.010;
int recv_buffer_bytes = 8 * 1024 * 1024;
/// Number of stacked plots (rows) to start with, 1..4.
int plots = 1;
};
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
/// argument prints a diagnostic to stderr and exits(2).
Config parse_args(int argc, char** argv);
/// Writes the usage text to `out`.
void print_usage(std::FILE* out, const char* argv0);
} // namespace udpscope
+141
View File
@@ -0,0 +1,141 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/data/series.hpp>
#include <algorithm>
#include <memory>
#include <vector>
namespace udpscope {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// One captured trigger event, immutable once published: per-channel values over
/// a shared, edge-relative time stamp array (the edge sits at exactly x = 0, the
/// window spans [-pre, +post]).
///
/// The plot binds to a snapshot while the trigger holds an event. Because the
/// snapshot can never change after publication, nothing can drag, re-base or
/// refresh the view while it is held -- the failures the previous design (two
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
/// could not stop.
struct EventSnapshot {
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
std::vector<std::vector<f32>> values; ///< one array per channel, same length
double pre_s = 0.0; ///< seconds of pre-trigger data kept
double post_s = 0.0; ///< seconds of post-trigger data kept
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
};
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
/// the exact stamp; gather decimates by 2^level like the ring series does.
class EventSeries final : public plot::data::Series {
public:
explicit EventSeries(std::shared_ptr<const EventSnapshot> snap, std::size_t channel)
: snap_(std::move(snap)), channel_(channel) {}
/// Identity for rebind: same snapshot object and channel means the existing
/// EventSeries already shows this event.
bool same(const std::shared_ptr<const EventSnapshot>& snap, std::size_t channel) const {
return snap_ == snap && channel_ == channel;
}
/// The shorter of the two arrays this series reads. `gather()` indexes `stamps`
/// and `values[channel]` at the same i, so reporting the stamp count alone would
/// be an out-of-bounds read the moment they differ.
u64 size() const override {
if (channel_ >= snap_->values.size()) return 0;
return static_cast<u64>(
std::min(snap_->stamps.size(), snap_->values[channel_].size()));
}
f64 x0() const override { return snap_->stamps.empty() ? 0.0 : snap_->stamps.front(); }
f64 dx() const override {
const auto& s = snap_->stamps;
return s.size() > 1 ? (s.back() - s.front()) / static_cast<f64>(s.size() - 1) : 1.0;
}
f64 x_at(u64 i) const override {
const auto& s = snap_->stamps;
return i < s.size() ? s[static_cast<std::size_t>(i)] : 0.0;
}
plot::Range x_bounds() const override {
const auto& s = snap_->stamps;
return s.empty() ? plot::Range{0.0, 0.0} : plot::Range{s.front(), s.back()};
}
plot::Range y_bounds() const override {
return channel_ < snap_->y.size() ? snap_->y[channel_] : plot::Range{0.0, 0.0};
}
const plot::data::MinMaxPyramid& pyramid() const override { return empty_pyramid_; }
bool streaming() const noexcept override { return false; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
plot::data::GatherMode /*mode*/ =
plot::data::GatherMode::Envelope) const override {
if (channel_ >= snap_->values.size()) return;
const auto& vals = snap_->values[channel_];
const auto& stamps = snap_->stamps;
const u64 n = size();
const u64 stride = u64{1} << level;
if (stride == 0 || begin >= n) return;
if (end > n) end = n;
// x **relative to x0()**, which is the library's gather contract: the renderer
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
// pre-trigger window -- to the left of the axis it had just been measured
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
// which is why that shift looked like a plausible picture: with a 50/50 split the
// right half of the plot was simply empty, zooming out showed the trace ending at
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
// right values.
const f64 base = x0();
out_xy.reserve(out_xy.size() + static_cast<std::size_t>((end - begin) / stride * 2 + 4));
for (u64 i = (begin / stride) * stride; i < end; i += stride) {
out_xy.push_back(static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base));
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
}
}
bool neighbours(f64 x, u64& i0, u64& i1) const override {
const u64 n = size();
if (n < 2) return false;
const f64 step = dx();
if (!(step > 0.0)) return false;
f64 idx = (x - x0()) / step;
if (idx < 0.0) idx = 0.0;
if (idx > static_cast<f64>(n - 1)) idx = static_cast<f64>(n - 1);
i0 = static_cast<u64>(idx);
i1 = i0 + 1 < n ? i0 + 1 : i0;
return true;
}
u64 snap_index(f64 x) const override {
u64 i0 = 0, i1 = 0;
if (!neighbours(x, i0, i1)) return 0;
return i1;
}
/// SlotSeries::copy_raw forwards here (public: the base's hook is protected,
/// and SlotSeries is a different class).
void raw_values(u64 begin, u64 end, f32* out) const {
const auto& vals = snap_->values[channel_];
const u64 n = static_cast<u64>(vals.size());
if (end > n) end = n;
for (u64 i = begin; i < end; ++i)
out[static_cast<std::size_t>(i - begin)] = vals[static_cast<std::size_t>(i)];
}
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override { raw_values(begin, end, out); }
private:
std::shared_ptr<const EventSnapshot> snap_;
std::size_t channel_;
plot::data::MinMaxPyramid empty_pyramid_{};
};
} // namespace udpscope
+273
View File
@@ -0,0 +1,273 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "export.hpp"
#include <algorithm>
#include <bit>
#include <cerrno>
#include <cstdio>
#include <cstring>
namespace udpscope {
namespace {
// The writers memcpy `f64`/`f32` straight out and label them `<f8`/`<f4`, so a
// big-endian host would produce a file whose bytes contradict its own dtype. There is no
// such host in this app's reach and a byte-swapping path nobody runs is a path nobody
// tests, so the assumption is stated instead of handled.
static_assert(std::endian::native == std::endian::little,
"the .npy writer labels its fields little-endian and does not swap");
/// A column label a reader can use: NumPy rejects duplicate field names outright, and a
/// quote or a brace in one would break the header dict it is spelled into. Signal names
/// come from the stream, so neither is this app's to promise.
std::string sanitise(const std::string& name) {
std::string out;
out.reserve(name.size());
for (const char ch : name) {
const bool ok = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') || ch == '_';
out.push_back(ok ? ch : '_');
}
if (out.empty()) out = "col";
return out;
}
/// The final labels: sanitised, and de-duplicated against each other *and* against the
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
/// easily sanitise to the same string.
std::vector<std::string> column_labels(const ExportTable& table) {
std::vector<std::string> taken{"time", "stream_time"};
std::vector<std::string> out;
out.reserve(table.columns.size());
for (const ExportTable::Column& col : table.columns) {
const std::string base = sanitise(col.name);
std::string label = base;
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
label = base + "_" + std::to_string(n);
}
taken.push_back(label);
out.push_back(std::move(label));
}
return out;
}
struct File {
explicit File(const std::string& path) : f(std::fopen(path.c_str(), "wb")) {}
~File() {
if (f != nullptr) std::fclose(f);
}
File(const File&) = delete;
File& operator=(const File&) = delete;
std::FILE* f = nullptr;
};
bool opened(const File& out, const std::string& path, std::string& error) {
if (out.f != nullptr) return true;
error = "cannot write '" + path + "': " + std::strerror(errno);
return false;
}
} // namespace
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
ExportTable table;
const RowSet::Row& r0 = rows.row(0);
if (r0.plot == nullptr) return table;
const plot::Range view = r0.plot->x_range(r0.axis_x);
// Which channels: every bound slot on a visible row, de-duplicated, then sorted. A
// signal dropped on two rows is one column, and channel order is the signal list's
// order, so the columns read in the same order as the panel.
std::vector<int> chans;
for (int i = 0; i < rows.row_count(); ++i) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const int c = rows.channel_of(i, s);
if (c >= 0 && std::find(chans.begin(), chans.end(), c) == chans.end()) {
chans.push_back(c);
}
}
}
std::sort(chans.begin(), chans.end());
if (chans.empty()) return table;
const std::vector<ChannelInfo>& infos = model.channels();
const auto name_of = [&](int c) {
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
: std::string("ch") + std::to_string(c);
};
// What the rows bind to is what gets exported, which is the same branch `RowSet::bind()`
// takes: the frozen event in trigger mode, the live ring otherwise. Reading the ring
// while an event is on screen would export data the user cannot see.
std::shared_ptr<const EventSnapshot> snap = model.event();
if (model.trigger_enabled() && snap != nullptr) {
// The snapshot is immutable and already index-aligned, so the visible window is two
// binary searches over its stamps and the same index range for every channel.
const std::vector<f32>& st = snap->stamps;
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
if (hi <= lo) return table;
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
const std::size_t n = static_cast<std::size_t>(hi - lo);
// The event's x = 0 is the edge, so the stream clock has to be added back for the
// second column -- the one case where the visible x is not the stream's own.
const f64 edge = model.trigger_time().load(std::memory_order_relaxed);
table.time.resize(n);
table.stream_time.resize(n);
for (std::size_t i = 0; i < n; ++i) {
table.time[i] = static_cast<f64>(st[first + i]);
table.stream_time[i] = edge + table.time[i];
}
for (const int c : chans) {
const std::size_t ch = static_cast<std::size_t>(c);
if (ch >= snap->values.size()) continue;
const std::vector<f32>& src = snap->values[ch];
if (src.size() < first + n) continue;
table.columns.push_back({name_of(c), std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
}
return table;
}
// Live: one absolute index range over the shared time ring, used for every channel --
// `live_window_locked()` is the same cut a latch makes, so an export and a capture of
// the same window hold the same samples. The lock is against a structural change
// dropping the rings under the copy, which is what the UI thread has to fear here.
std::lock_guard<std::mutex> lock(model.mutex());
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
// what keeps this correct rather than merely true today.
const f64 epoch = model.display_epoch();
u64 first = 0, last = 0;
model.live_window_locked(view.min - epoch, view.max - epoch, first, last);
const u64 n = last - first;
if (n == 0) return table;
BufferStore& store = model.live();
std::vector<f32> stamps(static_cast<std::size_t>(n));
store.time().copy_from(first, n, stamps.data());
table.time.resize(static_cast<std::size_t>(n));
table.stream_time.resize(static_cast<std::size_t>(n));
for (std::size_t i = 0; i < stamps.size(); ++i) {
table.stream_time[i] = static_cast<f64>(stamps[i]);
table.time[i] = epoch + table.stream_time[i];
}
for (const int c : chans) {
const std::size_t ch = static_cast<std::size_t>(c);
if (ch >= model.channel_count()) continue;
std::vector<f32> values(static_cast<std::size_t>(n));
store.values(ch).copy_from(first, n, values.data());
table.columns.push_back({name_of(c), std::move(values)});
}
return table;
}
bool write_npy(const std::string& path, const ExportTable& table, std::string& error) {
if (table.empty()) {
error = "nothing to export: no signal is plotted in the visible range";
return false;
}
const std::vector<std::string> labels = column_labels(table);
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
// only inserts alignment padding when asked to (`align=True`) -- so the records below
// are field widths back to back with no gaps.
std::string descr = "[('time', '<f8'), ('stream_time', '<f8')";
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
descr += "]";
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
std::to_string(table.rows()) + ",), }";
// v1.0: a 6-byte magic, 2 version bytes and a 2-byte little-endian header length, and
// the whole preamble padded so the data starts on a 64-byte boundary.
constexpr std::size_t kPreamble = 10;
const std::size_t unpadded = kPreamble + header.size() + 1;
const std::size_t padded = (unpadded + 63) / 64 * 64;
header.append(padded - unpadded, ' ');
header.push_back('\n');
if (header.size() > 0xFFFFu) {
error = "too many columns for a v1.0 .npy header";
return false;
}
File out(path);
if (!opened(out, path, error)) return false;
const unsigned char len[2] = {static_cast<unsigned char>(header.size() & 0xFFu),
static_cast<unsigned char>((header.size() >> 8) & 0xFFu)};
std::fwrite("\x93NUMPY\x01\x00", 1, 8, out.f);
std::fwrite(len, 1, 2, out.f);
std::fwrite(header.data(), 1, header.size(), out.f);
// One record at a time, into a reused buffer: a column-major staging array would be a
// second copy of the whole export, which at a full ring is tens of megabytes.
std::vector<unsigned char> record(2 * sizeof(f64) + table.columns.size() * sizeof(f32));
for (std::size_t i = 0; i < table.rows(); ++i) {
std::size_t at = 0;
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
at += sizeof(f64);
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
at += sizeof(f64);
for (const ExportTable::Column& col : table.columns) {
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
std::memcpy(record.data() + at, &v, sizeof(f32));
at += sizeof(f32);
}
if (std::fwrite(record.data(), 1, record.size(), out.f) != record.size()) {
error = "short write to '" + path + "': " + std::strerror(errno);
return false;
}
}
if (std::fflush(out.f) != 0) {
error = "cannot flush '" + path + "': " + std::strerror(errno);
return false;
}
return true;
}
bool write_csv(const std::string& path, const ExportTable& table, std::string& error) {
if (table.empty()) {
error = "nothing to export: no signal is plotted in the visible range";
return false;
}
File out(path);
if (!opened(out, path, error)) return false;
const std::vector<std::string> labels = column_labels(table);
std::string line = "time,stream_time";
for (const std::string& label : labels) line += "," + label;
line += "\n";
std::fwrite(line.data(), 1, line.size(), out.f);
// `%.17g` for the f64 times and `%.9g` for the f32 values: the shortest widths that
// round-trip each type exactly. Anything less silently rewrites the samples, which is
// the one thing an export may not do.
char buf[64];
for (std::size_t i = 0; i < table.rows(); ++i) {
line.clear();
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
line += buf;
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
line += ",";
line += buf;
for (const ExportTable::Column& col : table.columns) {
std::snprintf(buf, sizeof(buf), "%.9g",
double(i < col.values.size() ? col.values[i] : 0.0f));
line += ",";
line += buf;
}
line += "\n";
if (std::fwrite(line.data(), 1, line.size(), out.f) != line.size()) {
error = "short write to '" + path + "': " + std::strerror(errno);
return false;
}
}
if (std::fflush(out.f) != 0) {
error = "cannot flush '" + path + "': " + std::strerror(errno);
return false;
}
return true;
}
} // namespace udpscope
+59
View File
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "model.hpp"
#include "plot_host.hpp"
#include <string>
#include <vector>
namespace udpscope {
/// What the plots are showing, as a table: one row per sample of the visible x window,
/// one column per plotted signal.
///
/// The samples are the *stored* ones, never the LOD pyramid's buckets: the pyramid is
/// what makes a million samples drawable in a few hundred pixels, and exporting it would
/// hand back the envelope the renderer drew rather than the acquisition. So an export is
/// at the full rate the ring holds, however far the view is zoomed out.
struct ExportTable {
struct Column {
std::string name; ///< the legend name, sanitised for a column label
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
};
/// The x the plot shows and the crosshair reads: stream seconds in live mode,
/// edge-relative seconds in `[-pre, +post]` while an event is held.
std::vector<f64> time;
/// The stream's own clock, so two exports taken in different modes line up. Equal to
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
/// case where the visible x is not the stream's.
std::vector<f64> stream_time;
std::vector<Column> columns;
std::size_t rows() const noexcept { return time.size(); }
bool empty() const noexcept { return columns.empty() || time.empty(); }
};
/// Collects every signal bound to a visible row, over the shared x window, at the stored
/// rate. A signal on several rows is one column; the columns come out in channel order,
/// which is the signal list's order.
///
/// Takes `model.mutex()` for the live cut, so call it from the GTK thread like any other
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
ExportTable collect_export(ScopeModel& model, RowSet& rows);
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
/// about half the size of an all-`f8` one and invents no precision.
///
/// `pd.DataFrame(np.load(path))` is the whole reader.
bool write_npy(const std::string& path, const ExportTable& table, std::string& error);
/// Writes the same table as CSV: a header row of column names, then `%.*g` fields --
/// enough digits to round-trip each column's own type and no more.
bool write_csv(const std::string& path, const ExportTable& table, std::string& error);
} // namespace udpscope
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "acquisition.hpp"
#include "config.hpp"
#include "model.hpp"
#include "plot_host.hpp"
#include "ui.hpp"
#include <cstdio>
#include <exception>
int main(int argc, char** argv) {
udpscope::Config cfg;
try {
cfg = udpscope::parse_args(argc, argv);
} catch (const std::exception& e) {
std::fprintf(stderr, "udpscope: %s\n", e.what());
return 2;
}
udpscope::ScopeModel model(cfg);
udpscope::RowSet rows;
rows.set_row_count(cfg.plots);
udpscope::Acquisition acquisition(model, cfg);
try {
acquisition.start();
} catch (const std::exception& e) {
std::fprintf(stderr, "udpscope: %s\n", e.what());
return 1;
}
const int result = udpscope::run_ui(argc, argv, model, acquisition, rows);
acquisition.stop();
return result;
}
+378
View File
@@ -0,0 +1,378 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "model.hpp"
#include <udps_client.h>
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <string>
#include <unordered_map>
namespace udpscope {
namespace {
u64 pow2_floor(u64 v) {
if (v == 0) return 0;
u64 p = 1;
while (p <= v / 2) p <<= 1;
return p;
}
} // namespace
u64 Partition::total_samples() const noexcept {
u64 total = time_capacity;
for (u64 c : value_capacity) total += c;
return total;
}
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes) {
Partition p;
const std::size_t n = channel_dt.size();
p.stride.assign(n, 1);
p.value_capacity.assign(n, 0);
if (n == 0) return p;
// The fastest channel drives the shared time base: every other channel is
// decimated by the integer ratio of its own interval to that one.
double base_dt = 0.0;
for (double dt : channel_dt) {
if (dt > 0.0) base_dt = base_dt > 0.0 ? std::min(base_dt, dt) : dt;
}
// No declared rate: assume 1 kHz rather than one sample per second, so a
// stream that only says "here are samples" still gets a sane time scale and a
// sane first frame in `Acquisition::build_stamps()`.
if (!(base_dt > 0.0)) base_dt = 1.0e-3;
p.base_dt = base_dt;
// No per-channel decimation, and none on the shared clock either. A stride of
// `round(dt_i / dt_fastest)` throws away all but one sample per stride, and when
// that stride equals the signal's own period -- a 2 kHz sine on a 1 MHz clock,
// stride 500 -- every kept sample sits at the same phase: the channel plots as a
// flat line. Decimating the shared clock to make a 600 s window fit did the same
// to *every* channel. The LOD min/max pyramid is what preserves the extremes at
// draw time, and it needs the real samples; the buffer simply holds as many as
// it holds, and the window dropdown is capped to that span.
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
p.time_stride = 1;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
const u64 budget = budget_bytes / sizeof(f32);
double weight = 1.0; // the time slice itself
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s);
const u64 slots = budget / static_cast<u64>(std::llround(weight));
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
if (time_capacity == 0) {
throw std::runtime_error("buffer too small for one time-base sample per channel");
}
// The target span sets a floor, not a ceiling: the rest of the budget buys a
// longer ring, which is strictly more history to scroll back through. Growth
// stops before the total would exceed the budget. A channel's slots are rounded
// *up* on the power of two `RingBuffer` will round them to anyway, so every
// channel covers at least the time ring's span.
// Floor, not ceiling: a channel's window must not be *longer* than the time ring
// it reads its stamps from. Rounding up (with a stride that is not a power of two)
// gave a channel more samples than the shared timeline covers, and the oldest of
// them referenced time slots the ring had already overwritten -- they were then
// stamped with the epoch, a plausible-looking coordinate that collapsed the trace
// against the axis. Flooring can leave a channel holding as little as ~62% of the
// time ring's span, which is a shorter history, not a wrong one.
const auto capacity_for = [&](u64 ticks, u64 stride) {
return std::max<u64>(pow2_floor(ticks / stride), 1);
};
const auto bytes_for = [&](u64 ticks) {
u64 total = ticks;
for (u64 s : p.stride) total += capacity_for(ticks, s);
return total * sizeof(f32);
};
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) {
time_capacity *= 2;
}
p.time_capacity = time_capacity;
for (std::size_t i = 0; i < n; ++i) {
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
}
p.span_s = static_cast<double>(p.time_capacity) * static_cast<double>(p.time_stride) * base_dt;
return p;
}
// --- BufferStore ------------------------------------------------------------
BufferStore::BufferStore(const Partition& part, f64 span_s)
: part_(part),
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) {
(void)span_s;
const std::size_t n = part.value_capacity.size();
values_.reserve(n);
channels_.reserve(n);
for (std::size_t i = 0; i < n; ++i) {
values_.push_back(std::make_shared<plot::data::RingBuffer<f32>>(part.value_capacity[i]));
// `ChannelBuffer::dx()` multiplies this by the channel's stride, so the
// value here is the *slot* interval: stride * slot_dt is the real sample
// interval at the decimation the partition chose.
const double dt = part.slot_dt();
channels_.push_back(std::make_shared<ChannelBuffer>(values_[i], time_, part.stride[i], 0.0,
dt));
}
}
void BufferStore::acquire_all() {
for (auto& c : channels_) c->acquire();
}
// --- ScopeModel -------------------------------------------------------------
ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
trigger_.holdoff_s = cfg.trigger_holdoff_s;
}
void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
std::lock_guard<std::mutex> lock(mutex_);
// Keep the enable state across CONFIG updates for signals that kept their name.
std::unordered_map<std::string, bool> was_enabled;
for (std::size_t i = 0; i < signals_.size(); ++i) {
was_enabled.emplace(signals_[i].name, i < enabled_.size() ? enabled_[i] : false);
}
signals_ = std::move(signals);
enabled_.assign(signals_.size(), false);
for (std::size_t i = 0; i < signals_.size(); ++i) {
auto it = was_enabled.find(signals_[i].name);
// Off until the user enables it (spec: the signal panel's switch is what
// starts acquisition of a signal). A later CONFIG keeps whatever the user
// chose for a signal that kept its name.
enabled_[i] = it != was_enabled.end() ? it->second : false;
}
rebuild_channels();
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
}
void ScopeModel::rebuild_channels() {
channels_.clear();
for (std::size_t s = 0; s < signals_.size(); ++s) {
const SignalInfo& sig = signals_[s];
if (s >= enabled_.size() || !enabled_[s]) continue;
if (is_time_signal(signals_, s)) continue;
ChannelInfo ch;
ch.signal = s;
ch.enabled = true;
// A UDPStreamer signal is one channel: its columns are consecutive samples of
// that channel within a DATA packet (1x1000 is 1000 samples of the same
// signal, not 1000 signals). The worker client hands a multi-element signal
// exactly `elements` values per packet, which is the block of samples.
ch.name = sig.name;
ch.unit = sig.unit;
ch.elements = sig.elements();
ch.dt = sig.sampling_rate > 0.0 ? 1.0 / sig.sampling_rate : 0.0;
channels_.push_back(std::move(ch));
}
}
void ScopeModel::rebuild_locked() {
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
if (dts.empty()) {
part_ = Partition{};
store_.reset();
return;
}
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0);
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
}
bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
std::size_t signal) {
if (signal >= signals.size()) return false;
const SignalInfo& s = signals[signal];
// Referenced by name: another signal declares this one as its time base.
for (const SignalInfo& other : signals) {
if (other.time_signal_idx == signal) return true;
}
// A full-array signal that declares **no sample rate** is the stream's timestamp
// signal: it has stamps but no cadence of its own, because it is not data.
// Data channels can also be declared full-array (per-element stamps), and they
// keep a declared rate -- treating them as time signals hid every real channel.
if (s.time_mode == UDPS_TIME_FULL_ARRAY && s.elements() > 1 && s.sampling_rate == 0.0) {
return true;
}
return false;
}
bool ScopeModel::signal_enabled(std::size_t signal) const {
return signal < enabled_.size() && enabled_[signal];
}
void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal >= enabled_.size() || enabled_[signal] == on) return;
enabled_[signal] = on;
// The trigger names a channel by its display name, which is stable across
// renumbering: when its own signal is disabled the channel vanishes (the trigger
// then waits, and comes back when the signal is re-enabled).
const int old_trigger = trigger_.channel;
const std::string trigger_name =
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
? channels_[static_cast<std::size_t>(old_trigger)].name
: std::string();
rebuild_channels();
trigger_.channel = -1;
if (!trigger_name.empty()) {
for (std::size_t i = 0; i < channels_.size(); ++i) {
if (channels_[i].name == trigger_name) trigger_.channel = static_cast<int>(i);
}
}
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
}
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
std::lock_guard<std::mutex> lock(mutex_);
bool any = false;
for (ChannelInfo& c : channels_) {
if (c.signal != signal) continue;
c.gain = gain;
c.offset = offset;
any = true;
}
// Calibration is applied at ingest, so the buffers are rebuilt to avoid mixing
// calibrated and uncalibrated samples (and stale LOD aggregates).
if (any) {
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
}
}
double ScopeModel::channel_dt(std::size_t channel) const {
return channel < channels_.size() ? channels_[channel].dt : 0.0;
}
BufferStore& ScopeModel::live() { return *store_; }
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
BufferStore& src = live();
plot::data::RingBuffer<f32>& tsrc = src.time();
// The slice is one absolute index range, found in the time ring and then shared by
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
// the same length and index-aligned *by construction*, instead of by two independent
// truncations that could disagree.
const u64 tbase = tsrc.produced() - tsrc.size();
const u64 tend = tsrc.produced();
// The bounds are rounded to the ring's own `f32` before they are compared against
// it. The window arrives in f64 and the stamps are stored as f32, so an f64
// comparison decides a sample that sits exactly on a bound by which way its stamp
// happened to round -- the same nominal window then keeps or drops its first sample
// depending on how large the stream time is (`f32(38.001) < 38.001` but
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a
// sample include it, always.
const f32 t_lo = static_cast<f32>(lo_s);
const f32 t_hi = static_cast<f32>(hi_s);
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
// stamps is the whole point: the window is in seconds and this ring is the only
// thing that knows how many samples a second of this stream holds.
u64 a = tbase, b = tend;
while (a < b) {
const u64 m = a + (b - a) / 2;
if (tsrc[m - tbase] < t_lo) {
a = m + 1;
} else {
b = m;
}
}
first = a;
b = tend;
while (a < b) {
const u64 m = a + (b - a) / 2;
if (tsrc[m - tbase] <= t_hi) {
a = m + 1;
} else {
b = m;
}
}
last = a;
for (std::size_t c = 0; c < channels_.size(); ++c) {
plot::data::RingBuffer<f32>& from = src.values(c);
first = std::max(first, from.produced() - from.size());
last = std::min(last, from.produced());
}
if (last < first) last = first;
}
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
auto snap = std::make_shared<EventSnapshot>();
BufferStore& src = live();
plot::data::RingBuffer<f32>& tsrc = src.time();
u64 first = 0, last = 0;
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
const u64 n = last - first;
snap->stamps.resize(static_cast<std::size_t>(n));
if (n > 0) {
tsrc.copy_from(first, n, snap->stamps.data());
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
// edge at exactly 0, with no axis-level shift any other code path could
// disagree with.
for (auto& t : snap->stamps) t -= static_cast<f32>(edge);
}
snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
for (std::size_t c = 0; c < channels_.size(); ++c) {
auto& vals = snap->values[c];
vals.resize(static_cast<std::size_t>(n));
if (n > 0) src.values(c).copy_from(first, n, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo;
for (f32 v : vals) {
lo = std::min(lo, v);
hi = std::max(hi, v);
}
snap->y[c] = plot::Range{lo, hi};
}
// The span is read off the stamps that were actually captured, never off the
// request: the ring may not reach back a full `pre_s`, and the producer stops the
// capture at the first sample past `post_s` rather than exactly on it.
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front());
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back());
event_.store(std::move(snap), std::memory_order_release);
}
void BufferStore::set_epoch(f64 epoch) noexcept {
for (auto& c : channels_) c->set_epoch(epoch);
}
void ScopeModel::set_display_epoch(f64 e) noexcept {
display_epoch_.store(e, std::memory_order_relaxed);
if (store_) store_->set_epoch(e);
}
void ScopeModel::acquire_all() {
if (store_) store_->acquire_all();
}
void ScopeModel::set_trigger_enabled(bool on) {
std::lock_guard<std::mutex> lock(mutex_);
trigger_.enabled = on;
trigger_enabled_.store(on, std::memory_order_relaxed);
}
void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
if (epoch_ == 0.0) epoch_ = e;
}
} // namespace udpscope
+297
View File
@@ -0,0 +1,297 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "channel.hpp"
#include "config.hpp"
#include "event_series.hpp"
#include "trigger.hpp"
#include <plot/data/ring_buffer.hpp>
#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace udpscope {
/// One streamed signal, as the CONFIG packet described it.
struct SignalInfo {
std::string name;
std::string unit;
std::uint8_t type_code = 0;
std::uint8_t quant_type = 0;
std::uint32_t rows = 1;
std::uint32_t cols = 1;
double range_min = 0.0;
double range_max = 1.0;
std::uint8_t time_mode = 0;
double sampling_rate = 0.0;
std::uint32_t time_signal_idx = 0xFFFFFFFFu;
std::uint32_t elements() const noexcept {
const std::uint32_t n = rows * cols;
return n == 0 ? 1u : n;
}
};
/// One plottable signal element: a signal with a single element, or one element
/// of a vector/matrix signal. The unit of enable/disable is the *signal*; the
/// unit of acquisition and plotting is the channel.
struct ChannelInfo {
std::size_t signal = 0;
bool enabled = false; ///< whether its signal is enabled
double gain = 1.0; ///< calibration, applied as sample * gain + offset
double offset = 0.0;
std::string name; ///< the signal's name
std::string unit;
/// Samples of this channel per DATA packet (`elements()` of the signal). A
/// UDPStreamer signal is one channel: its columns are consecutive samples of the
/// same signal, not separate channels.
std::uint32_t elements = 1;
/// Sample interval of this channel's own cadence, in seconds. 0 when unknown.
double dt = 0.0;
};
/// How one buffer's storage is divided between the shared time base and the
/// channels (spec item 2: "split in N+1", the +1 being the time base).
///
/// The split is by *time span*, not by bytes: every channel's ring and the time
/// ring cover the same amount of stream time, which is what makes a shared time
/// base meaningful. When no channel is decimated (the common case) the two rules
/// coincide and every slice is the same size.
struct Partition {
u64 time_capacity = 0; ///< time-base slots retained
u64 time_stride = 1; ///< base ticks per time-base slot (reserved; 1)
std::vector<u64> stride; ///< time slots per channel sample (>= 1)
std::vector<u64> value_capacity; ///< samples retained per channel
double base_dt = 1.0; ///< seconds per base tick
double span_s = 0.0; ///< stream seconds one buffer covers
u64 total_samples() const noexcept;
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
/// Seconds between two time-base slots (the sample interval of every channel).
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
};
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, targeting a
/// span of `target_span_s` seconds. Throws std::runtime_error when the budget
/// cannot hold even one sample per channel.
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes);
/// One of the two identical sample buffers: a shared time-base ring plus one
/// value ring and one `ChannelBuffer` per channel.
class BufferStore {
public:
BufferStore(const Partition& part, f64 span_s);
/// Re-bases every channel's x (see `ChannelBuffer::set_epoch`). Used to make the
/// axis read seconds from the trigger event.
void set_epoch(f64 epoch) noexcept;
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
/// Shared handle to a channel view: the caller keeps the storage alive even if
/// the model rebuilds.
std::shared_ptr<ChannelBuffer> shared_channel(std::size_t channel) {
return channels_[channel];
}
const Partition& partition() const noexcept { return part_; }
/// Consumer: publishes every channel's new samples.
void acquire_all();
private:
Partition part_;
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
};
/// Live connection and counter state, readable from the UI thread.
struct StreamStats {
std::atomic<std::uint64_t> packets{0};
std::atomic<std::uint64_t> frames{0};
std::atomic<std::uint64_t> gaps{0};
std::atomic<std::uint64_t> reconnects{0};
std::atomic<bool> connected{false};
std::atomic<bool> have_config{false};
};
/// The application's data model: the stream description, the two sample buffers,
/// the trigger configuration and the counters. Structural changes (a CONFIG
/// update, an enable/disable, a calibration) rebuild the buffers, so all mutating
/// access is serialised on one mutex; the acquisition thread takes it once per
/// DATA frame.
class ScopeModel {
public:
explicit ScopeModel(const Config& cfg);
const Config& config() const noexcept { return cfg_; }
// --- stream description ---------------------------------------------------
/// Takes a new CONFIG: rebuilds the signal and channel lists and the two
/// buffers. Clears the trigger's channel selection if the stream changed.
void set_signals(std::vector<SignalInfo> signals);
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
bool signal_enabled(std::size_t signal) const;
/// True when the signal is the stream's time base, i.e. when some other signal
/// declares it via `time_signal_idx`. A signal used as a time base builds the x
/// axis and is not a plottable channel.
static bool is_time_signal(const std::vector<SignalInfo>& signals,
std::size_t signal);
void set_signal_enabled(std::size_t signal, bool on);
void set_calibration(std::size_t signal, double gain, double offset);
std::size_t channel_count() const noexcept { return channels_.size(); }
const Partition& partition() const noexcept { return part_; }
/// The channel's sample interval (0 when the stream did not say).
double channel_dt(std::size_t channel) const;
// --- buffers --------------------------------------------------------------
/// The one sample store: the producer always writes it, and the plot draws from
/// it whenever it is not showing a frozen event. Always-filling is what
/// guarantees a full pre-trigger history on the *first* latch after the trigger
/// is armed.
BufferStore& live();
/// Builds the captured event from the slice of the live buffer covering
/// `[edge - pre_s, edge + post_s]` in stream seconds, and publishes it atomically.
/// The stamps are re-based so the edge sits at x = 0. The plot binds to this
/// immutable snapshot -- it is the "frozen buffer" -- while the live ring keeps
/// filling behind it.
///
/// The cut is by time, not by a sample count: the window the trigger panel asks
/// for is in seconds and the time ring stores seconds, so any count in between
/// needs a cadence estimate -- which is exactly what used to size the capture
/// wrong.
void publish_event(double pre_s, double post_s, double edge);
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
/// clamped to what the time ring and *every* value ring still hold. One range for the
/// time ring and all the channels, which is what keeps a cut index-aligned by
/// construction; the bounds are compared in the ring's own `f32`, so a bound that
/// lands on a sample includes it whatever the magnitude of the stream time.
///
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
/// The event on screen, or null before the first latch.
std::shared_ptr<const EventSnapshot> event() const noexcept {
return event_.load(std::memory_order_acquire);
}
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
void acquire_all();
// --- trigger --------------------------------------------------------------
/// The trigger configuration. Mutating it means holding `mutex()`: the producer
/// reads it once per frame (and writes `armed` itself when a single-shot latch
/// disarms), so an unguarded field write can be lost or read half-updated. The
/// panel's callbacks take the lock for the fields they touch.
TriggerConfig& trigger() noexcept { return trigger_; }
const TriggerConfig& trigger() const noexcept { return trigger_; }
/// Trigger mode on/off, as both the frame tick and the producer read it: the one
/// trigger field the tick needs and the only one written from the UI, so it has
/// an atomic mirror and a setter that keeps the copy under the lock in step.
bool trigger_enabled() const noexcept {
return trigger_enabled_.load(std::memory_order_relaxed);
}
void set_trigger_enabled(bool on);
/// Whether the engine is mid-capture (an edge was seen and the post-trigger
/// window is still filling). Purely informational: it drives the status line's
/// wording. The plot deliberately does *not* react to it -- in trigger mode the
/// picture changes only on a latch (SPECS: "data visualisation should not be
/// refreshed till next event").
bool trigger_pending() const noexcept {
return trigger_pending_.load(std::memory_order_relaxed);
}
void set_trigger_pending(bool on) noexcept {
trigger_pending_.store(on, std::memory_order_relaxed);
}
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
/// the trigger instant while in trigger mode, so the axis reads seconds from the
/// event. Applied to the live store's channels.
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
void set_display_epoch(f64 e) noexcept;
// --- stream state ---------------------------------------------------------
StreamStats& stats() noexcept { return stats_; }
const StreamStats& stats() const noexcept { return stats_; }
/// Seconds since the epoch of the streamed time, or 0 before the first frame.
f64 epoch() const noexcept { return epoch_; }
void set_epoch_if_unset(f64 e) noexcept;
/// Bumped whenever the signal or channel lists change, so the UI knows to
/// rebuild the panels it derives from them.
unsigned generation() const noexcept { return generation_.load(std::memory_order_acquire); }
/// The length of the event the trigger latches, in seconds: the time window the
/// toolbar selects. The pre-trigger percentage is a share of *this*, not of the
/// whole ring, which is what makes the control mean something on a long buffer.
void set_event_span(double seconds) noexcept {
event_span_.store(seconds > 0.0 ? seconds : 1.0, std::memory_order_relaxed);
}
double event_span() const noexcept { return event_span_.load(std::memory_order_relaxed); }
/// Trigger state for the UI, published by the acquisition thread. Atomics
/// rather than fields of `TriggerConfig` because the producer writes the arm
/// state when a single-shot trigger disarms itself, and the frame tick reads it
/// without holding the model mutex.
std::atomic<bool>& trigger_armed() noexcept { return trigger_armed_; }
/// Incremented every time an event is latched, so the UI can tell "waiting for
/// an event" from "an event is on screen".
std::atomic<std::uint64_t>& trigger_latches() noexcept { return trigger_latches_; }
/// The stream time of the edge behind the event on screen, and whether there is
/// one. Written by the acquisition thread on a latch and cleared by the UI when
/// the event stops meaning anything (arming again, or leaving trigger mode).
std::atomic<double>& trigger_time() noexcept { return trigger_time_; }
std::atomic<bool>& trigger_time_valid() noexcept { return trigger_time_valid_; }
/// Serialises a structural change against the acquisition thread.
std::mutex& mutex() noexcept { return mutex_; }
/// Rebuilds buffers from the current channel list. Caller holds `mutex()`.
void rebuild_locked();
private:
void rebuild_channels();
Config cfg_;
std::vector<SignalInfo> signals_;
std::vector<ChannelInfo> channels_;
std::vector<bool> enabled_;
Partition part_;
std::unique_ptr<BufferStore> store_;
TriggerConfig trigger_{};
StreamStats stats_{};
f64 epoch_ = 0.0;
std::atomic<double> event_span_{1.0};
std::atomic<bool> trigger_pending_{false};
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
std::atomic<double> display_epoch_{0.0};
std::atomic<bool> trigger_armed_{false};
std::atomic<bool> trigger_enabled_{false};
std::atomic<std::uint64_t> trigger_latches_{0};
std::atomic<double> trigger_time_{0.0};
std::atomic<bool> trigger_time_valid_{false};
std::atomic<unsigned> generation_{0};
std::mutex mutex_;
};
} // namespace udpscope
+414
View File
@@ -0,0 +1,414 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "plot_host.hpp"
#include <algorithm>
#include <cmath>
namespace udpscope {
namespace {
/// A small qualitative palette, one colour per *signal*. It is indexed by the signal
/// rather than by the trace slot so that a signal plotted on several rows is the same
/// colour in all of them: the signal list's chip is what ties a row of that list to a
/// trace on screen, and a colour that depended on where the signal landed would name a
/// different trace in every row.
constexpr plot::u32 kPalette[] = {
0x4FA3FFu, 0xFF8A4Fu, 0x5FD08Au, 0xE86A9Cu, 0xC9A227u, 0x8F7BFFu, 0x4FD0D0u, 0xFF6B6Bu,
};
constexpr std::size_t kPaletteSize = sizeof(kPalette) / sizeof(kPalette[0]);
/// The chip of a signal that is acquired but on no plot, and of one that is not even
/// acquired. Mid grey: it has to read as "no trace" next to any palette entry.
constexpr plot::u32 kUnplottedColor = 0x6E6E6Eu;
constexpr plot::u32 kCursorAColor = 0xFFD24Fu;
constexpr plot::u32 kCursorBColor = 0x7FE0FFu;
constexpr plot::u32 kRulerColor = 0xB0B0B0u;
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
/// The gap between two rows, in device pixels. The rows used to be separate widgets in
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
/// stacked frames read as one tall plot with a line through it.
constexpr plot::f32 kRowGap = 6.0f;
} // namespace
plot::Color RowSet::palette_color(int index) {
const std::size_t i = static_cast<std::size_t>(index < 0 ? 0 : index) % kPaletteSize;
return plot::Color::hex(kPalette[i]);
}
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
RowSet::RowSet() { rebuild_all(); }
void RowSet::set_row_count(int rows) {
const int want = std::clamp(rows, 1, kMaxRows);
if (want == rows_) return;
rows_ = want;
// A `Figure`'s row count is fixed at construction, so a new count is a new figure.
rebuild_all();
}
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot = &p;
r.axis_x = r.plot->add_x_axis(
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
r.axis_y = r.plot->add_y_axis(
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace(
{&r.slots[i], palette_color(r.color_index[i]), 1.2f, r.axis_x, r.axis_y, r.name[i]});
}
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
r.ruler = r.plot->add_h_ruler(0.0, plot::Color::hex(kRulerColor));
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
// A marker, not an instrument: it must sit exactly on the edge's time, so its
// snap is Free and the controller never rounds it onto a sample.
r.plot->set_cursor_snap(r.trigger_marker, plot::SnapMode::Free);
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
void RowSet::rebuild_all() {
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
figure_->set_row_gap(kRowGap);
x_group_ = figure_->add_x_link();
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
row_dirty_[static_cast<std::size_t>(i)] = false;
if (i >= rows_) {
r.plot = nullptr; // not a row of this figure: nothing to plot into
continue;
}
install(r, i, figure_->plot(static_cast<plot::u32>(i)));
place_cursors(r);
if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
}
figure_->link_x(static_cast<plot::u32>(i), x_group_);
}
if (y_gutter_px_ > 0.0) apply_y_gutter();
// A new figure starts with no window at all, so the one the rows were showing has to
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
// changing the row count while a capture is displayed must not throw the view back
// onto the live edge, which in trigger mode nothing would move it off again until the
// next latch.
if (pinned_) {
pin_event_window(pinned_pre_, pinned_post_);
} else {
set_time_window(window_s_);
}
}
bool RowSet::rebuild_if_dirty() {
bool any = false;
for (int i = 0; i < kMaxRows; ++i) {
if (row_dirty_[static_cast<std::size_t>(i)]) any = true;
}
if (!any) return false;
// One row's legend text changed, which `Plot` cannot express, so its traces have to
// be created again -- and a row belongs to a figure, so the figure is rebuilt. That
// costs the plots' CPU state and nothing else: the host keeps its GL context and is
// handed the new figure.
rebuild_all();
return true;
}
void RowSet::assign(int row_index, int slot, std::string signal_name,
std::string display_name, int color_index) {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
Row& r = row(row_index);
const std::size_t s = static_cast<std::size_t>(slot);
r.signal_name[s] = std::move(signal_name);
r.channel[s] = -1; // re-resolved by the next bind()
r.name[s] = std::move(display_name);
r.color_index[s] = color_index;
row_dirty_[static_cast<std::size_t>(row_index)] = true;
}
bool RowSet::slot_bound(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
}
int RowSet::channel_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return -1;
return row(row_index).channel[static_cast<std::size_t>(slot)];
}
void RowSet::bind(ScopeModel& model) {
const std::vector<SignalInfo>& signals = model.signals();
const std::vector<ChannelInfo>& channels = model.channels();
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.signal_name[si].empty()) {
r.channel[si] = -1;
r.slots[si].bind(nullptr);
continue;
}
// Resolve the stable identity every frame: the channel list is rebuilt (and
// renumbered) whenever a signal is enabled, disabled or re-described, and a
// stored index would then point at a different signal's channel.
int signal_index = -1;
for (std::size_t k = 0; k < signals.size(); ++k) {
if (signals[k].name == r.signal_name[si]) {
signal_index = static_cast<int>(k);
break;
}
}
int channel = -1;
if (signal_index >= 0) {
for (std::size_t c = 0; c < channels.size(); ++c) {
if (static_cast<int>(channels[c].signal) == signal_index) {
channel = static_cast<int>(c);
break;
}
}
}
r.channel[si] = channel;
if (channel < 0) {
// Its signal is present but not acquired: keep the identity (so enabling it
// brings the trace back) and plot nothing meanwhile.
r.slots[si].bind(nullptr);
continue;
}
// In trigger mode the trace shows the captured event and nothing else: the
// last one latched, or nothing at all before the first. Never the live ring,
// not even while the post window is filling -- a frozen picture that only
// changes on an event is the whole point of trigger mode, and binding the
// live ring mid-capture made the plot scroll for most of every event cycle.
const std::size_t ci = static_cast<std::size_t>(channel);
if (model.trigger_enabled()) {
if (auto snap = model.event()) {
r.slots[si].bind_event(std::move(snap), ci);
} else {
r.slots[si].bind(nullptr); // armed, waiting for the first event: blank
}
} else {
r.slots[si].bind(model.live().shared_channel(ci));
}
}
}
}
bool RowSet::live_edge(double& newest) const {
bool any = false;
for (int i = 0; i < rows_; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
double edge = 0.0;
// Follow-independent: it reads the row's streaming traces, so a row whose own axis
// is `Off` (which every member of a link group is) still reports its edge.
if (!r.plot->x_live_edge(r.axis_x, edge)) continue;
newest = any ? std::max(newest, edge) : edge;
any = true;
}
return any;
}
void RowSet::set_time_window(double seconds) {
window_s_ = std::max(0.05, seconds);
pinned_ = false;
if (!figure_) return;
figure_->set_x_link_follow(x_group_, plot::XFollow::Live);
double newest = 0.0;
if (live_edge(newest)) {
// Under a following group the range states the span and -- through its maximum --
// where the window sits behind the live edge, so a maximum *at* the edge is what
// makes the offset zero and parks the window on the newest sample. Asking for
// `{0, window}` here instead would state an offset of a whole epoch.
figure_->set_x_link_range(x_group_, {newest - window_s_, newest});
} else {
figure_->set_x_link_range(x_group_, {0.0, window_s_});
}
}
void RowSet::set_y_auto(int row_index, bool on) {
Row& r = row(row_index);
if (!r.plot) return;
r.plot->set_y_auto_scale(r.axis_y, on);
if (on) r.plot->auto_fit_y(r.axis_y);
}
void RowSet::set_y_range(int row_index, double lo, double hi) {
Row& r = row(row_index);
if (!r.plot) return;
const double a = std::min(lo, hi);
const double b = std::max(lo, hi);
if (!std::isfinite(a) || !std::isfinite(b) || !(b > a)) return;
r.plot->set_y_range(r.axis_y, {a, b});
}
void RowSet::place_cursors(Row& r) {
if (!r.plot) return;
const bool show = r.cursors_visible;
const double a = show ? r.cursor_a_x : kParked;
const double b = show ? r.cursor_b_x : kParked;
const double h = show ? r.ruler_y : kParked;
r.plot->set_cursor_x(r.cursor_a, a);
r.plot->set_cursor_x(r.cursor_b, b);
r.plot->set_cursor_y(r.ruler, h);
}
void RowSet::clamp_to_data() {
if (!figure_) return;
// The rows share one window, so they share one bound: the union of what they hold.
// Taking each row's own would let a row whose signal was enabled a moment ago clamp
// the window every row is looking at.
plot::Range data{};
bool have = false;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (!r.plot) continue;
const plot::data::Series* series = nullptr;
for (int s = 0; s < kSlotsPerRow && series == nullptr; ++s) {
if (!r.signal_name[static_cast<std::size_t>(s)].empty()) {
series = r.slots[static_cast<std::size_t>(s)].bound();
}
}
if (series == nullptr || series->size() < 2) continue;
const plot::Range b = series->x_bounds();
if (!std::isfinite(b.min) || !std::isfinite(b.max) || !(b.max > b.min)) continue;
data = have ? plot::Range{std::min(data.min, b.min), std::max(data.max, b.max)} : b;
have = true;
}
if (!have) return;
const double retained = data.max - data.min;
const double span = figure_->x_link_range(x_group_).span();
if (!(span > 0.0) || !std::isfinite(span)) return;
// The *requested* window is intent, and a buffer that has not filled it yet must not
// be allowed to redefine it. This runs every frame and cannot tell "the user zoomed
// out past the data" from "the ring has only just started filling", and the first
// frames after a signal is enabled hold a couple of hundred samples: clamping to
// those shrank a requested 2 s window to ~0.02 s, and nothing here ever grows a span
// back, so the view stayed pinned to the newest 20 ms of a 6.5 s buffer for the rest
// of the run. That is the loudest form of the "live trace is clipped, only the most
// recent part is visible, data is there because the crosshair reads it" defect -- the
// data was never clipped at all, the window was. Clamping against `max(retained,
// window_s_)` leaves a partly filled window empty on its left, which is what a scope
// that just started should look like.
//
// The span only. A pan is the group's `offset` behind its live edge and the group
// clamps it against what its members retain on every layout, so there is nothing
// here to do about `view.min < data.min` -- and doing it anyway would state a maximum
// away from the live edge, i.e. ask the group to scroll back.
const double limit = std::max(retained, window_s_);
if (span > limit) figure_->set_x_link_range(x_group_, {data.max - limit, data.max});
}
void RowSet::pin_event_window(double pre_s, double post_s) {
window_s_ = std::max(1.0e-3, pre_s + post_s);
pinned_ = true;
pinned_pre_ = pre_s;
pinned_post_ = post_s;
if (!figure_) return;
// The captured event sits at [-pre, +post] with the edge at 0, and the bound series is
// the immutable snapshot. The group must stop following first: Live re-derives the
// window from a live edge, and with nothing streaming it would keep placing the window
// from the last stale edge instead of the event.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
figure_->set_x_link_range(x_group_, {-pre_s, post_s});
}
void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (r.plot) r.plot->auto_fit_y(r.axis_y);
}
Row& first = row(0);
if (!first.plot || !figure_) return;
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and
// stating it through the group is what puts every row on it.
first.plot->auto_fit_x();
const plot::Range fitted = first.plot->x_range(first.axis_x);
const double span = fitted.span();
if (!std::isfinite(span) || !(span > 0.0)) return;
if (pinned_) {
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
// trigger mode has nothing bound to.
window_s_ = span;
figure_->set_x_link_range(x_group_, fitted);
} else {
set_time_window(span);
}
}
void RowSet::note_y_gutter(double need_px) {
double want = std::min(need_px + 6.0, 200.0);
// Reserve room for the widest label the formatter can produce, once, with the
// font the labels are actually drawn in (the embedded font is monospaced, so a
// character count is an exact width).
if (!y_gutter_reserved_) {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (!r.plot) continue;
const plot::render::TextBackend* text = r.plot->text_backend();
if (text == nullptr) continue;
const plot::render::Style& style = r.plot->style();
const double advance = double(text->measure("0123456789")) / 10.0;
const double pad = double(style.device(style.grid_label_pad));
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
y_gutter_reserved_ = true;
break;
}
}
if (want > y_gutter_px_) y_gutter_px_ = want;
}
void RowSet::apply_y_gutter() {
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
}
void RowSet::set_trigger_marker(double x, bool visible) {
trigger_marker_on_ = visible;
trigger_marker_x_ = x;
const double want = visible ? x : kParked;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// Re-assert against what the plot *holds*, not against a cached value: the
// marker is a cursor, so the input layer will happily drag it, and a marker the
// user can move off the trigger instant is not marking anything. Comparing
// with the plot also keeps the steady state write-free (a write invalidates the
// plot's cached measurements).
if (r.plot->cursor(r.trigger_marker).x == want) continue;
r.plot->set_cursor_x(r.trigger_marker, want, plot::CursorPlacement::Free);
}
}
void RowSet::set_cursors_visible(bool on) {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
r.cursors_visible = on;
place_cursors(r);
}
}
void RowSet::reset_cursors() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
const plot::Range xr = r.plot->x_range(r.axis_x);
const double span = xr.span() > 0.0 ? xr.span() : window_s_;
r.cursor_a_x = xr.min + 0.25 * span;
r.cursor_b_x = xr.min + 0.75 * span;
r.ruler_y = r.plot->y_range(r.axis_y).min +
0.5 * (r.plot->y_range(r.axis_y).span() > 0.0 ? r.plot->y_range(r.axis_y).span()
: 1.0);
place_cursors(r);
}
}
} // namespace udpscope
+215
View File
@@ -0,0 +1,215 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "model.hpp"
#include <plot/plot/figure.hpp>
#include <plot/plot/plot.hpp>
#include <array>
#include <memory>
#include <string>
namespace udpscope {
/// The stacked plots (spec item 4).
///
/// The rows are the rows of **one `plot::Figure`**, rendered by one
/// `plot::gtk4::PlotArea` in figure mode. The figure owns everything the rows have
/// to agree on, which is why it is one figure and not N plots:
///
/// - the x window, through a single x link group every row joins. A gesture on any
/// row changes that row's range, the group adopts it and pushes it to the others
/// (`Figure::sync_x_links()`, run from `layout()`), so there is no "which row
/// acted" to work out and nothing to propagate by hand;
/// - the live follow, which is the *group's* (`set_x_link_follow`). A member's own
/// axis must be `XFollow::Off` -- `Figure::link_x()` refuses a `Live` member --
/// because a following axis would reinterpret the pushed range as its own span and
/// offset behind its own edge, which is per-row drift by construction;
/// - the left gutter, so a vertical feature lands in the same pixel column in every
/// row (`Figure::layout()` lays each row out twice and imposes the maximum);
/// - the crosshair, placed on every row at one x by `PlotArea`'s figure-mode sync.
///
/// A row's `Plot` cannot drop a trace, so every row carries a fixed set of trace
/// slots created up front; assigning a signal to a slot is a rebind of that slot's
/// `SlotSeries`, and only a *name* change -- which `Plot` cannot express -- forces a
/// rebuild (see `row_dirty()`). A rebuild is of the whole figure: `Figure`'s row
/// count is fixed at construction, so there is one path for "a row's traces changed"
/// and "the row count changed", and it costs no GL state -- the area keeps its
/// context and is simply handed the new figure.
class RowSet {
public:
static constexpr int kMaxRows = 4;
static constexpr int kSlotsPerRow = 4;
RowSet();
struct Row {
/// The figure's row, borrowed. Null for a row beyond `row_count()`, which is how
/// every loop here and in the UI stays inside the rows that exist. Invalidated by
/// `rebuild_all()`, which builds a new figure.
plot::Plot* plot = nullptr;
std::array<SlotSeries, kSlotsPerRow> slots;
/// Model channel per trace slot. Must start at -1: `std::array<Row, N>{}`
/// zero-initialises a `Row`, and a zero here reads as "slot 0 already holds
/// channel 0", which makes every drop look like the row is full.
/// What the slot plots, as a *stable identity*: a channel index is positional,
/// and enabling/disabling a signal or a new CONFIG renumbers the channel list,
/// so an index would silently start plotting a different signal. Empty name =
/// free slot.
std::array<std::string, kSlotsPerRow> signal_name;
/// The channel index `signal_name`/`element` resolved to, filled by `bind()`.
std::array<int, kSlotsPerRow> channel = {-1, -1, -1, -1};
/// The trace's legend name (the channel's display name), which is what the
/// plot was built with.
std::array<std::string, kSlotsPerRow> name;
/// The palette entry the slot's trace is drawn in. A *signal's* index, not the
/// slot's: a signal dropped on two rows has to be the same colour in both, or the
/// signal list's colour chip cannot name a trace. Supplied by the caller, because
/// only it knows the signal list this indexes into. A trace's colour is fixed at
/// `add_trace()`, which is why this is stored rather than resolved per frame --
/// changing it needs a rebuild, and `assign()` already forces one.
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
plot::u32 axis_x = 0;
plot::u32 axis_y = 0;
plot::u32 cursor_a = 0;
plot::u32 cursor_b = 0;
plot::u32 ruler = 0;
/// The vertical line marking the trigger instant, drawn whenever an event is
/// on screen (and parked outside the axis otherwise). Not the user's cursor:
/// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0;
/// Off by default: the cursors are switched on from the toolbar, and a rebuild
/// (any drop, any row-count change) must not switch them back off.
bool cursors_visible = false;
double cursor_a_x = 0.0;
double cursor_b_x = 0.0;
double ruler_y = 0.0;
};
int row_count() const noexcept { return rows_; }
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
/// count is fixed at construction.
void set_row_count(int rows);
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
/// The figure the rows live in. The host must be handed this again after every
/// `rebuild_all()`.
plot::Figure& figure() noexcept { return *figure_; }
/// Builds a new figure: one `Plot` per visible row with its axes, cursors and fixed
/// trace slots, all joined by one x link group.
void rebuild_all();
/// Rebuilds if any row's traces changed, and reports whether it did -- in which case
/// the caller re-points its host at `figure()`.
bool rebuild_if_dirty();
bool row_dirty(int i) const { return row_dirty_[static_cast<std::size_t>(i)]; }
/// Binds a row's trace slot to a signal element (`signal_name` empty frees it).
/// `color_index` is the signal's palette entry (see `Row::color_index`).
void assign(int row, int slot, std::string signal_name, std::string display_name,
int color_index);
/// Whether a slot plots anything.
bool slot_bound(int row, int slot) const;
/// The channel index a row/slot holds, or -1.
int channel_of(int row, int slot) const;
/// Rebinds every slot to what it should show this frame: the model's live buffer,
/// or -- in trigger mode -- the captured event. Called every frame, because the
/// channel list is renumbered by any structural change.
void bind(ScopeModel& model);
/// Publishes the live buffer's new samples.
void acquire(ScopeModel& model) { model.acquire_all(); }
/// Frames the newest `seconds` of the stream: the link group follows the live edge,
/// with its window ending on that edge (which is what resets any pan the user had).
void set_time_window(double seconds);
double time_window() const noexcept { return window_s_; }
void set_y_auto(int row, bool on);
void set_y_range(int row, double lo, double hi);
void set_cursors_visible(bool on);
void reset_cursors();
/// Places the trigger marker at stream time `x` (seconds since the epoch), or
/// parks it when there is no event on screen.
void set_trigger_marker(double x, bool visible);
/// Keeps the shared x window inside the data the rings actually hold: zooming out
/// stops at the whole retained span. A time window is a view of the acquisition, so
/// there is nothing to see outside it -- zooming into empty time only loses the data
/// off the edge of the plot. Only the *span*: a following group clamps its own pan
/// against what its members retain (`Figure::place_live_window()`), while a wheel
/// notch multiplies the span and nothing in the library bounds that.
void clamp_to_data();
/// Pins the shared x window to the captured event: `[-pre, +post]` with the edge at
/// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s);
/// Fits every row's y axis to the data it holds, and re-frames the shared x window
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
/// actually on screen.
void fit_all();
/// Reports the left gutter the rows currently need, and reserves at least the width
/// of the widest *plausible* y label.
///
/// The gutter is measured from the y tick labels, and `TickFormat::Si` makes their
/// width change as the auto-fitted range moves (`1.5` becomes `15`, `150`, `1.5k`),
/// so a gutter that follows them drags the whole axes -- and with it the x axis --
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
/// floor and the library still takes the larger of the two, a floor smaller than
/// the labels cannot stop that; the reservation therefore covers the widest label
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
/// Both reservations only ever grow, so the gutter is constant in practice.
void note_y_gutter(double need_px);
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
/// frame is drawn.
///
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
/// written onto the rows survives exactly until the next layout.
void apply_y_gutter();
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
/// trace is drawn in is the colour the signal list's chip shows, on every row the
/// signal appears in.
static plot::Color palette_color(int index);
/// The colour of a signal that is on no plot, which the signal list shows instead of
/// leaving its chip blank -- a blank chip is a narrower cell than a filled one, so the
/// names beside it shifted sideways as signals were placed.
static plot::Color unplotted_color();
private:
void install(Row& r, int row_index, plot::Plot& p);
void place_cursors(Row& r);
/// The newest x any row's live traces hold, which is what the group follows.
bool live_edge(double& newest) const;
std::unique_ptr<plot::Figure> figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1.
plot::u32 x_group_ = 0;
int rows_ = 1;
double window_s_ = 2.0;
double trigger_marker_x_ = 0.0;
bool trigger_marker_on_ = false;
/// The event window currently pinned, so a rebuild can restate it instead of
/// re-framing on the live edge.
double pinned_pre_ = 0.0;
double pinned_post_ = 0.0;
bool pinned_ = false;
double y_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
};
} // namespace udpscope
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "trigger.hpp"
#include <algorithm>
namespace udpscope {
double TriggerConfig::post_span_s(double buffer_span_s) const {
const double pre = std::clamp(pretrigger_pct, 0.0, 100.0) / 100.0;
return buffer_span_s * (1.0 - pre);
}
void TriggerEngine::configure(const TriggerConfig& cfg, double post_seconds,
double pre_seconds, double oldest_s) {
const bool channel_changed = cfg.channel != cfg_.channel;
cfg_ = cfg;
post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
oldest_s_ = oldest_s;
if (channel_changed) {
have_prev_ = false;
pending_ = false;
post_accum_count_ = 0;
}
}
void TriggerEngine::arm(double now_s) noexcept {
armed_ = true;
pending_ = false;
post_accum_count_ = 0.0;
holdoff_until_s_ = now_s;
have_prev_ = false;
}
bool TriggerEngine::process(double value, double now_s) noexcept {
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
// Hold-off: settled, waiting for the next edge.
if (!pending_ && now_s < holdoff_until_s_) {
prev_ = value;
have_prev_ = true;
last_t_s_ = now_s;
return false;
}
if (!have_prev_) {
prev_ = value;
have_prev_ = true;
last_t_s_ = now_s;
return false;
}
last_t_s_ = now_s;
if (pending_) {
// Accumulate the post-trigger window; latch once the stream clock has advanced
// past it. Time, not a sample count: `now_s` is the same clock the x axis reads,
// so the capture covers exactly the requested span whatever the cadence is.
++post_accum_count_;
prev_ = value;
if (now_s - pending_start_s_ >= post_seconds_) {
pending_ = false;
post_accum_count_ = 0;
if (cfg_.mode == TriggerMode::Single) {
armed_ = false;
} else {
holdoff_until_s_ = now_s + std::max(0.0, cfg_.holdoff_s);
}
return true;
}
return false;
}
// Edge detection against the level.
const bool crossed_up = prev_ < cfg_.level && value >= cfg_.level;
const bool crossed_down = prev_ > cfg_.level && value <= cfg_.level;
const bool fired = (cfg_.edge == TriggerEdge::Rising && crossed_up) ||
(cfg_.edge == TriggerEdge::Falling && crossed_down) ||
(cfg_.edge == TriggerEdge::Both && (crossed_up || crossed_down));
prev_ = value;
if (!fired) return false;
// The pre-trigger window has to already be behind this edge, or the capture opens
// on a half-filled pre window and the event is short on its left side -- the plot
// then shows a window that is correctly sized but only partly drawn. The buffer
// fills whether or not the trigger is armed, so this normally passes on the first
// edge; it only bites just after a rebuild (a CONFIG, or enabling the signal),
// when the history genuinely is not there yet.
if (now_s - oldest_s_ < pre_seconds_) return false;
// The edge's own time, which is what a trigger marker marks: the latch itself
// may happen a post-window later.
last_trigger_s_ = now_s;
// The edge always opens a capture; there is no "latch on the edge itself" case.
// A zero-length post window latches on the very next sample, which is the least a
// capture can hold, and every longer window is just more samples -- no conversion
// from seconds to a sample count, so no conversion to get wrong.
pending_ = true;
pending_start_s_ = now_s;
post_accum_count_ = 0;
return false;
}
} // namespace udpscope
+112
View File
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstdint>
namespace udpscope {
/// Which way the trigger signal crosses the level.
enum class TriggerEdge { Rising, Falling, Both };
/// How an armed trigger re-arms after it fired.
enum class TriggerMode { Normal, Single };
/// The trigger panel's configuration (spec item 2.2).
///
/// The trigger names a *signal* (an index into `ScopeModel::signals()`), not a
/// channel: the panel lists every signal the stream describes, whether or not it
/// is being acquired, and picking one is what arms the trigger on it. The
/// acquisition resolves the signal to the channel carrying it.
struct TriggerConfig {
bool enabled = false; ///< trigger mode on/off (toolbar).
bool armed = false; ///< arm/disarm button.
int channel = -1; ///< index into ScopeModel::channels(), -1 = none.
double level = 0.0;
TriggerEdge edge = TriggerEdge::Rising;
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
double holdoff_s = 0.010; ///< default from the spec.
TriggerMode mode = TriggerMode::Normal;
/// Seconds of post-trigger data to collect before latching the event, given
/// the buffer's span.
double post_span_s(double buffer_span_s) const;
};
/// The producer-side trigger state machine. Fed one sample at a time in stream
/// order; it says when the event buffer should be latched.
class TriggerEngine {
public:
/// Configures the channel and the post-trigger window. Resets the state when the
/// channel changes.
///
/// `post_seconds` is the post-trigger window in **stream seconds**, the same clock
/// `process()` is fed and the same clock the x axis reads, so the capture covers
/// exactly that much axis by construction.
///
/// It used to be a count of samples, converted from seconds by a measured estimate
/// of the channel's cadence. That estimate is what made the window wrong: when it
/// collapsed (any stream whose time signal advances in large steps drove it to the
/// 1 S/s floor) the count came out as 1, the engine latched on the edge sample
/// itself, and the event had no post-trigger data at all. Seconds need no estimate.
///
/// `pre_seconds` is the pre-trigger window, and `oldest_s` the stream time of the
/// oldest sample the buffer still holds. Together they gate the edge: a capture is
/// only allowed once the history reaches back a full `pre_seconds` before it, so an
/// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
/// off, so the history is usually already there and waiting would be wrong.
void configure(const TriggerConfig& cfg, double post_seconds, double pre_seconds,
double oldest_s);
/// Disarms without forgetting the configuration.
void disarm() noexcept { armed_ = false; pending_ = false; }
void arm(double now_s) noexcept;
/// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp
/// in stream seconds. Returns true when the event has just been latched.
bool process(double value, double now_s) noexcept;
bool pending() const noexcept { return pending_; }
bool armed() const noexcept { return armed_; }
/// Diagnostics for the capture state machine: how many samples have landed in
/// the post-trigger window, the window it is filling (in stream seconds), how
/// much of that has elapsed, and the last value fed.
std::uint64_t post_accum() const noexcept { return post_accum_count_; }
double post_seconds() const noexcept { return post_seconds_; }
double post_elapsed() const noexcept {
return pending_ ? last_t_s_ - pending_start_s_ : 0.0;
}
/// How much history the buffer holds behind the newest sample, against the
/// pre-trigger window an edge needs before it may open a capture.
double history() const noexcept { return last_t_s_ - oldest_s_; }
double pre_seconds() const noexcept { return pre_seconds_; }
double last_value() const noexcept { return prev_; }
bool have_last() const noexcept { return have_prev_; }
double last_t() const noexcept { return last_t_s_; }
double holdoff_until() const noexcept { return holdoff_until_s_; }
/// Stream time of the edge that armed the event now on screen. Valid once
/// `process()` has fired; the view is centred on this instant (pre-trigger share
/// before it, post-trigger share after), so it is the x a trigger marker belongs
/// at. Unchanged while a later event has not fired yet.
double last_trigger_time() const noexcept { return last_trigger_s_; }
private:
TriggerConfig cfg_{};
bool armed_ = false;
bool have_prev_ = false;
double prev_ = 0.0;
bool pending_ = false; ///< an edge was seen; collecting the post window
double pending_start_s_ = 0.0;
double holdoff_until_s_ = 0.0;
std::uint64_t post_accum_count_ = 0;
double post_seconds_ = 0.0; ///< post-trigger window, in stream seconds.
double pre_seconds_ = 0.0; ///< pre-trigger window an edge needs behind it.
double oldest_s_ = 0.0; ///< stream time of the oldest sample still buffered.
double last_t_s_ = 0.0;
double last_trigger_s_ = 0.0;
};
} // namespace udpscope
+2164
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
namespace udpscope {
struct Config;
class ScopeModel;
class Acquisition;
class RowSet;
/// Runs the GTK application. Never returns while the window is open; returns the
/// `g_application_run()` result once it closes.
int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& rows);
} // namespace udpscope
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
"""A minimal UDPStreamer (UDPS) producer, for exercising udpscope without MARTe2.
Unicast only: binds `--port`, waits for a CONNECT datagram, then streams CONFIG
once and DATA packets to whoever connected. Publish mode is Accumulate, so each
packet carries `--batch` consecutive samples per (scalar) signal -- the shape the
viewer's per-sample path expects.
Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`.
Everything is float64 and unquantised.
./tools/mock_udpstreamer.py --port 8000 --rate 10000 --batch 100
"""
import argparse
import math
import socket
import struct
import time
MAGIC = 0x53504455
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
T_FLOAT64 = 9
QUANT_NONE = 0
TIME_PACKET, TIME_FULL_ARRAY = 0, 1
PUBLISH_ACCUMULATE = 1
NO_TIME_SIGNAL = 0xFFFFFFFF
def header(pkt_type, counter, payload_bytes):
return struct.pack("<IBIHHI", MAGIC, pkt_type, counter, 0, 1, payload_bytes)
def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0):
"""One 136-byte signal descriptor."""
d = bytearray(136)
d[0:64] = name.encode()[:64].ljust(64, b"\0")
d[64] = T_FLOAT64
d[65] = QUANT_NONE
d[66] = dims
struct.pack_into("<II", d, 67, rows, cols)
struct.pack_into("<dd", d, 75, -10.0, 10.0)
d[91] = time_mode
struct.pack_into("<d", d, 92, rate)
struct.pack_into("<I", d, 100, time_idx)
d[104:136] = unit.encode()[:32].ljust(32, b"\0")
return bytes(d)
def build_config(rate):
sigs = [
# The stream's clock: referenced by every data signal, so udpscope treats
# it as the time base rather than a plottable channel.
signal_desc("time", rate=0.0, time_mode=TIME_FULL_ARRAY,
time_idx=NO_TIME_SIGNAL, unit="s"),
signal_desc("sine", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
signal_desc("square", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
signal_desc("ramp", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
]
payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
payload += bytes([PUBLISH_ACCUMULATE])
return header(PKT_CONFIG, 0, len(payload)) + payload
def build_data(counter, t0, dt, batch, freq):
stamps = [t0 + i * dt for i in range(batch)]
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
payload = struct.pack("<QI", int(t0 * 1e9), batch)
for arr in (stamps, sine, square, ramp):
payload += struct.pack("<%dd" % batch, *arr)
return header(PKT_DATA, counter, len(payload)) + payload
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=8000)
ap.add_argument("--rate", type=float, default=10000.0, help="samples per second")
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
ap.add_argument("--duration", type=float, default=0.0, help="0 = forever")
ap.add_argument("--stream-scale", type=float, default=1.0,
help="stream seconds per wall second: 0.1 mimics a producer whose own "
"clock advances ten times slower than the wire delivers samples")
ap.add_argument("--declared-rate", type=float, default=-1.0,
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
args = ap.parse_args()
dt = args.stream_scale / args.rate # stream seconds per sample
period = args.batch / args.rate # wall seconds per packet
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", args.port))
sock.settimeout(0.001)
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
"stream clock x%.3g, declared %.0f S/s)"
% (args.port, args.rate, args.batch, args.stream_scale, declared), flush=True)
peer = None
counter = 1
t_stream = 0.0
started = None
next_send = time.monotonic()
while True:
try:
buf, addr = sock.recvfrom(2048)
if len(buf) >= 17:
magic, ptype = struct.unpack_from("<IB", buf, 0)
if magic == MAGIC and ptype == PKT_CONNECT:
peer = addr
counter = 1
t_stream = 0.0
started = time.monotonic()
next_send = started
sock.sendto(build_config(declared), peer)
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
elif magic == MAGIC and ptype == PKT_DISCONNECT:
print("mock udpstreamer: DISCONNECT", flush=True)
peer = None
except socket.timeout:
pass
if peer is None:
continue
now = time.monotonic()
if now < next_send:
time.sleep(min(next_send - now, 0.002))
continue
sock.sendto(build_data(counter, t_stream, dt, args.batch, args.freq), peer)
counter += 1
t_stream += args.batch * dt
next_send += period
if args.duration > 0.0 and started is not None and now - started > args.duration:
print("mock udpstreamer: done (%d packets)" % counter, flush=True)
return
if __name__ == "__main__":
main()