From 8059b90de7fb4ddbfd00ce04320daed9d2bf04e8 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 1 Oct 2026 15:42:48 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + .gitmodules | 3 + CLAUDE.md | 245 ++++ CMakeLists.txt | 49 + README.md | 402 ++++++ SPECS.md | 38 + deps/plotter | 1 + deps/udpsclient/Makefile | 46 + deps/udpsclient/udps_client.c | 1230 +++++++++++++++++++ deps/udpsclient/udps_client.h | 340 ++++++ src/acquisition.cpp | 476 ++++++++ src/acquisition.hpp | 83 ++ src/channel.cpp | 317 +++++ src/channel.hpp | 161 +++ src/config.cpp | 100 ++ src/config.hpp | 41 + src/event_series.hpp | 141 +++ src/export.cpp | 273 +++++ src/export.hpp | 59 + src/main.cpp | 36 + src/model.cpp | 378 ++++++ src/model.hpp | 297 +++++ src/plot_host.cpp | 414 +++++++ src/plot_host.hpp | 215 ++++ src/trigger.cpp | 107 ++ src/trigger.hpp | 112 ++ src/ui.cpp | 2164 +++++++++++++++++++++++++++++++++ src/ui.hpp | 17 + tools/mock_udpstreamer.py | 147 +++ 29 files changed, 7893 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CLAUDE.md create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 SPECS.md create mode 160000 deps/plotter create mode 100644 deps/udpsclient/Makefile create mode 100644 deps/udpsclient/udps_client.c create mode 100644 deps/udpsclient/udps_client.h create mode 100644 src/acquisition.cpp create mode 100644 src/acquisition.hpp create mode 100644 src/channel.cpp create mode 100644 src/channel.hpp create mode 100644 src/config.cpp create mode 100644 src/config.hpp create mode 100644 src/event_series.hpp create mode 100644 src/export.cpp create mode 100644 src/export.hpp create mode 100644 src/main.cpp create mode 100644 src/model.cpp create mode 100644 src/model.hpp create mode 100644 src/plot_host.cpp create mode 100644 src/plot_host.hpp create mode 100644 src/trigger.cpp create mode 100644 src/trigger.hpp create mode 100644 src/ui.cpp create mode 100644 src/ui.hpp create mode 100644 tools/mock_udpstreamer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3395f7c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "deps/plotter"] + path = deps/plotter + url = https://git.ferrarinas.it/martino/plotter.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..77962a5 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3be140a --- /dev/null +++ b/CMakeLists.txt @@ -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() diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ad078a --- /dev/null +++ b/README.md @@ -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=` +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 '' 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=` 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. diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..df5ee78 --- /dev/null +++ b/SPECS.md @@ -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 diff --git a/deps/plotter b/deps/plotter new file mode 160000 index 0000000..98cfc58 --- /dev/null +++ b/deps/plotter @@ -0,0 +1 @@ +Subproject commit 98cfc5805b63f8d86edfa1e50c0f1ef73a719995 diff --git a/deps/udpsclient/Makefile b/deps/udpsclient/Makefile new file mode 100644 index 0000000..9369f15 --- /dev/null +++ b/deps/udpsclient/Makefile @@ -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 diff --git a/deps/udpsclient/udps_client.c b/deps/udpsclient/udps_client.c new file mode 100644 index 0000000..64925c4 --- /dev/null +++ b/deps/udpsclient/udps_client.c @@ -0,0 +1,1230 @@ +/** + * @file udps_client.c + * @brief Implementation of the standalone UDPS receiver library. + * + * Layering, bottom up: little-endian readers, fragment reassembly, CONFIG/DATA + * decoding, socket transport, and finally udps_client_poll() which stitches + * them together into the connect/receive/reconnect state machine. + */ + +#define _POSIX_C_SOURCE 200809L +/* struct ip_mreq is not in strict POSIX; _BSD_SOURCE is the pre-2.19 glibc name. */ +#define _DEFAULT_SOURCE 1 +#define _BSD_SOURCE 1 + +#include "udps_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/*---------------------------------------------------------------------------*/ +/* Tunable limits */ +/*---------------------------------------------------------------------------*/ + +/** Concurrent in-flight reassemblies. The server interleaves at most a couple. */ +#define UDPS_MAX_SLOTS 4 + +/** Fragments per update; also bounds the receive bitmask below. */ +#define UDPS_MAX_FRAGMENTS 512 +#define UDPS_FRAG_MASK_BYTES (UDPS_MAX_FRAGMENTS / 8) + +/** Largest datagram we can receive, header included. */ +#define UDPS_RX_BUF_BYTES (65535 + 17) + +/** An incomplete reassembly older than this is abandoned. */ +#define UDPS_SLOT_STALE_S 2.0 + +/** Datagrams drained per poll before housekeeping runs again. */ +#define UDPS_DRAIN_LIMIT 256 + +/** Refuse CONFIGs claiming more signals than this. */ +#define UDPS_MAX_SIGNALS 4096 + +/*---------------------------------------------------------------------------*/ +/* Little-endian primitives */ +/*---------------------------------------------------------------------------*/ + +static uint16_t rd_u16(const uint8_t *p) { + return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); +} + +static uint32_t rd_u32(const uint8_t *p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | + ((uint32_t)p[3] << 24); +} + +static uint64_t rd_u64(const uint8_t *p) { + return (uint64_t)rd_u32(p) | ((uint64_t)rd_u32(p + 4) << 32); +} + +static float rd_f32(const uint8_t *p) { + uint32_t bits = rd_u32(p); + float f; + memcpy(&f, &bits, sizeof f); + return f; +} + +static double rd_f64(const uint8_t *p) { + uint64_t bits = rd_u64(p); + double d; + memcpy(&d, &bits, sizeof d); + return d; +} + +static void wr_u16(uint8_t *p, uint16_t v) { + p[0] = (uint8_t)(v & 0xFFu); + p[1] = (uint8_t)(v >> 8); +} + +static void wr_u32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)(v & 0xFFu); + p[1] = (uint8_t)((v >> 8) & 0xFFu); + p[2] = (uint8_t)((v >> 16) & 0xFFu); + p[3] = (uint8_t)((v >> 24) & 0xFFu); +} + +/** Serialises a header into @p buf, which must hold UDPS_HEADER_SIZE bytes. */ +static void build_header(uint8_t *buf, uint8_t type, uint32_t counter, + uint16_t frag_idx, uint16_t total_frags, + uint32_t payload_bytes) { + wr_u32(buf, UDPS_MAGIC); + buf[4] = type; + wr_u32(buf + 5, counter); + wr_u16(buf + 9, frag_idx); + wr_u16(buf + 11, total_frags); + wr_u32(buf + 13, payload_bytes); +} + +/*---------------------------------------------------------------------------*/ +/* Clocks */ +/*---------------------------------------------------------------------------*/ + +static double now_mono(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; +} + +static double now_wall(void) { + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; +} + +static void sleep_s(double s) { + struct timespec ts; + if (s <= 0.0) { + return; + } + ts.tv_sec = (time_t)s; + ts.tv_nsec = (long)((s - (double)ts.tv_sec) * 1e9); + (void)nanosleep(&ts, NULL); +} + +/*---------------------------------------------------------------------------*/ +/* Internal state */ +/*---------------------------------------------------------------------------*/ + +typedef struct { + int active; + uint32_t counter; + uint8_t type; + uint16_t total_fragments; + uint16_t received_fragments; + uint8_t mask[UDPS_FRAG_MASK_BYTES]; + uint8_t *payload; + size_t payload_cap; + uint32_t chunk_size; /**< Learned from fragment 0; fixes placement. */ + uint32_t assembled_bytes;/**< Highest byte written; the true payload size. */ + double first_seen; +} udps_slot_t; + +struct udps_client { + udps_client_config_t cfg; + /* Owned copies: the caller's strings need not outlive the create call. */ + char server_addr[128]; + char mcast_group[128]; + char iface_addr[128]; + + struct sockaddr_in server_sa; + int udp_fd; /**< Unicast receive socket, or joined multicast socket. */ + int tcp_fd; /**< Multicast control channel; -1 in unicast mode. */ + int connected; + int ever_connected; + double last_data; /**< Monotonic; drives the silence timeout. */ + double last_keepalive; + double disconnect_t; /**< Monotonic; 0 before the first attempt. */ + + udps_slot_t slots[UDPS_MAX_SLOTS]; + + udps_signal_t *sigs; + uint32_t num_sigs; + size_t sigs_cap; + uint8_t publish_mode; + + double *valbuf; /**< Arena holding one frame's doubles. */ + size_t valbuf_cap; + udps_signal_values_t *vals; + size_t vals_cap; + + uint8_t *rxbuf; + + uint32_t last_counter; + int have_counter; + + udps_stats_t stats; + char err[256]; + + udps_config_cb on_config; + udps_data_cb on_data; + udps_event_cb on_event; + void *user; +}; + +static void emit_event(udps_client_t *c, udps_event_t ev, const char *detail) { + if (c->on_event != NULL) { + c->on_event(ev, detail, c->user); + } +} + +/** Records an error, reports it, and always returns -1 for tail calls. */ +static int fail(udps_client_t *c, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + vsnprintf(c->err, sizeof c->err, fmt, ap); + va_end(ap); + emit_event(c, UDPS_EVENT_ERROR, c->err); + return -1; +} + +/** Grows @p *buf to at least @p need bytes, preserving nothing. */ +static int ensure_cap(void **buf, size_t *cap, size_t need) { + void *p; + if (*cap >= need) { + return 0; + } + p = realloc(*buf, need); + if (p == NULL) { + return -1; + } + *buf = p; + *cap = need; + return 0; +} + +/*---------------------------------------------------------------------------*/ +/* Stateless helpers (public) */ +/*---------------------------------------------------------------------------*/ + +uint32_t udps_signal_num_elements(const udps_signal_t *s) { + uint32_t r, c, n; + if (s == NULL) { + return 0u; + } + r = (s->num_rows == 0u) ? 1u : s->num_rows; + c = (s->num_cols == 0u) ? 1u : s->num_cols; + if (r > UDPS_MAX_ELEMENTS || c > UDPS_MAX_ELEMENTS) { + return UDPS_MAX_ELEMENTS; + } + n = r * c; + return (n > UDPS_MAX_ELEMENTS) ? UDPS_MAX_ELEMENTS : n; +} + +const char *udps_type_name(uint8_t tc) { + switch (tc) { + case UDPS_T_UINT8: return "uint8"; + case UDPS_T_INT8: return "int8"; + case UDPS_T_UINT16: return "uint16"; + case UDPS_T_INT16: return "int16"; + case UDPS_T_UINT32: return "uint32"; + case UDPS_T_INT32: return "int32"; + case UDPS_T_UINT64: return "uint64"; + case UDPS_T_INT64: return "int64"; + case UDPS_T_FLOAT32: return "float32"; + case UDPS_T_FLOAT64: return "float64"; + default: return "unknown"; + } +} + +int udps_parse_header(const void *buf, size_t len, udps_header_t *out) { + const uint8_t *b = (const uint8_t *)buf; + if (b == NULL || out == NULL || len < UDPS_HEADER_SIZE) { + return -1; + } + out->magic = rd_u32(b); + if (out->magic != UDPS_MAGIC) { + return -1; + } + out->type = b[4]; + out->counter = rd_u32(b + 5); + out->fragment_idx = rd_u16(b + 9); + out->total_fragments = rd_u16(b + 11); + out->payload_bytes = rd_u32(b + 13); + return 0; +} + +int udps_parse_config(const void *payload, size_t len, udps_signal_t *sigs, + uint32_t max_signals, uint32_t *num_signals, + uint8_t *publish_mode) { + const uint8_t *p = (const uint8_t *)payload; + uint32_t n, i; + size_t off; + + if (p == NULL || sigs == NULL || num_signals == NULL || len < 4u) { + return -1; + } + n = rd_u32(p); + /* A CONFIG cannot describe more signals than its payload can hold. */ + if (n > (len - 4u) / UDPS_SIGNAL_DESC_SIZE || n > max_signals) { + return -1; + } + off = 4u; + for (i = 0u; i < n; i++) { + const uint8_t *d = p + off; + udps_signal_t *s = &sigs[i]; + memcpy(s->name, d, 64); + s->name[64] = '\0'; + s->type_code = d[64]; + s->quant_type = d[65]; + s->num_dimensions = d[66]; + s->num_rows = rd_u32(d + 67); + s->num_cols = rd_u32(d + 71); + s->range_min = rd_f64(d + 75); + s->range_max = rd_f64(d + 83); + s->time_mode = d[91]; + s->sampling_rate = rd_f64(d + 92); + s->time_signal_idx = rd_u32(d + 100); + memcpy(s->unit, d + 104, 32); + s->unit[32] = '\0'; + off += UDPS_SIGNAL_DESC_SIZE; + } + *num_signals = n; + if (publish_mode != NULL) { + /* Trailing byte, absent in streams from older producers. */ + *publish_mode = (off < len) ? p[off] : (uint8_t)UDPS_PUBLISH_STRICT; + } + return 0; +} + +double udps_frame_value(const udps_frame_t *f, uint32_t sig, uint32_t sample, + uint32_t elem) { + const udps_signal_values_t *v; + uint32_t idx; + if (f == NULL || sig >= f->num_signals) { + return 0.0; + } + v = &f->values[sig]; + /* Accumulated scalars hold one value per batch slot; everything else holds + * one value per element and repeats across slots. */ + idx = (v->count == f->num_samples && udps_signal_num_elements(&f->signals[sig]) == 1u) + ? sample + : elem; + return (idx < v->count) ? v->values[idx] : 0.0; +} + +double udps_frame_element_time(const udps_frame_t *f, uint32_t sig, + uint32_t elem) { + const udps_signal_t *s; + uint32_t n; + double dt; + + if (f == NULL || sig >= f->num_signals) { + return 0.0; + } + s = &f->signals[sig]; + n = udps_signal_num_elements(s); + if (n <= 1u || s->sampling_rate <= 0.0 || + (s->time_mode != UDPS_TIME_FIRST_SAMPLE && + s->time_mode != UDPS_TIME_LAST_SAMPLE)) { + return f->recv_time; + } + if (elem >= n) { + elem = n - 1u; + } + dt = 1.0 / s->sampling_rate; + /* Anchor on arrival == the newest element, and walk backwards. */ + return f->recv_time - (double)(n - 1u - elem) * dt; +} + +/*---------------------------------------------------------------------------*/ +/* Payload decoding */ +/*---------------------------------------------------------------------------*/ + +static size_t raw_type_size(uint8_t tc) { + switch (tc) { + case UDPS_T_UINT8: case UDPS_T_INT8: return 1u; + case UDPS_T_UINT16: case UDPS_T_INT16: return 2u; + case UDPS_T_UINT32: case UDPS_T_INT32: case UDPS_T_FLOAT32: return 4u; + case UDPS_T_UINT64: case UDPS_T_INT64: case UDPS_T_FLOAT64: return 8u; + default: return 0u; + } +} + +static size_t quant_size(uint8_t qt) { + switch (qt) { + case UDPS_QUANT_UINT8: case UDPS_QUANT_INT8: return 1u; + case UDPS_QUANT_UINT16: case UDPS_QUANT_INT16: return 2u; + default: return 0u; + } +} + +static double read_raw(const uint8_t *p, uint8_t tc) { + switch (tc) { + case UDPS_T_UINT8: return (double)p[0]; + case UDPS_T_INT8: return (double)(int8_t)p[0]; + case UDPS_T_UINT16: return (double)rd_u16(p); + case UDPS_T_INT16: return (double)(int16_t)rd_u16(p); + case UDPS_T_UINT32: return (double)rd_u32(p); + case UDPS_T_INT32: return (double)(int32_t)rd_u32(p); + case UDPS_T_UINT64: return (double)rd_u64(p); + case UDPS_T_INT64: return (double)(int64_t)rd_u64(p); + case UDPS_T_FLOAT32: return (double)rd_f32(p); + case UDPS_T_FLOAT64: return rd_f64(p); + default: return 0.0; + } +} + +/** Expands a quantised integer back onto the signal's physical range. */ +static double dequantise(uint8_t qt, uint16_t raw, double lo, double hi) { + double span = hi - lo; + switch (qt) { + case UDPS_QUANT_UINT8: + return lo + ((double)(uint8_t)raw / 255.0) * span; + case UDPS_QUANT_INT8: + return lo + (((double)(int8_t)(uint8_t)raw + 127.0) / 254.0) * span; + case UDPS_QUANT_UINT16: + return lo + ((double)raw / 65535.0) * span; + case UDPS_QUANT_INT16: + return lo + (((double)(int16_t)raw + 32767.0) / 65534.0) * span; + default: + return 0.0; + } +} + +/** Reads @p n consecutive elements of @p s, advancing @p off. */ +static int parse_elems(const uint8_t *pl, size_t len, size_t *off, uint32_t n, + const udps_signal_t *s, double *out) { + int quantised = (s->quant_type != UDPS_QUANT_NONE); + size_t sz = quantised ? quant_size(s->quant_type) + : raw_type_size(s->type_code); + size_t base = *off; + uint32_t i; + + if (sz == 0u || base > len) { + return -1; + } + /* Division rather than multiplication: no overflow on a crafted count. */ + if ((size_t)n > (len - base) / sz) { + return -1; + } + for (i = 0u; i < n; i++) { + const uint8_t *p = pl + base + (size_t)i * sz; + if (quantised) { + uint16_t raw = (sz == 1u) ? (uint16_t)p[0] : rd_u16(p); + out[i] = dequantise(s->quant_type, raw, s->range_min, s->range_max); + } else { + out[i] = read_raw(p, s->type_code); + } + } + *off = base + (size_t)n * sz; + return 0; +} + +static int decode_config(udps_client_t *c, const uint8_t *pl, size_t len) { + uint32_t claimed; + + if (len < 4u) { + return fail(c, "CONFIG payload too short (%lu bytes)", (unsigned long)len); + } + claimed = rd_u32(pl); + if (claimed > (len - 4u) / UDPS_SIGNAL_DESC_SIZE || claimed > UDPS_MAX_SIGNALS) { + return fail(c, "CONFIG claims %lu signals, payload holds %lu", + (unsigned long)claimed, + (unsigned long)((len - 4u) / UDPS_SIGNAL_DESC_SIZE)); + } + if (ensure_cap((void **)&c->sigs, &c->sigs_cap, + (claimed ? claimed : 1u) * sizeof(udps_signal_t)) != 0) { + return fail(c, "out of memory for %lu signals", (unsigned long)claimed); + } + if (udps_parse_config(pl, len, c->sigs, claimed, &c->num_sigs, + &c->publish_mode) != 0) { + c->num_sigs = 0u; + return fail(c, "malformed CONFIG payload"); + } + if (ensure_cap((void **)&c->vals, &c->vals_cap, + (c->num_sigs ? c->num_sigs : 1u) * sizeof(udps_signal_values_t)) != 0) { + c->num_sigs = 0u; + return fail(c, "out of memory for signal value table"); + } + c->stats.config_updates++; + if (c->on_config != NULL) { + c->on_config(c->sigs, c->num_sigs, c->publish_mode, c->user); + } + return 0; +} + +static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len, + uint32_t counter, double recv_time) { + uint32_t nsamples = 1u; + size_t off = 8u; + size_t total = 0u; + size_t written = 0u; + uint32_t i; + uint32_t lost = 0u; + udps_frame_t frame; + + if (c->num_sigs == 0u) { + return 0; /* DATA before CONFIG: nothing to decode against. */ + } + + /* Order the sequence before spending anything on the payload. + * + * Reassembly completes in arrival order, not counter order, so a packet + * delayed or duplicated on the wire surfaces after a newer one has already + * been delivered. Its samples carry an older time base: they land on top + * of data the consumer already has and leave the span they should have + * filled empty. Nothing in the payload distinguishes such a packet from a + * good one, only the counter does. + * + * The counter is a wrapping uint32, so it is ordered by the signed + * difference; comparing the values directly would call the first packet + * after the wrap stale and reject the stream from then on. */ + if (c->have_counter) { + int32_t delta = (int32_t)(counter - c->last_counter); + if (delta <= 0) { + c->stats.stale_packets++; + return 0; + } + lost = (uint32_t)delta - 1u; + c->stats.counter_gaps += lost; + } + c->last_counter = counter; + c->have_counter = 1; + if (len < 8u) { + return fail(c, "DATA payload too short (%lu bytes)", (unsigned long)len); + } + if (c->publish_mode == UDPS_PUBLISH_ACCUMULATE) { + if (len < 12u) { + return fail(c, "accumulate DATA payload missing sample count"); + } + nsamples = rd_u32(pl + 8); + off = 12u; + if (nsamples == 0u) { + return 0; + } + if (nsamples > UDPS_MAX_ELEMENTS) { + return fail(c, "accumulate sample count %lu out of range", + (unsigned long)nsamples); + } + } + + for (i = 0u; i < c->num_sigs; i++) { + uint32_t n = udps_signal_num_elements(&c->sigs[i]); + total += (n == 1u) ? nsamples : n; + } + if (ensure_cap((void **)&c->valbuf, &c->valbuf_cap, + (total ? total : 1u) * sizeof(double)) != 0) { + return fail(c, "out of memory for %lu decoded values", + (unsigned long)total); + } + + for (i = 0u; i < c->num_sigs; i++) { + uint32_t n = udps_signal_num_elements(&c->sigs[i]); + uint32_t count = (n == 1u) ? nsamples : n; + if (parse_elems(pl, len, &off, count, &c->sigs[i], + c->valbuf + written) != 0) { + return fail(c, "DATA payload truncated at signal '%s'", + c->sigs[i].name); + } + c->vals[i].values = c->valbuf + written; + c->vals[i].count = count; + written += count; + } + + c->stats.frames_delivered++; + + if (c->on_data != NULL) { + frame.counter = counter; + frame.lost = lost; + frame.hrt = rd_u64(pl); + frame.recv_time = recv_time; + frame.publish_mode = c->publish_mode; + frame.num_samples = nsamples; + frame.num_signals = c->num_sigs; + frame.signals = c->sigs; + frame.values = c->vals; + c->on_data(&frame, c->user); + } + return 0; +} + +/*---------------------------------------------------------------------------*/ +/* Fragment reassembly */ +/*---------------------------------------------------------------------------*/ + +static void slot_reset_all(udps_client_t *c) { + int i; + for (i = 0; i < UDPS_MAX_SLOTS; i++) { + c->slots[i].active = 0; + } +} + +static void gc_slots(udps_client_t *c, double now) { + int i; + for (i = 0; i < UDPS_MAX_SLOTS; i++) { + if (c->slots[i].active && (now - c->slots[i].first_seen) > UDPS_SLOT_STALE_S) { + c->slots[i].active = 0; + c->stats.fragments_dropped += c->slots[i].received_fragments; + } + } +} + +static void deliver_payload(udps_client_t *c, uint8_t type, const uint8_t *pl, + size_t len, uint32_t counter, double recv_time) { + if (type == UDPS_PKT_CONFIG) { + (void)decode_config(c, pl, len); + } else { + (void)decode_data(c, pl, len, counter, recv_time); + } +} + +/** + * @brief Files one fragment of a multi-fragment update. + * + * Fragments carry no offset, only an index, so placement relies on every + * fragment but the last being the same size — learned from fragment 0. + */ +static void place_fragment(udps_client_t *c, const udps_header_t *h, + const uint8_t *payload, size_t payload_bytes, + double recv_time) { + int slot = -1; + int i; + udps_slot_t *s; + size_t byte_idx; + uint8_t bit; + size_t offset; + + if (h->fragment_idx >= h->total_fragments || + h->total_fragments > UDPS_MAX_FRAGMENTS) { + c->stats.fragments_dropped++; + return; + } + + for (i = 0; i < UDPS_MAX_SLOTS; i++) { + if (c->slots[i].active && c->slots[i].counter == h->counter && + c->slots[i].type == h->type) { + slot = i; + break; + } + } + if (slot < 0) { + double oldest = 0.0; + for (i = 0; i < UDPS_MAX_SLOTS; i++) { + if (!c->slots[i].active) { + slot = i; + break; + } + if (slot < 0 || c->slots[i].first_seen < oldest) { + oldest = c->slots[i].first_seen; + slot = i; + } + } + s = &c->slots[slot]; + if (s->active) { + /* All slots busy: the oldest update is never going to complete. */ + c->stats.fragments_dropped += s->received_fragments; + } + if (ensure_cap((void **)&s->payload, &s->payload_cap, + c->cfg.max_packet_bytes) != 0) { + (void)fail(c, "out of memory for a %lu byte reassembly buffer", + (unsigned long)c->cfg.max_packet_bytes); + s->active = 0; + return; + } + s->active = 1; + s->counter = h->counter; + s->type = h->type; + s->total_fragments = h->total_fragments; + s->received_fragments = 0u; + s->chunk_size = 0u; + s->assembled_bytes = 0u; + s->first_seen = recv_time; + memset(s->mask, 0, sizeof s->mask); + } + s = &c->slots[slot]; + + byte_idx = (size_t)h->fragment_idx / 8u; + bit = (uint8_t)(1u << (h->fragment_idx % 8u)); + if ((s->mask[byte_idx] & bit) != 0u) { + c->stats.fragments_dropped++; /* duplicate */ + return; + } + + if (s->chunk_size == 0u) { + if (h->fragment_idx != 0u) { + /* Fragment 0 was lost, so no offset can be computed for this one. */ + c->stats.fragments_dropped++; + return; + } + s->chunk_size = (uint32_t)payload_bytes; + } + offset = (size_t)h->fragment_idx * s->chunk_size; + if (offset + payload_bytes > s->payload_cap) { + c->stats.fragments_dropped++; + return; + } + if (payload_bytes > 0u) { + memcpy(s->payload + offset, payload, payload_bytes); + } + if (offset + payload_bytes > s->assembled_bytes) { + s->assembled_bytes = (uint32_t)(offset + payload_bytes); + } + s->mask[byte_idx] |= bit; + s->received_fragments++; + + if (s->received_fragments >= s->total_fragments) { + s->active = 0; + deliver_payload(c, s->type, s->payload, s->assembled_bytes, s->counter, + recv_time); + } +} + +/** Validates one datagram (or TCP frame) and routes it. */ +static void handle_packet(udps_client_t *c, const uint8_t *buf, size_t len) { + udps_header_t h; + double recv_time = now_wall(); + + if (udps_parse_header(buf, len, &h) != 0) { + return; /* Not ours: stray traffic on a shared multicast port. */ + } + if (h.type != UDPS_PKT_DATA && h.type != UDPS_PKT_CONFIG) { + return; + } + if ((size_t)h.payload_bytes + UDPS_HEADER_SIZE > len) { + return; /* truncated */ + } + + c->stats.packets_received++; + c->stats.bytes_received += len; + + if (h.total_fragments <= 1u) { + deliver_payload(c, h.type, buf + UDPS_HEADER_SIZE, h.payload_bytes, + h.counter, recv_time); + } else { + place_fragment(c, &h, buf + UDPS_HEADER_SIZE, h.payload_bytes, recv_time); + } +} + +/*---------------------------------------------------------------------------*/ +/* Transport */ +/*---------------------------------------------------------------------------*/ + +/** Resolves a dotted quad, falling back to a name lookup. */ +static int resolve_ipv4(const char *host, struct in_addr *out) { + struct addrinfo hints; + struct addrinfo *res = NULL; + + if (inet_pton(AF_INET, host, out) == 1) { + return 0; + } + memset(&hints, 0, sizeof hints); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_DGRAM; + if (getaddrinfo(host, NULL, &hints, &res) != 0 || res == NULL) { + return -1; + } + *out = ((struct sockaddr_in *)(void *)res->ai_addr)->sin_addr; + freeaddrinfo(res); + return 0; +} + +static void set_nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0) { + (void)fcntl(fd, F_SETFL, flags | O_NONBLOCK); + } +} + +/** Raises SO_RCVBUF; a small kernel buffer silently drops burst traffic. */ +static void set_recv_buffer(int fd, uint32_t bytes) { + int v = (int)bytes; + if (v > 0) { + (void)setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &v, (socklen_t)sizeof v); + } +} + +static int send_control(udps_client_t *c, uint8_t type) { + uint8_t pkt[UDPS_HEADER_SIZE]; + ssize_t n; + + build_header(pkt, type, 0u, 0u, 1u, 0u); + if (c->tcp_fd >= 0) { + n = send(c->tcp_fd, pkt, sizeof pkt, 0); + } else if (c->udp_fd >= 0) { + n = sendto(c->udp_fd, pkt, sizeof pkt, 0, + (struct sockaddr *)&c->server_sa, (socklen_t)sizeof c->server_sa); + } else { + return -1; + } + return (n == (ssize_t)sizeof pkt) ? 0 : -1; +} + +static void close_sockets(udps_client_t *c) { + if (c->udp_fd >= 0) { + (void)close(c->udp_fd); + c->udp_fd = -1; + } + if (c->tcp_fd >= 0) { + (void)close(c->tcp_fd); + c->tcp_fd = -1; + } +} + +static int connect_unicast(udps_client_t *c) { + struct sockaddr_in local; + + c->udp_fd = socket(AF_INET, SOCK_DGRAM, 0); + if (c->udp_fd < 0) { + return fail(c, "socket() failed: %s", strerror(errno)); + } + set_recv_buffer(c->udp_fd, c->cfg.recv_buffer_bytes); + + memset(&local, 0, sizeof local); + local.sin_family = AF_INET; + local.sin_addr.s_addr = htonl(INADDR_ANY); + local.sin_port = 0; /* ephemeral: the server replies to this port */ + if (bind(c->udp_fd, (struct sockaddr *)&local, (socklen_t)sizeof local) != 0) { + int e = errno; + close_sockets(c); + return fail(c, "bind() failed: %s", strerror(e)); + } + set_nonblocking(c->udp_fd); + + if (send_control(c, UDPS_PKT_CONNECT) != 0) { + int e = errno; + close_sockets(c); + return fail(c, "CONNECT to %s:%u failed: %s", c->server_addr, + (unsigned)c->cfg.server_port, strerror(e)); + } + return 0; +} + +static int connect_multicast(udps_client_t *c) { + struct sockaddr_in local; + struct ip_mreq mreq; + struct timeval tv; + int on = 1; + + /* Join before announcing: the server multicasts CONFIG the moment it sees + * CONNECT, and a group we have not joined yet drops it in the kernel. */ + c->udp_fd = socket(AF_INET, SOCK_DGRAM, 0); + if (c->udp_fd < 0) { + return fail(c, "socket() failed: %s", strerror(errno)); + } + (void)setsockopt(c->udp_fd, SOL_SOCKET, SO_REUSEADDR, &on, (socklen_t)sizeof on); + set_recv_buffer(c->udp_fd, c->cfg.recv_buffer_bytes); + + memset(&local, 0, sizeof local); + local.sin_family = AF_INET; + local.sin_addr.s_addr = htonl(INADDR_ANY); + local.sin_port = htons(c->cfg.data_port); + if (bind(c->udp_fd, (struct sockaddr *)&local, (socklen_t)sizeof local) != 0) { + int e = errno; + close_sockets(c); + return fail(c, "bind() on data port %u failed: %s", + (unsigned)c->cfg.data_port, strerror(e)); + } + + memset(&mreq, 0, sizeof mreq); + if (resolve_ipv4(c->mcast_group, &mreq.imr_multiaddr) != 0) { + close_sockets(c); + return fail(c, "bad multicast group '%s'", c->mcast_group); + } + if (c->iface_addr[0] != '\0') { + if (resolve_ipv4(c->iface_addr, &mreq.imr_interface) != 0) { + close_sockets(c); + return fail(c, "bad interface address '%s'", c->iface_addr); + } + } else { + mreq.imr_interface.s_addr = htonl(INADDR_ANY); + } + if (setsockopt(c->udp_fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, + (socklen_t)sizeof mreq) != 0) { + int e = errno; + close_sockets(c); + return fail(c, "joining %s failed: %s", c->mcast_group, strerror(e)); + } + set_nonblocking(c->udp_fd); + + /* Control channel: CONNECT out, CONFIG back. */ + c->tcp_fd = socket(AF_INET, SOCK_STREAM, 0); + if (c->tcp_fd < 0) { + int e = errno; + close_sockets(c); + return fail(c, "TCP socket() failed: %s", strerror(e)); + } + if (connect(c->tcp_fd, (struct sockaddr *)&c->server_sa, + (socklen_t)sizeof c->server_sa) != 0) { + int e = errno; + close_sockets(c); + return fail(c, "TCP connect to %s:%u failed: %s", c->server_addr, + (unsigned)c->cfg.server_port, strerror(e)); + } + /* Bound wait so a half-received control frame cannot wedge the poll loop. */ + tv.tv_sec = 1; + tv.tv_usec = 0; + (void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, (socklen_t)sizeof tv); + + if (send_control(c, UDPS_PKT_CONNECT) != 0) { + int e = errno; + close_sockets(c); + return fail(c, "CONNECT over TCP failed: %s", strerror(e)); + } + return 0; +} + +static int do_connect(udps_client_t *c) { + int rc = (c->mcast_group[0] != '\0') ? connect_multicast(c) + : connect_unicast(c); + if (rc != 0) { + c->disconnect_t = now_mono(); + return rc; + } + c->connected = 1; + c->last_data = now_mono(); + c->last_keepalive = c->last_data; + c->have_counter = 0; + slot_reset_all(c); + if (c->ever_connected) { + c->stats.reconnects++; + } + c->ever_connected = 1; + emit_event(c, UDPS_EVENT_CONNECTED, c->server_addr); + return 0; +} + +static void do_disconnect(udps_client_t *c, const char *why) { + if (!c->connected) { + return; + } + (void)send_control(c, UDPS_PKT_DISCONNECT); + close_sockets(c); + c->connected = 0; + c->disconnect_t = now_mono(); + slot_reset_all(c); + emit_event(c, UDPS_EVENT_DISCONNECTED, why); +} + +/** Reads exactly @p n bytes from the control connection. */ +static int read_exact_tcp(udps_client_t *c, uint8_t *dst, size_t n) { + size_t got = 0u; + while (got < n) { + ssize_t r = recv(c->tcp_fd, dst + got, n - got, 0); + if (r == 0) { + return -1; /* orderly close */ + } + if (r < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + got += (size_t)r; + } + return 0; +} + +/** Reads one framed UDPS packet off the TCP control connection. */ +static int read_tcp_frame(udps_client_t *c) { + udps_header_t h; + + if (read_exact_tcp(c, c->rxbuf, UDPS_HEADER_SIZE) != 0) { + return -1; + } + if (udps_parse_header(c->rxbuf, UDPS_HEADER_SIZE, &h) != 0) { + return -1; /* desynchronised: the stream cannot be resynced */ + } + if (h.payload_bytes > (uint32_t)(UDPS_RX_BUF_BYTES - UDPS_HEADER_SIZE)) { + return -1; + } + if (h.payload_bytes > 0u && + read_exact_tcp(c, c->rxbuf + UDPS_HEADER_SIZE, h.payload_bytes) != 0) { + return -1; + } + handle_packet(c, c->rxbuf, UDPS_HEADER_SIZE + h.payload_bytes); + return 0; +} + +/*---------------------------------------------------------------------------*/ +/* Public client API */ +/*---------------------------------------------------------------------------*/ + +void udps_client_config_init(udps_client_config_t *cfg) { + if (cfg == NULL) { + return; + } + memset(cfg, 0, sizeof *cfg); + cfg->silence_timeout_s = 1.0; + cfg->reconnect_delay_s = 2.0; + cfg->keepalive_interval_s = 15.0; /* server evicts silent clients at 30 s */ + cfg->recv_buffer_bytes = 4u * 1024u * 1024u; + cfg->max_packet_bytes = 1u * 1024u * 1024u; +} + +static void copy_str(char *dst, size_t cap, const char *src) { + if (src == NULL) { + dst[0] = '\0'; + return; + } + strncpy(dst, src, cap - 1u); + dst[cap - 1u] = '\0'; +} + +udps_client_t *udps_client_create(const udps_client_config_t *cfg) { + udps_client_t *c; + + if (cfg == NULL || cfg->server_addr == NULL || cfg->server_port == 0u) { + return NULL; + } + c = (udps_client_t *)calloc(1u, sizeof *c); + if (c == NULL) { + return NULL; + } + c->cfg = *cfg; + c->udp_fd = -1; + c->tcp_fd = -1; + copy_str(c->server_addr, sizeof c->server_addr, cfg->server_addr); + copy_str(c->mcast_group, sizeof c->mcast_group, cfg->multicast_group); + copy_str(c->iface_addr, sizeof c->iface_addr, cfg->interface_addr); + /* The config's string pointers must not be used again: they belong to the + * caller and the copies above are what the client works from. */ + c->cfg.server_addr = c->server_addr; + c->cfg.multicast_group = (c->mcast_group[0] != '\0') ? c->mcast_group : NULL; + c->cfg.interface_addr = (c->iface_addr[0] != '\0') ? c->iface_addr : NULL; + + if (c->cfg.max_packet_bytes == 0u) { + c->cfg.max_packet_bytes = 1u * 1024u * 1024u; + } + if (c->cfg.data_port == 0u) { + c->cfg.data_port = (uint16_t)(cfg->server_port + 1u); + } + + memset(&c->server_sa, 0, sizeof c->server_sa); + c->server_sa.sin_family = AF_INET; + c->server_sa.sin_port = htons(cfg->server_port); + if (resolve_ipv4(c->server_addr, &c->server_sa.sin_addr) != 0) { + free(c); + return NULL; + } + + c->rxbuf = (uint8_t *)malloc(UDPS_RX_BUF_BYTES); + if (c->rxbuf == NULL) { + free(c); + return NULL; + } + strcpy(c->err, "no error"); + return c; +} + +void udps_client_destroy(udps_client_t *c) { + int i; + if (c == NULL) { + return; + } + do_disconnect(c, "closed by application"); + close_sockets(c); + for (i = 0; i < UDPS_MAX_SLOTS; i++) { + free(c->slots[i].payload); + } + free(c->sigs); + free(c->valbuf); + free(c->vals); + free(c->rxbuf); + free(c); +} + +void udps_client_set_callbacks(udps_client_t *c, udps_config_cb on_config, + udps_data_cb on_data, udps_event_cb on_event, + void *user) { + if (c == NULL) { + return; + } + c->on_config = on_config; + c->on_data = on_data; + c->on_event = on_event; + c->user = user; +} + +int udps_client_is_connected(const udps_client_t *c) { + return (c != NULL) && c->connected; +} + +const udps_signal_t *udps_client_signals(const udps_client_t *c, + uint32_t *num_signals) { + if (c == NULL) { + if (num_signals != NULL) { + *num_signals = 0u; + } + return NULL; + } + if (num_signals != NULL) { + *num_signals = c->num_sigs; + } + return (c->num_sigs > 0u) ? c->sigs : NULL; +} + +uint8_t udps_client_publish_mode(const udps_client_t *c) { + return (c != NULL) ? c->publish_mode : (uint8_t)UDPS_PUBLISH_STRICT; +} + +void udps_client_stats(const udps_client_t *c, udps_stats_t *out) { + if (c != NULL && out != NULL) { + *out = c->stats; + } +} + +const char *udps_client_last_error(const udps_client_t *c) { + return (c != NULL) ? c->err : "invalid client"; +} + +/** Clamps @p budget so a pending deadline is not slept through. */ +static double clamp_deadline(double budget, double interval, double elapsed) { + double left; + if (interval <= 0.0) { + return budget; + } + left = interval - elapsed; + if (left < 0.0) { + left = 0.0; + } + return (left < budget) ? left : budget; +} + +int udps_client_poll(udps_client_t *c, int timeout_ms) { + double now; + double budget; + fd_set rset; + struct timeval tv; + int maxfd; + int nready; + int processed = 0; + + if (c == NULL) { + return -1; + } + + if (!c->connected) { + now = now_mono(); + if (c->disconnect_t > 0.0) { + double wait = c->cfg.reconnect_delay_s - (now - c->disconnect_t); + if (wait > 0.0) { + /* Idle out the retry delay rather than hammering the server. */ + double cap = (timeout_ms < 0) ? wait : (double)timeout_ms / 1000.0; + sleep_s((wait < cap) ? wait : cap); + return 0; + } + } + if (do_connect(c) != 0) { + return -1; + } + } + + now = now_mono(); + budget = (timeout_ms < 0) ? 1.0 : (double)timeout_ms / 1000.0; + /* Only unicast sends keepalives; clamping on them in multicast mode would + * spin on a deadline that never gets refreshed. */ + if (c->tcp_fd < 0) { + budget = clamp_deadline(budget, c->cfg.keepalive_interval_s, + now - c->last_keepalive); + } + budget = clamp_deadline(budget, c->cfg.silence_timeout_s, now - c->last_data); + + FD_ZERO(&rset); + maxfd = -1; + if (c->udp_fd >= 0 && c->udp_fd < FD_SETSIZE) { + FD_SET(c->udp_fd, &rset); + maxfd = c->udp_fd; + } + if (c->tcp_fd >= 0 && c->tcp_fd < FD_SETSIZE) { + FD_SET(c->tcp_fd, &rset); + if (c->tcp_fd > maxfd) { + maxfd = c->tcp_fd; + } + } + if (maxfd < 0) { + do_disconnect(c, "no usable socket"); + return fail(c, "socket descriptor outside FD_SETSIZE"); + } + + tv.tv_sec = (time_t)budget; + tv.tv_usec = (suseconds_t)((budget - (double)tv.tv_sec) * 1e6); + nready = select(maxfd + 1, &rset, NULL, NULL, &tv); + if (nready < 0) { + if (errno == EINTR) { + return 0; + } + do_disconnect(c, "select failed"); + return fail(c, "select() failed: %s", strerror(errno)); + } + + if (nready > 0 && c->tcp_fd >= 0 && FD_ISSET(c->tcp_fd, &rset)) { + if (read_tcp_frame(c) != 0) { + do_disconnect(c, "control connection lost"); + return fail(c, "TCP control connection lost"); + } + c->last_data = now_mono(); + processed++; + } + + if (nready > 0 && c->udp_fd >= 0 && FD_ISSET(c->udp_fd, &rset)) { + int drained = 0; + while (drained < UDPS_DRAIN_LIMIT) { + ssize_t n = recv(c->udp_fd, c->rxbuf, UDPS_RX_BUF_BYTES, 0); + if (n < 0) { + if (errno == EINTR) { + continue; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) { + break; /* socket drained */ + } + do_disconnect(c, "receive failed"); + return fail(c, "recv() failed: %s", strerror(errno)); + } + drained++; + c->last_data = now_mono(); + if ((size_t)n >= UDPS_HEADER_SIZE) { + handle_packet(c, c->rxbuf, (size_t)n); + } + } + processed += drained; + } + + now = now_mono(); + if (c->cfg.keepalive_interval_s > 0.0 && c->tcp_fd < 0 && + (now - c->last_keepalive) >= c->cfg.keepalive_interval_s) { + /* ACK, not CONNECT: it refreshes the server's last-seen without making + * it resend CONFIG. */ + (void)send_control(c, UDPS_PKT_ACK); + c->last_keepalive = now; + } + gc_slots(c, now_wall()); + + if (c->cfg.silence_timeout_s > 0.0 && + (now - c->last_data) >= c->cfg.silence_timeout_s) { + do_disconnect(c, "server went silent"); + return fail(c, "no data for %.3f s", c->cfg.silence_timeout_s); + } + return processed; +} diff --git a/deps/udpsclient/udps_client.h b/deps/udpsclient/udps_client.h new file mode 100644 index 0000000..0d3fe8b --- /dev/null +++ b/deps/udpsclient/udps_client.h @@ -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 +#include + +#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 */ diff --git a/src/acquisition.cpp b/src/acquisition.cpp new file mode 100644 index 0000000..dbea192 --- /dev/null +++ b/src/acquisition.cpp @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "acquisition.hpp" + +#include + +#include +#include +#include +#include +#include + +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(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 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(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(time_signal), k, 0) + : udps_frame_value(frame, static_cast(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(model_.signals().size())) { + double unit_scale = 1.0; + const std::string& unit = model_.signals()[static_cast(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 lock(model_.mutex()); + if (!running_.load(std::memory_order_relaxed)) return; + + const Partition& part = model_.partition(); + const std::vector& 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(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(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(snap ? snap->stamps.size() : 0), + frame->recv_time, static_cast(tick_), + std::chrono::duration(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(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(ch.signal), 0, + std::min(k, ch.elements - 1)) + : udps_frame_value(frame, static_cast(ch.signal), k, 0); + const f32 value = static_cast(raw * ch.gain + ch.offset); + store.values(i).push(value); + if (static_cast(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(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(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(user)->on_config(signals, count, publish_mode); +} + +void Acquisition::data_trampoline(const udps_frame_t* frame, void* user) { + static_cast(user)->on_data(frame); +} + +void Acquisition::event_trampoline(udps_event_t event, const char* detail, void* user) { + static_cast(user)->on_event(static_cast(event), detail); +} + +} // namespace udpscope diff --git a/src/acquisition.hpp b/src/acquisition.hpp new file mode 100644 index 0000000..93995ea --- /dev/null +++ b/src/acquisition.hpp @@ -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 + +#include +#include +#include +#include +#include + +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 running_{false}; + std::atomic 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 stamps_; + std::chrono::steady_clock::time_point started_{}; +}; + +} // namespace udpscope diff --git a/src/channel.cpp b/src/channel.cpp new file mode 100644 index 0000000..0d845c4 --- /dev/null +++ b/src/channel.cpp @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "channel.hpp" + +#include +#include + +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> values, + std::shared_ptr> 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(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& 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(x_at(i) - base)); + out_xy.push_back(y); + } + }; + const auto rel = [&](u64 i) { return static_cast(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(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& 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 diff --git a/src/channel.hpp b/src/channel.hpp new file mode 100644 index 0000000..6a19455 --- /dev/null +++ b/src/channel.hpp @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include + +#include "event_series.hpp" + +#include +#include + +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> values, + std::shared_ptr> 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& 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> values_; + std::shared_ptr> 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 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 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(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& 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 buffer_; + std::unique_ptr event_; ///< the captured event, when held +}; + +} // namespace udpscope diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..ea7a87e --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "config.hpp" + +#include +#include +#include +#include + +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(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(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(n); + } else { + fail("unknown argument: " + a); + } + } + return c; +} + +} // namespace udpscope diff --git a/src/config.hpp b/src/config.hpp new file mode 100644 index 0000000..384b6e1 --- /dev/null +++ b/src/config.hpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include + +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 diff --git a/src/event_series.hpp b/src/event_series.hpp new file mode 100644 index 0000000..12daa90 --- /dev/null +++ b/src/event_series.hpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari +#pragma once + +#include + +#include +#include +#include + +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 stamps; ///< edge-relative seconds, monotonic + std::vector> 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 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 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& 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( + 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(s.size() - 1) : 1.0; + } + f64 x_at(u64 i) const override { + const auto& s = snap_->stamps; + return i < s.size() ? s[static_cast(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& 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((end - begin) / stride * 2 + 4)); + for (u64 i = (begin / stride) * stride; i < end; i += stride) { + out_xy.push_back(static_cast(stamps[static_cast(i)] - base)); + out_xy.push_back(vals[static_cast(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(n - 1)) idx = static_cast(n - 1); + i0 = static_cast(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(vals.size()); + if (end > n) end = n; + for (u64 i = begin; i < end; ++i) + out[static_cast(i - begin)] = vals[static_cast(i)]; + } + + protected: + void copy_raw(u64 begin, u64 end, f32* out) const override { raw_values(begin, end, out); } + + private: + std::shared_ptr snap_; + std::size_t channel_; + plot::data::MinMaxPyramid empty_pyramid_{}; +}; + +} // namespace udpscope diff --git a/src/export.cpp b/src/export.cpp new file mode 100644 index 0000000..80db2b4 --- /dev/null +++ b/src/export.cpp @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "export.hpp" + +#include +#include +#include +#include +#include + +namespace udpscope { +namespace { + +// The writers memcpy `f64`/`f32` straight out and label them `= '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 column_labels(const ExportTable& table) { + std::vector taken{"time", "stream_time"}; + std::vector 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 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& infos = model.channels(); + const auto name_of = [&](int c) { + return static_cast(c) < infos.size() ? infos[static_cast(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 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& st = snap->stamps; + const auto lo = std::lower_bound(st.begin(), st.end(), static_cast(view.min)); + const auto hi = std::upper_bound(st.begin(), st.end(), static_cast(view.max)); + if (hi <= lo) return table; + const std::size_t first = static_cast(lo - st.begin()); + const std::size_t n = static_cast(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(st[first + i]); + table.stream_time[i] = edge + table.time[i]; + } + for (const int c : chans) { + const std::size_t ch = static_cast(c); + if (ch >= snap->values.size()) continue; + const std::vector& src = snap->values[ch]; + if (src.size() < first + n) continue; + table.columns.push_back({name_of(c), std::vector(src.begin() + static_cast(first), + src.begin() + static_cast(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 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 stamps(static_cast(n)); + store.time().copy_from(first, n, stamps.data()); + table.time.resize(static_cast(n)); + table.stream_time.resize(static_cast(n)); + for (std::size_t i = 0; i < stamps.size(); ++i) { + table.stream_time[i] = static_cast(stamps[i]); + table.time[i] = epoch + table.stream_time[i]; + } + for (const int c : chans) { + const std::size_t ch = static_cast(c); + if (ch >= model.channel_count()) continue; + std::vector values(static_cast(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 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', ' 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(header.size() & 0xFFu), + static_cast((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 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 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 diff --git a/src/export.hpp b/src/export.hpp new file mode 100644 index 0000000..2cb2b37 --- /dev/null +++ b/src/export.hpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "model.hpp" +#include "plot_host.hpp" + +#include +#include + +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 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 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 stream_time; + std::vector 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 +/// ` +#include + +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; +} diff --git a/src/model.cpp b/src/model.cpp new file mode 100644 index 0000000..e4fd8b0 --- /dev/null +++ b/src/model.cpp @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "model.hpp" + +#include + +#include +#include +#include +#include +#include + +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& 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(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(s); + + const u64 slots = budget / static_cast(std::llround(weight)); + u64 time_capacity = pow2_floor(std::max(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(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(p.time_capacity) * static_cast(p.time_stride) * base_dt; + return p; +} + +// --- BufferStore ------------------------------------------------------------ + +BufferStore::BufferStore(const Partition& part, f64 span_s) + : part_(part), + time_(std::make_shared>(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>(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(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 signals) { + std::lock_guard lock(mutex_); + // Keep the enable state across CONFIG updates for signals that kept their name. + std::unordered_map 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 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(part_, part_.span_s); + + if (trigger_.channel >= static_cast(channels_.size())) trigger_.channel = -1; +} + +bool ScopeModel::is_time_signal(const std::vector& 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 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(channels_.size())) + ? channels_[static_cast(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(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 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& 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(lo_s); + const f32 t_hi = static_cast(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& 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(); + BufferStore& src = live(); + plot::data::RingBuffer& 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(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(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(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(snap->stamps.front()); + snap->post_s = snap->stamps.empty() ? 0.0 : static_cast(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 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 diff --git a/src/model.hpp b/src/model.hpp new file mode 100644 index 0000000..d05806a --- /dev/null +++ b/src/model.hpp @@ -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 + +#include +#include +#include +#include +#include +#include + +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 stride; ///< time slots per channel sample (>= 1) + std::vector 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(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& 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& time() noexcept { return *time_; } + const plot::data::RingBuffer& time() const noexcept { return *time_; } + plot::data::RingBuffer& 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 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> time_; + std::vector>> values_; + std::vector> channels_; +}; + +/// Live connection and counter state, readable from the UI thread. +struct StreamStats { + std::atomic packets{0}; + std::atomic frames{0}; + std::atomic gaps{0}; + std::atomic reconnects{0}; + std::atomic connected{false}; + std::atomic 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 signals); + const std::vector& signals() const noexcept { return signals_; } + const std::vector& 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& 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 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& 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& 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& trigger_time() noexcept { return trigger_time_; } + std::atomic& 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 signals_; + std::vector channels_; + std::vector enabled_; + + Partition part_; + std::unique_ptr store_; + TriggerConfig trigger_{}; + StreamStats stats_{}; + f64 epoch_ = 0.0; + std::atomic event_span_{1.0}; + std::atomic trigger_pending_{false}; + std::atomic> event_; ///< the event on screen + std::atomic display_epoch_{0.0}; + std::atomic trigger_armed_{false}; + std::atomic trigger_enabled_{false}; + std::atomic trigger_latches_{0}; + std::atomic trigger_time_{0.0}; + std::atomic trigger_time_valid_{false}; + std::atomic generation_{0}; + std::mutex mutex_; +}; + +} // namespace udpscope diff --git a/src/plot_host.cpp b/src/plot_host.cpp new file mode 100644 index 0000000..e45d70e --- /dev/null +++ b/src/plot_host.cpp @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "plot_host.hpp" + +#include +#include + +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(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(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(static_cast(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(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(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(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(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(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(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(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(slot)]; +} + +void RowSet::bind(ScopeModel& model) { + const std::vector& signals = model.signals(); + const std::vector& 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(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(k); + break; + } + } + int channel = -1; + if (signal_index >= 0) { + for (std::size_t c = 0; c < channels.size(); ++c) { + if (static_cast(channels[c].signal) == signal_index) { + channel = static_cast(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(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(s)].empty()) { + series = r.slots[static_cast(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(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 diff --git a/src/plot_host.hpp b/src/plot_host.hpp new file mode 100644 index 0000000..5ed3bc0 --- /dev/null +++ b/src/plot_host.hpp @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "model.hpp" + +#include +#include + +#include +#include +#include + +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 slots; + /// Model channel per trace slot. Must start at -1: `std::array{}` + /// 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 signal_name; + /// The channel index `signal_name`/`element` resolved to, filled by `bind()`. + std::array 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 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 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(i)]; } + const Row& row(int i) const { return storage_[static_cast(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(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 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(kMaxRows)> storage_{}; + std::array(kMaxRows)> row_dirty_{}; +}; + +} // namespace udpscope diff --git a/src/trigger.cpp b/src/trigger.cpp new file mode 100644 index 0000000..16fa3cf --- /dev/null +++ b/src/trigger.cpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "trigger.hpp" + +#include + +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 diff --git a/src/trigger.hpp b/src/trigger.hpp new file mode 100644 index 0000000..b9efe4b --- /dev/null +++ b/src/trigger.hpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include + +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 diff --git a/src/ui.cpp b/src/ui.cpp new file mode 100644 index 0000000..309389f --- /dev/null +++ b/src/ui.cpp @@ -0,0 +1,2164 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The GTK4 front end: the toolbar, the trigger and signal panels, and the one plot +// area the `RowSet`'s figure is drawn in. +// +// Every widget here is a thin driver over the model: nothing in this file owns +// sample data. The one thing it does own is the `plot::gtk4::PlotArea`, which is in +// figure mode -- the rows are the figure's rows, so a row whose trace set changed +// (`RowSet::row_dirty()`) costs a new figure and not a new widget: the area keeps its +// GL context and is handed the rebuilt figure. + +#include "ui.hpp" + +#include + +#include "acquisition.hpp" +#include "config.hpp" +#include "model.hpp" +#include "plot_host.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope { +namespace { + +constexpr int kTickMs = 16; + +struct App { + ScopeModel* model = nullptr; + Acquisition* acq = nullptr; + RowSet* rows = nullptr; + + GtkWidget* window = nullptr; + GtkWidget* plot_box = nullptr; + /// The one GL area, drawing the `RowSet`'s figure: every row is a row of that + /// figure, so there is one widget, one context and one `::render` whatever the row + /// count is. + GtkWidget* area_widget = nullptr; + plot::gtk4::PlotArea* area = nullptr; + /// What `PlotArea::create()` is handed. The area is in figure mode from the start and + /// never draws this, but it keeps a reference to the plot it was created with (its + /// single-plot controller holds one) -- and every plot of the figure is destroyed by a + /// rebuild, so handing it a row would leave that reference dangling. + plot::Plot placeholder_plot; + + GtkWidget* status_label = nullptr; + GtkWidget* window_drop = nullptr; + GtkWidget* trigger_toggle = nullptr; + GtkWidget* trigger_revealer = nullptr; + GtkWidget* signal_toggle = nullptr; + GtkWidget* signal_revealer = nullptr; + GtkWidget* signal_panel = nullptr; + GtkWidget* side_box = nullptr; + GtkWidget* arm_button = nullptr; + GtkWidget* trigger_signal_drop = nullptr; + GtkWidget* sidebar_toggle = nullptr; + GtkWidget* side_scroll = nullptr; + GtkWidget* trigger_edge_drop = nullptr; + GtkWidget* trigger_mode_drop = nullptr; + GtkWidget* trigger_level_spin = nullptr; + GtkWidget* trigger_pre_spin = nullptr; + GtkWidget* trigger_holdoff_spin = nullptr; + GtkWidget* signal_list = nullptr; + GtkWidget* pause_button = nullptr; + GtkWidget* menu_button = nullptr; + GtkWidget* cursors_toggle = nullptr; + GtkWidget* cursors_reset = nullptr; + + unsigned signal_generation = ~0u; + bool updating_panels = false; ///< guards signal handlers during a rebuild + bool relayout_pending = false; + bool signals_panel_initialised = false; + /// Set once the user edits the trigger level, so the panel stops defaulting it + /// into the middle of the declared range. + bool trigger_level_touched = false; + /// A short-lived message shown in the status line (a drop's outcome), so an + /// action that changes nothing still says why. + std::string notice; + gint64 notice_until_us = 0; + /// Trigger-mode state for the status line: how many latches were on screen last + /// frame, and whether an event is held (which is what "frozen" means). + std::uint64_t seen_latches = 0; + bool event_held = false; + double window_choices_limit = -1.0; ///< the cap the dropdown was last built with + /// Self-check: how much the axes' left edge moves between frames. + int dbg_axes_x_min = 1 << 20; + int dbg_axes_x_max = -(1 << 20); + int dbg_axes_frames = 0; + /// Frames on which some row's x window did not match row 0's. The rows share the + /// link group's one window, so this is an assertion: it must read 0. + int dbg_x_desync = 0; + gint64 last_channels_meas = 0; + /// Frames drawn with every bound trace slot empty although its channel has + /// samples: the view was swapped and not yet published. + int dbg_blank_frames = 0; + /// Frames the one area staged: a figure is one `::render` for every row. + int dbg_frames_painted = 0; + int dbg_reset_visible = -1; + std::vector last_stored; + std::uint64_t dbg_latches_at_disarm = 0; + int dbg_swaps = 0; + int dbg_axes_w_min = 1 << 20; + int dbg_axes_w_max = -(1 << 20); + int dbg_axes_w_prev = -1; + int dbg_axes_w_changes = 0; + int dbg_axes_w_change_frame = -1; + int dbg_axes_w_change_side = -1; + float dbg_scale_min = 1e9f; + float dbg_scale_max = -1e9f; + int dbg_vp_w_min = 1 << 20; + int dbg_vp_w_max = -(1 << 20); + int dbg_xtick_min = 1 << 20; + int dbg_xtick_max = -(1 << 20); + int dbg_gutter_min = 1 << 20; + int dbg_gutter_max = -(1 << 20); + int dbg_side_w = 0; + int dbg_side_h = 0; + int dbg_signal_w = 0; + int dbg_signal_h = 0; + int dbg_signal_row_h = 0; + int dbg_trigger_w = 0; + int dbg_trigger_h = 0; + int dbg_signal_revealed = 0; + int dbg_trigger_revealed = 0; + int dbg_side_visible = 0; + int dbg_trigger_items = 0; + int dbg_trigger_selected = -1; + int dbg_side_scroll_after_hide = 1; + std::string dbg_mode_triggered; + std::string dbg_window_drop_text; + double dbg_marker_x = 0.0; + double dbg_marker_max = 0.0; + double dbg_trigger_time = 0.0; + int dbg_trigger_time_valid = -1; + std::string dbg_mode_live; + int dbg_toggles_visible = 0; + int dbg_trigger_revealed_default = 0; +}; + +// --- small helpers ---------------------------------------------------------- + +/// Installs the one style rule the app needs: a signal row that is as short as its +/// contents. +/// +/// Adwaita gives a button a 34 px minimum height and a list row its own padding, and +/// those -- not the widgets in it -- are what set a signal row's height, so no amount +/// of trimming margins in C makes the list shorter. A handful of signals then filled +/// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other +/// button in the window. +void install_css() { + static const char kCss[] = + "row.udpscope-compact { min-height: 0; padding: 1px 0; }\n" + "row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n" + "row.udpscope-compact checkbutton { min-height: 20px; }\n"; + GtkCssProvider* p = gtk_css_provider_new(); + gtk_css_provider_load_from_string(p, kCss); + gtk_style_context_add_provider_for_display(gdk_display_get_default(), + GTK_STYLE_PROVIDER(p), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(p); +} + +GtkWidget* labeled_box(const char* label, GtkWidget* child) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* l = gtk_label_new(label); + gtk_widget_set_halign(l, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), l); + gtk_widget_set_hexpand(child, true); + gtk_box_append(GTK_BOX(box), child); + return box; +} + +GtkWidget* spin(double min, double max, double step, int digits) { + GtkWidget* s = gtk_spin_button_new_with_range(min, max, step); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(s), digits); + return s; +} + +/// A modal, transient window with a vertical content box; the caller appends its +/// controls and then a button row. GTK 4.10 deprecated `GtkDialog` and +/// `GtkMessageDialog`, so the dialogs here are built from a plain window. +GtkWidget* dialog_window(GtkWidget* parent, const char* title) { + GtkWidget* w = gtk_window_new(); + gtk_window_set_title(GTK_WINDOW(w), title); + if (parent != nullptr) gtk_window_set_transient_for(GTK_WINDOW(w), GTK_WINDOW(parent)); + gtk_window_set_modal(GTK_WINDOW(w), TRUE); + gtk_window_set_default_size(GTK_WINDOW(w), 340, -1); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); + gtk_widget_set_margin_top(box, 12); + gtk_widget_set_margin_bottom(box, 12); + gtk_widget_set_margin_start(box, 12); + gtk_widget_set_margin_end(box, 12); + gtk_window_set_child(GTK_WINDOW(w), box); + return w; +} + +/// Appends `button` to a right-aligned row at the end of the dialog's box. +void dialog_add_button(GtkWidget* window, GtkWidget* button) { + GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); + GtkWidget* last = gtk_widget_get_last_child(box); + GtkWidget* row = nullptr; + if (last != nullptr && g_object_get_data(G_OBJECT(last), "udpscope-dialog-buttons") != nullptr) { + row = last; + } else { + row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_halign(row, GTK_ALIGN_END); + g_object_set_data(G_OBJECT(row), "udpscope-dialog-buttons", GINT_TO_POINTER(1)); + gtk_box_append(GTK_BOX(box), row); + } + gtk_box_append(GTK_BOX(row), button); +} + +void on_popover_closed(GtkPopover* popover, gpointer) { + // Unparent only. `gtk_widget_set_parent()` took the reference that the popover + // was created with (GTK4 widgets are created with a floating reference, sunk by + // the first parent), so unparenting drops it to zero and destroys the popover -- + // a following g_object_unref() would be a use-after-free. + gtk_widget_unparent(GTK_WIDGET(popover)); +} + +/// The time windows the toolbar offers: the standard ladder, capped by the +/// configured maximum and by what one buffer actually holds. +std::vector window_choices(const Config& cfg, double span_limit) { + static const double kStd[] = {0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, + 10.0, 20.0, 30.0, 60.0, 120.0, 300.0, 600.0}; + double limit = cfg.max_window_s; + if (span_limit > 0.0) limit = std::min(limit, span_limit); + std::vector out; + for (double w : kStd) { + if (w <= limit + 1.0e-9) out.push_back(w); + } + if (out.empty()) out.push_back(limit); + return out; +} + +std::string format_window(double s) { + char buf[32]; + if (s < 1.0) { + std::snprintf(buf, sizeof(buf), "%.0f ms", s * 1000.0); + } else if (s < 60.0) { + std::snprintf(buf, sizeof(buf), "%.2g s", s); + } else { + std::snprintf(buf, sizeof(buf), "%.0f s", s); + } + return buf; +} + +// --- plot widgets ----------------------------------------------------------- + +void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user); + +gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user); + +/// The row index a widget belongs to -- a popover entry's, not a plot's: the menu is +/// built for one row and its buttons have to remember which. Stored biased by one, +/// because `g_object_set_data(obj, key, NULL)` *removes* the key and row 0 would then +/// read back as "no data". +constexpr int kRowKeyBias = 1; + +void set_row_key(GtkWidget* widget, int row) { + g_object_set_data(G_OBJECT(widget), "udpscope-row", GINT_TO_POINTER(row + kRowKeyBias)); +} + +int row_key(GtkWidget* widget) { + return GPOINTER_TO_INT(g_object_get_data(G_OBJECT(widget), "udpscope-row")) - kRowKeyBias; +} + +/// The row a pointer event at logical `(x, y)` in the area is over. One widget covers +/// every row now, so the row comes from the figure's layout rather than from a key on +/// the widget the event arrived on. +int row_at(const App& app, double x, double y) { + if (app.area == nullptr) return 0; + const int row = static_cast(app.area->row_at(x, y)); + return std::clamp(row, 0, app.rows->row_count() - 1); +} + +/// Cancels the pan drag the adapter's controller opened on the press a GTK gesture is +/// about to swallow -- the right-click menu claims the sequence, and a drop's release +/// goes to the DnD machinery, so in both cases the controller never sees the release +/// that would end the drag and the plot would then follow the pointer with no button +/// held. Escape is the controller's own cancel (it also restores the ranges it +/// captured at the press), and it has to go to *that row's* controller. +void cancel_drag_on(App& app, int row) { + if (app.area == nullptr) return; + if (plot::input::Controller* c = app.area->row_controller(static_cast(row))) { + c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true}); + } +} + +GtkWidget* make_area(App& app) { + plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(app.placeholder_plot); + app.area = area; + area->set_figure(&app.rows->figure()); + GtkWidget* widget = area->widget(); + app.area_widget = widget; + + gtk_widget_set_hexpand(widget, true); + gtk_widget_set_vexpand(widget, true); + gtk_widget_set_size_request(widget, 320, 120); + + GtkDropTarget* target = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY); + g_signal_connect(target, "drop", G_CALLBACK(on_drop_channel), &app); + gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(target)); + + GtkGesture* right = gtk_gesture_click_new(); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(right), 3); + g_signal_connect(right, "pressed", G_CALLBACK(on_right_click), &app); + gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(right)); + + return widget; +} + +/// Brings the plot area in line with the rows: a changed trace set (or row count) +/// rebuilds the figure, and the area is pointed at the new one. Deferred to the frame +/// tick -- a drop arrives from inside a gesture attached to this widget, and the +/// rebuild resets that gesture's controller. +void relayout_areas(App& app) { + if (!app.rows->rebuild_if_dirty()) return; + if (app.area != nullptr) app.area->set_figure(&app.rows->figure()); +} + +// --- panel rebuilds --------------------------------------------------------- + +/// Defined below, forward-declared because placing or freeing a trace slot has to +/// rebuild the signal list: each row's colour chip names the trace that signal is +/// drawn as, so it goes stale the moment the placement changes. +void rebuild_signal_panels(App& app); + +void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->updating_panels) return; + const std::size_t index = static_cast( + GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal"))); + app->model->set_signal_enabled(index, gtk_check_button_get_active(GTK_CHECK_BUTTON(obj))); +} + +void on_arm_clicked(GtkButton* button, gpointer user) { + App* app = static_cast(user); + (void)button; + bool armed = false; + int signal = -1; + { + // The producer reads (and, on a single-shot latch, writes) these fields under + // the same lock; an unguarded read-modify-write here can lose its update. + std::lock_guard lock(app->model->mutex()); + TriggerConfig& tc = app->model->trigger(); + tc.armed = !tc.armed; + armed = tc.armed; + signal = tc.channel; + } + if (armed) { + app->event_held = false; + app->model->trigger_time_valid().store(false, std::memory_order_relaxed); + } + // Arming is a statement that this signal should be watched, so its acquisition + // is switched on with it -- otherwise the trigger could never see a sample. + // Outside the lock: set_signal_enabled() takes it. + if (armed && signal >= 0) app->model->set_signal_enabled(static_cast(signal), true); + gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm"); +} + +void on_trigger_field_changed(GtkWidget*, gpointer user) { + App* app = static_cast(user); + if (app->updating_panels) return; + // Read the widgets first (they may be touched again by a re-entrant handler if a + // setter ran while the lock was held), then publish the values under the lock the + // producer reads them with. + const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level_spin)); + const double pre = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_pre_spin)); + const double holdoff = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_holdoff_spin)); + const guint edge = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_edge_drop)); + const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop)); + { + std::lock_guard lock(app->model->mutex()); + TriggerConfig& tc = app->model->trigger(); + tc.level = level; + tc.pretrigger_pct = pre; + tc.holdoff_s = holdoff; + tc.edge = edge == 0 ? TriggerEdge::Rising + : edge == 1 ? TriggerEdge::Falling + : TriggerEdge::Both; + tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single; + } +} + +void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->updating_panels) return; + (void)obj; + const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_signal_drop)); + if (sel == GTK_INVALID_LIST_POSITION) return; + { + std::lock_guard lock(app->model->mutex()); + if (sel >= app->model->channels().size()) return; + app->model->trigger().channel = static_cast(sel); + } + app->trigger_level_touched = false; // a new signal gets a fresh default level +} + +void show_info_dialog(GtkWindow* parent, const SignalInfo& sig) { + char body[1024]; + std::snprintf(body, sizeof(body), + "unit: %s\ntype: %d\nquantisation: %d\nrows x cols: %u x %u\nrange: %g .. %g\n" + "sample rate: %g Hz\ntime mode: %d\ntime signal: %u", + sig.unit.empty() ? "(none)" : sig.unit.c_str(), int(sig.type_code), + int(sig.quant_type), sig.rows, sig.cols, sig.range_min, sig.range_max, + sig.sampling_rate, int(sig.time_mode), sig.time_signal_idx); + GtkWidget* window = dialog_window(GTK_WIDGET(parent), sig.name.c_str()); + GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); + GtkWidget* label = gtk_label_new(body); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_label_set_wrap(GTK_LABEL(label), true); + gtk_label_set_selectable(GTK_LABEL(label), true); + gtk_box_append(GTK_BOX(box), label); + GtkWidget* close = gtk_button_new_with_label("Close"); + g_signal_connect_swapped(close, "clicked", G_CALLBACK(gtk_window_destroy), window); + dialog_add_button(window, close); + gtk_window_present(GTK_WINDOW(window)); +} + +void on_info_clicked(GtkButton* button, gpointer user) { + App* app = static_cast(user); + const std::size_t index = static_cast( + GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal"))); + if (index < app->model->signals().size()) { + show_info_dialog(GTK_WINDOW(app->window), app->model->signals()[index]); + } +} + +struct CalibContext { + App* app; + std::size_t signal; + GtkWidget* gain; + GtkWidget* offset; + GtkWidget* window; +}; + +void on_calibrate_apply(GtkButton*, gpointer user) { + CalibContext* ctx = static_cast(user); + ctx->app->model->set_calibration(ctx->signal, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->gain)), + gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->offset))); + gtk_window_destroy(GTK_WINDOW(ctx->window)); +} + +void open_calibration(App& app, GtkWindow* parent, std::size_t signal) { + const SignalInfo& sig = app.model->signals()[signal]; + GtkWidget* window = dialog_window(GTK_WIDGET(parent), "Calibrate"); + GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); + GtkWidget* title = gtk_label_new(sig.name.c_str()); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), title); + GtkWidget* gain = spin(-1.0e12, 1.0e12, 0.001, 6); + GtkWidget* offset = spin(-1.0e12, 1.0e12, 0.001, 6); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(gain), 1.0); + gtk_box_append(GTK_BOX(box), labeled_box("gain", gain)); + gtk_box_append(GTK_BOX(box), labeled_box("offset", offset)); + + auto* ctx = new CalibContext{&app, signal, gain, offset, window}; + GtkWidget* cancel = gtk_button_new_with_label("Cancel"); + g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window); + GtkWidget* apply = gtk_button_new_with_label("Apply"); + g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx, + [](gpointer p) { delete static_cast(p); }); + g_signal_connect(apply, "clicked", G_CALLBACK(on_calibrate_apply), ctx); + dialog_add_button(window, cancel); + dialog_add_button(window, apply); + gtk_window_present(GTK_WINDOW(window)); +} + +struct DragContext { + App* app; + std::size_t signal; +}; + +GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user) { + DragContext* ctx = static_cast(user); + // The payload is the signal index; the channel is resolved on drop. + char buf[64]; + std::snprintf(buf, sizeof(buf), "%zu", ctx->signal); + return gdk_content_provider_new_typed(G_TYPE_STRING, buf); +} + +/// The palette entry a signal owns: its position among the *plottable* signals, the +/// time base skipped. It is a property of the signal and not of the row/slot a drop +/// lands in, so the same signal is drawn in the same colour on every plot it appears on +/// -- which is what lets one chip in this list name every one of those traces. +/// +/// Positional, like the channel list, so a new CONFIG re-colours; that is fine because a +/// CONFIG drops the assignments and the history with them. A name hash would survive a +/// CONFIG but collide inside one, and two traces of the same colour on one row is the +/// failure this exists to prevent. +int signal_color_index(const std::vector& signals, std::size_t signal) { + int index = 0; + for (std::size_t i = 0; i < signals.size() && i < signal; ++i) { + if (!ScopeModel::is_time_signal(signals, i)) ++index; + } + return index; +} + +std::string chip_markup(plot::Color c) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "\u25CF", + unsigned(c.r * 255.0f + 0.5f), unsigned(c.g * 255.0f + 0.5f), + unsigned(c.b * 255.0f + 0.5f)); + return buf; +} + +/// The chip for a row of the signal list, as Pango markup: the signal's own colour when +/// it is on a plot, grey when it is not (whether that is because it is not acquired or +/// because it is acquired and not placed). Always a filled disc, never a blank: a blank +/// cell is narrower than a disc, so the names beside it used to shift sideways as +/// signals were placed, and "no trace" read as "no chip here" rather than as a state. +std::string signal_chip_markup(App& app, const std::vector& signals, + std::size_t signal) { + const std::string& name = signals[signal].name; + for (int r = 0; r < app.rows->row_count(); ++r) { + const RowSet::Row& row = app.rows->row(r); + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + if (row.signal_name[static_cast(s)] != name) continue; + return chip_markup(RowSet::palette_color(signal_color_index(signals, signal))); + } + } + return chip_markup(RowSet::unplotted_color()); +} + +/// One line per signal: colour chip, checkbox, name, unit, and a menu for the things +/// you touch once (information, calibration). A `GtkSwitch` and two always-visible +/// buttons made every row about twice this tall, which is what turned a four-signal +/// stream into a panel you have to scroll. +void rebuild_signal_list(App& app, const std::vector& signals) { + app.updating_panels = true; + GtkWidget* list = app.signal_list; + GtkWidget* child = gtk_widget_get_first_child(list); + while (child != nullptr) { + GtkWidget* next = gtk_widget_get_next_sibling(child); + gtk_list_box_remove(GTK_LIST_BOX(list), child); + child = next; + } + + for (std::size_t i = 0; i < signals.size(); ++i) { + if (ScopeModel::is_time_signal(signals, i)) { + continue; // the time base is not a plottable signal + } + const SignalInfo& sig = signals[i]; + GtkWidget* row = gtk_list_box_row_new(); + gtk_widget_add_css_class(row, "udpscope-compact"); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_margin_start(box, 6); + gtk_widget_set_margin_end(box, 2); + + GtkWidget* chip = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, signals, i).c_str()); + gtk_widget_set_tooltip_text(chip, app.model->signal_enabled(i) + ? "the colour this signal is plotted in" + : "not acquired"); + gtk_box_append(GTK_BOX(box), chip); + + GtkWidget* enable = gtk_check_button_new(); + gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), app.model->signal_enabled(i)); + gtk_widget_set_tooltip_text(enable, "acquire this signal"); + g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i)); + g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); + gtk_box_append(GTK_BOX(box), enable); + + GtkWidget* name = gtk_label_new(sig.name.c_str()); + gtk_widget_set_hexpand(name, true); + gtk_widget_set_halign(name, GTK_ALIGN_START); + gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END); + gtk_box_append(GTK_BOX(box), name); + + if (!sig.unit.empty()) { + GtkWidget* unit = gtk_label_new(sig.unit.c_str()); + gtk_widget_add_css_class(unit, "dim-label"); + gtk_box_append(GTK_BOX(box), unit); + } + + // The overflow: information and calibration are set once per session, so they + // cost a click here instead of a button on every row forever. + GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_set_margin_top(menu_box, 4); + gtk_widget_set_margin_bottom(menu_box, 4); + gtk_widget_set_margin_start(menu_box, 4); + gtk_widget_set_margin_end(menu_box, 4); + + GtkWidget* info = gtk_button_new_with_label("Information"); + g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i)); + g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app); + gtk_box_append(GTK_BOX(menu_box), info); + + GtkWidget* cal = gtk_button_new_with_label("Calibrate..."); + gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest"); + g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i)); + g_signal_connect(cal, "clicked", + G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + const std::size_t idx = static_cast( + GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); + open_calibration(*a, GTK_WINDOW(a->window), idx); + }), + &app); + gtk_box_append(GTK_BOX(menu_box), cal); + + GtkWidget* popover = gtk_popover_new(); + gtk_popover_set_child(GTK_POPOVER(popover), menu_box); + GtkWidget* more = gtk_menu_button_new(); + gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(more), "view-more-symbolic"); + gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover); + gtk_widget_add_css_class(more, "flat"); + gtk_widget_set_tooltip_text(more, "information and calibration"); + gtk_box_append(GTK_BOX(box), more); + + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); + + DragContext* ctx = new DragContext{&app, i}; + GtkDragSource* source = gtk_drag_source_new(); + gtk_drag_source_set_actions(source, GDK_ACTION_COPY); + g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx, + [](gpointer data, GClosure*) { delete static_cast(data); }, + G_CONNECT_DEFAULT); + gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source)); + + gtk_list_box_append(GTK_LIST_BOX(list), row); + } + app.updating_panels = false; +} + +/// Fills the trigger's signal dropdown with the channels that are actually being +/// acquired -- a trigger can only fire on a channel that has samples. Selecting a +/// channel defaults the level into the middle of its signal's declared range (a +/// level of 0 on a 0..5 V transducer never fires, and "the trigger does nothing" is +/// exactly what that looks like), until the user edits the level by hand. +void rebuild_trigger_signals(App& app) { + const std::vector& channels = app.model->channels(); + app.updating_panels = true; + std::vector storage; + storage.reserve(channels.size()); + for (const ChannelInfo& c : channels) { + storage.push_back(c.unit.empty() ? c.name : c.name + " [" + c.unit + "]"); + } + std::vector names; + names.reserve(storage.size() + 1); + for (const std::string& s : storage) names.push_back(s.c_str()); + if (names.empty()) { + storage.emplace_back("(no acquired channels)"); + names.push_back(storage.back().c_str()); + } + names.push_back(nullptr); + GtkStringList* list = gtk_string_list_new(names.data()); + gtk_drop_down_set_model(GTK_DROP_DOWN(app.trigger_signal_drop), G_LIST_MODEL(list)); + g_object_unref(list); + + TriggerConfig& tc = app.model->trigger(); + if (tc.channel < 0 && !channels.empty()) tc.channel = 0; + const int sel = tc.channel; + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop), + sel < 0 || sel >= static_cast(storage.size()) + ? 0u + : static_cast(sel)); + + if (sel >= 0 && sel < static_cast(channels.size()) && !app.trigger_level_touched) { + const SignalInfo& sig = + app.model->signals()[static_cast(channels[static_cast(sel)].signal)]; + if (std::isfinite(sig.range_min) && std::isfinite(sig.range_max) && + sig.range_max > sig.range_min) { + const double mid = 0.5 * (sig.range_min + sig.range_max); + tc.level = mid; + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), mid); + } + } + app.updating_panels = false; +} + +// --- toolbar handlers ------------------------------------------------------- + +void on_time_window_changed(GObject* obj, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->updating_panels) return; + const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)); + const Partition& part = app->model->partition(); + const std::vector choices = window_choices(app->model->config(), part.span_s); + if (sel < choices.size()) { + app->rows->set_time_window(choices[sel]); + // The trigger captures this much of the stream: its pre-trigger share is a + // share of the window the toolbar selects. + app->model->set_event_span(choices[sel]); + } +} + +/// Points the display-window dropdown at `seconds`, so a control that changes the +/// span on screen (the trigger's event window, or Fit) does not leave the toolbar +/// disagreeing with the plot. A span that is not on the ladder -- a fit frames +/// whatever data the ring holds -- is added as its own entry rather than rounded +/// to a neighbour, because the dropdown is a reading as much as it is a control. +void select_window_choice(App& app, double seconds) { + if (app.window_drop == nullptr || !(seconds > 0.0)) return; + const Partition& part = app.model->partition(); + const std::vector choices = window_choices(app.model->config(), part.span_s); + guint select = GTK_INVALID_LIST_POSITION; + bool extra = true; + for (guint i = 0; i < choices.size(); ++i) { + if (std::fabs(choices[i] - seconds) < 1.0e-9) { + select = i; + extra = false; + break; + } + } + + std::vector labels; + std::vector cstrs; + for (double w : choices) labels.push_back(format_window(w)); + if (extra) { + labels.push_back(format_window(seconds) + " (fit)"); + select = static_cast(choices.size()); + } + for (const std::string& l : labels) cstrs.push_back(l.c_str()); + cstrs.push_back(nullptr); + + app.updating_panels = true; + GtkStringList* list = gtk_string_list_new(cstrs.data()); + gtk_drop_down_set_model(GTK_DROP_DOWN(app.window_drop), G_LIST_MODEL(list)); + g_object_unref(list); + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.window_drop), select); + app.updating_panels = false; +} + +/// What one buffer actually holds, in seconds; the configured maximum until a +/// CONFIG has arrived. +double buffered_span(const App& app) { + const Partition& part = app.model->partition(); + return part.span_s > 0.0 ? part.span_s : app.model->config().max_window_s; +} + +/// Rebuilds the window dropdown for the current cap, keeping the selected window +/// when it still exists. Called once at start-up and again when a CONFIG changes +/// how much history one buffer holds -- never on a mere enable or calibration +/// change, so those cannot silently re-anchor the view. +void refresh_window_choices(App& app) { + if (app.window_drop == nullptr) return; + const double limit = buffered_span(app); + if (std::fabs(limit - app.window_choices_limit) < 1.0e-9) return; + app.window_choices_limit = limit; + + const std::vector choices = window_choices(app.model->config(), limit); + std::vector labels; + std::vector cstrs; + for (double w : choices) labels.push_back(format_window(w)); + for (const std::string& s : labels) cstrs.push_back(s.c_str()); + cstrs.push_back(nullptr); + + const double wanted = app.rows->time_window(); + guint best = 0; + for (guint i = 0; i < choices.size(); ++i) { + if (std::fabs(choices[i] - wanted) < std::fabs(choices[best] - wanted)) best = i; + } + + app.updating_panels = true; + GtkStringList* list = gtk_string_list_new(cstrs.data()); + gtk_drop_down_set_model(GTK_DROP_DOWN(app.window_drop), G_LIST_MODEL(list)); + g_object_unref(list); + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.window_drop), best); + app.updating_panels = false; + + app.rows->set_time_window(choices[best]); + app.model->set_event_span(choices[best]); +} + +void on_layout_changed(GObject* obj, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->updating_panels) return; + const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)); + const int rows = static_cast(sel) + 1; + if (rows == app->rows->row_count()) return; + // A row count is the figure's row count, fixed at construction, so this builds a new + // figure -- and the area has to be told about it. Nothing is shown or hidden: the rows + // divide the one area's height between them. + app->rows->set_row_count(rows); + if (app->area != nullptr) app->area->set_figure(&app->rows->figure()); +} + +void on_pause_toggled(GtkToggleButton* button, gpointer user) { + App* app = static_cast(user); + const bool paused = gtk_toggle_button_get_active(button); + app->acq->set_paused(paused); + gtk_button_set_icon_name(GTK_BUTTON(button), paused ? "media-playback-start-symbolic" + : "media-playback-pause-symbolic"); + gtk_widget_set_tooltip_text(GTK_WIDGET(button), paused ? "resume acquisition" + : "pause acquisition"); +} + +void on_trigger_toggled(GtkToggleButton* button, gpointer user) { + App* app = static_cast(user); + const bool on = gtk_toggle_button_get_active(button); + int signal = -1; + app->model->set_trigger_enabled(on); // takes the lock and mirrors the atomic + { + std::lock_guard lock(app->model->mutex()); + TriggerConfig& tc = app->model->trigger(); + tc.armed = on; // trigger mode means "wait for an event", so it starts armed + signal = tc.channel; + } + app->event_held = false; + if (!on) { + app->model->trigger_time_valid().store(false, std::memory_order_relaxed); + app->model->set_display_epoch(0.0); // back to seconds since the stream started + app->rows->set_time_window(app->rows->time_window()); // and follow the live edge + } + if (on && signal >= 0) { + app->model->set_signal_enabled(static_cast(signal), true); + } + gtk_revealer_set_reveal_child(GTK_REVEALER(app->trigger_revealer), on); + gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm"); +} + +/// The way out of trigger mode, from inside the panel that froze the view: it is +/// the toolbar's trigger switch, so there is exactly one piece of state. +void on_go_live(GtkButton*, gpointer user) { + App* app = static_cast(user); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), FALSE); +} + +void on_cursors_toggled(GtkToggleButton* button, gpointer user) { + App* app = static_cast(user); + const bool on = gtk_toggle_button_get_active(button); + app->rows->set_cursors_visible(on); + // The reset button exists for the cursors: with them hidden there is nothing to + // reset, and a button that does nothing reads as broken. + gtk_widget_set_visible(app->cursors_reset, on); +} + +void on_signals_toggled(GtkToggleButton* button, gpointer user) { + App* app = static_cast(user); + gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer), + gtk_toggle_button_get_active(button)); +} + +/// Fit: auto-scale every row's y axis and re-frame the x window on the data the +/// ring holds (the plot's own `f` semantics), then tell the toolbar what span that +/// is. +void on_fit_clicked(GtkButton*, gpointer user) { + App* app = static_cast(user); + app->rows->fit_all(); + select_window_choice(*app, app->rows->time_window()); + app->notice = "fitted to the buffered data (" + format_window(app->rows->time_window()) + ")"; + app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; +} + +void on_cursors_reset(GtkButton*, gpointer user) { + App* app = static_cast(user); + // A reset the user cannot see reads as a dead button, so it also switches the + // cursors on. + if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->cursors_toggle))) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->cursors_toggle), true); + } + app->rows->reset_cursors(); +} + +// --- right-click menu ------------------------------------------------------- + +void on_y_auto_clicked(GtkButton* button, gpointer user) { + App* app = static_cast(user); + const int row = row_key(GTK_WIDGET(button)); + app->rows->set_y_auto(row, true); + GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover")); + if (popover != nullptr) gtk_popover_popdown(popover); +} + +struct FixedRangeContext { + App* app; + int row; + GtkWidget* lo; + GtkWidget* hi; + GtkWidget* window; +}; + +void on_fixed_range_apply(GtkButton*, gpointer user) { + FixedRangeContext* ctx = static_cast(user); + ctx->app->rows->set_y_range(ctx->row, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->lo)), + gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->hi))); + gtk_window_destroy(GTK_WINDOW(ctx->window)); +} + +void on_y_fixed_clicked(GtkButton* button, gpointer user) { + App* app = static_cast(user); + const int row = row_key(GTK_WIDGET(button)); + GtkWidget* window = dialog_window(app->window, "Fixed Y range"); + GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); + const plot::Range cur = app->rows->row(row).plot->y_range(0); + GtkWidget* lo = spin(-1.0e12, 1.0e12, 0.1, 6); + GtkWidget* hi = spin(-1.0e12, 1.0e12, 0.1, 6); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(lo), cur.min); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(hi), cur.max); + gtk_box_append(GTK_BOX(box), labeled_box("min", lo)); + gtk_box_append(GTK_BOX(box), labeled_box("max", hi)); + + auto* ctx = new FixedRangeContext{app, row, lo, hi, window}; + GtkWidget* cancel = gtk_button_new_with_label("Cancel"); + g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window); + GtkWidget* apply = gtk_button_new_with_label("Apply"); + g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx, + [](gpointer p) { delete static_cast(p); }); + g_signal_connect(apply, "clicked", G_CALLBACK(on_fixed_range_apply), ctx); + dialog_add_button(window, cancel); + dialog_add_button(window, apply); + gtk_window_present(GTK_WINDOW(window)); + + GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover")); + if (popover != nullptr) gtk_popover_popdown(popover); +} + +/// One "remove from this plot" entry's payload: which row and which trace slot. +struct RemoveTarget { + App* app; + int row; + int slot; +}; + +void on_remove_clicked(GtkButton* button, gpointer user) { + (void)user; + auto* target = static_cast(g_object_get_data(G_OBJECT(button), "udpscope-remove")); + if (target == nullptr) return; + const std::string name = target->app->rows->row(target->row).name[static_cast( + target->slot)]; + // Freeing the slot is all it takes: the row is rebuilt with a trace whose series + // is nothing, so it draws nothing and disappears from the legend. + target->app->rows->assign(target->row, target->slot, std::string(), std::string(), 0); + target->app->relayout_pending = true; + rebuild_signal_panels(*target->app); // the signal's chip goes grey with it + target->app->notice = "removed '" + name + "' from plot " + std::to_string(target->row + 1); + target->app->notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; + GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover")); + if (popover != nullptr) gtk_popover_popdown(popover); +} + +void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user) { + App* app = static_cast(user); + GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); + const int row = row_at(*app, x, y); + + GtkWidget* popover = gtk_popover_new(); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_set_margin_top(box, 4); + gtk_widget_set_margin_bottom(box, 4); + gtk_widget_set_margin_start(box, 4); + gtk_widget_set_margin_end(box, 4); + + char title[64]; + std::snprintf(title, sizeof(title), "Plot %d", row + 1); + GtkWidget* heading = gtk_label_new(title); + gtk_widget_set_halign(heading, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), heading); + + GtkWidget* auto_btn = gtk_button_new_with_label("Y axis: auto scale"); + set_row_key(auto_btn, row); + g_object_set_data(G_OBJECT(auto_btn), "udpscope-popover", popover); + g_signal_connect(auto_btn, "clicked", G_CALLBACK(on_y_auto_clicked), app); + gtk_box_append(GTK_BOX(box), auto_btn); + + GtkWidget* fixed_btn = gtk_button_new_with_label("Y axis: fixed range..."); + set_row_key(fixed_btn, row); + g_object_set_data(G_OBJECT(fixed_btn), "udpscope-popover", popover); + g_signal_connect(fixed_btn, "clicked", G_CALLBACK(on_y_fixed_clicked), app); + gtk_box_append(GTK_BOX(box), fixed_btn); + + // One entry per occupied trace slot of this row. + const RowSet::Row& r = app->rows->row(row); + bool any = false; + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + if (r.channel[static_cast(s)] < 0) continue; + const std::string label = + "Remove '" + r.name[static_cast(s)] + "' from plot " + std::to_string(row + 1); + GtkWidget* btn = gtk_button_new_with_label(label.c_str()); + auto* target = new RemoveTarget{app, row, s}; + g_object_set_data_full(G_OBJECT(btn), "udpscope-remove", target, + [](gpointer p) { delete static_cast(p); }); + g_object_set_data(G_OBJECT(btn), "udpscope-popover", popover); + g_signal_connect(btn, "clicked", G_CALLBACK(on_remove_clicked), nullptr); + gtk_box_append(GTK_BOX(box), btn); + any = true; + } + if (!any) { + GtkWidget* empty = gtk_label_new("no signals on this plot"); + gtk_widget_add_css_class(empty, "dim-label"); + gtk_widget_set_halign(empty, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), empty); + } + + gtk_popover_set_child(GTK_POPOVER(popover), box); + GdkRectangle rect{static_cast(x), static_cast(y), 1, 1}; + gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect); + gtk_widget_set_parent(popover, widget); + // Claim the sequence: without this the button *release* arrives while the popover + // is up, counts as a click outside it, and dismisses the menu before it can be + // read -- the menu flashes and vanishes. + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); + cancel_drag_on(*app, row); + gtk_popover_popup(GTK_POPOVER(popover)); + // NOT g_signal_connect_swapped: that hands the callback its user_data as the + // first argument, so `on_popover_closed` would receive nullptr instead of the + // popover and both the unparent and the unref would fail with a Gtk-CRITICAL. + g_signal_connect(popover, "closed", G_CALLBACK(on_popover_closed), nullptr); +} + +gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, + gpointer user) { + App* app = static_cast(user); + if (!G_VALUE_HOLDS_STRING(value)) return FALSE; + // Where the drop landed decides the row: one area covers them all. + const int row = row_at(*app, x, y); + + const char* text = g_value_get_string(value); + const std::size_t signal = static_cast(std::atoll(text ? text : "0")); + if (signal >= app->model->signals().size()) return FALSE; + + const auto notice = [&](const std::string& what) { + app->notice = what; + app->notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; + std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str()); + std::fflush(stderr); + }; + + // The dropped signal's channels are the ones the model built for it; take the + // first, or the first element if the signal is a vector. + const std::vector& channels = app->model->channels(); + int channel = -1; + std::string name; + for (std::size_t i = 0; i < channels.size(); ++i) { + if (channels[i].signal == signal) { + channel = static_cast(i); + name = channels[i].name; + break; + } + } + if (channel < 0) { + // The signal is not enabled: enable it, then take the channel that appears. + app->model->set_signal_enabled(signal, true); + const std::vector& again = app->model->channels(); + for (std::size_t i = 0; i < again.size(); ++i) { + if (again[i].signal == signal) { + channel = static_cast(i); + name = again[i].name; + break; + } + } + } + if (channel < 0) { + notice("cannot acquire '" + app->model->signals()[signal].name + + "' (multi-element signals are not acquired)"); + return FALSE; + } + + int slot = -1; + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + if (!app->rows->slot_bound(row, s)) { + slot = s; + break; + } + } + if (slot < 0) { + notice("plot " + std::to_string(row + 1) + " already holds four signals"); + return FALSE; + } + // The slot remembers the *signal* (and element), never the channel index: the + // channel list is renumbered whenever a signal is enabled or disabled. + const std::vector& signals = app->model->signals(); + const SignalInfo& sig = signals[signal]; + app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal)); + app->relayout_pending = true; + rebuild_signal_panels(*app); // the row's colour chip now has a trace to name + cancel_drag_on(*app, row); + notice("plotting '" + name + "' on plot " + std::to_string(row + 1)); + return TRUE; +} + +/// The side column is on when it has something to show *and* the user's sidebar +/// button is in; the two panel buttons appear as soon as a CONFIG describes any +/// signal at all. +void update_side_visibility(App& app) { + if (app.side_scroll == nullptr) return; + const bool have_signals = !app.model->signals().empty(); + const bool want_column = + have_signals && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle)); + gtk_widget_set_visible(app.side_scroll, want_column); + gtk_widget_set_visible(app.signal_toggle, have_signals); + gtk_widget_set_visible(app.trigger_toggle, have_signals); +} + +void on_sidebar_toggled(GtkToggleButton* button, gpointer user) { + App* app = static_cast(user); + (void)button; + update_side_visibility(*app); +} + +// --- rebuild of the derived panels ----------------------------------------- + +void rebuild_signal_panels(App& app) { + // Snapshot the model's lists under the lock: the acquisition thread replaces + // them on every CONFIG, and the panel build holds these references across many + // GTK calls (a use-after-free of the name strings otherwise). + std::vector signals; + bool have_signals = false; + { + std::lock_guard lock(app.model->mutex()); + signals = app.model->signals(); + have_signals = !signals.empty(); + } + rebuild_signal_list(app, signals); + rebuild_trigger_signals(app); + refresh_window_choices(app); + + // Nothing to configure before a CONFIG arrives: hide the whole column and its + // toolbar buttons rather than showing two empty panels. The first CONFIG + // reveals the signal panel (the trigger panel stays behind its own button). + update_side_visibility(app); + if (have_signals && !app.signals_panel_initialised) { + app.signals_panel_initialised = true; + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), TRUE); + } + if (!have_signals) { + app.signals_panel_initialised = false; + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), FALSE); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE); + } + app.signal_generation = app.model->generation(); +} + +// --- the frame tick --------------------------------------------------------- + +gboolean tick(gpointer user) { + App* app = static_cast(user); + if (app->window == nullptr) return G_SOURCE_REMOVE; + ScopeModel& model = *app->model; + + if (app->relayout_pending) { + app->relayout_pending = false; + relayout_areas(*app); + } + if (model.generation() != app->signal_generation) rebuild_signal_panels(*app); + + { + // Serialise against the acquisition thread's CONFIG handling and against the + // UI's own structural changes: the bind takes shared handles into the live + // store, and acquire() reads it. + std::lock_guard lock(model.mutex()); + app->rows->bind(model); + app->rows->acquire(model); + } + + // Nothing to do about the rows' x windows: they are one window, the x link group's. + // A gesture changes the row it landed on, `Figure::layout()` adopts that change into + // the group and pushes it to the others, so the "which row acted" search this replaced + // (and the per-row pan/zoom propagation behind it) has no subject any more. + + // While a captured event is on screen the window is pinned to [edge-pre, + // edge+post]; clamp_to_data would fight that pin whenever the bound series + // still reports the acquisition buffer's raw coordinates, dragging the view + // along the ring. Clamping is a live-view concern only. + if (!app->event_held) app->rows->clamp_to_data(); + + // Curator: the trigger's arm state can change under us (single mode disarms + // itself), so the button and the status line read the published atomics. + const bool trigger_on = model.trigger_enabled(); + if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->trigger_toggle)) != trigger_on) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), trigger_on); + } + const bool armed = model.trigger_armed().load(std::memory_order_relaxed); + gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm"); + const std::uint64_t latches = model.trigger_latches().load(std::memory_order_relaxed); + if (latches != app->seen_latches) { + app->seen_latches = latches; + app->event_held = true; + // The window is pinned to exactly what the snapshot captured, read off the + // snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was + // *asked for*, in stream seconds, while how much the capture actually covers + // depends on the cadence the trigger channel was running at. Pinning to the + // request collapsed the waveform into a sliver at x = 0 and left the rest of + // the axis -- the whole post side included -- blank. + // + // The snapshot is edge-relative (the edge at x = 0), so the axis needs no epoch + // shift: snapshot, pin and marker all speak the same coordinates. Rebind first + // so the slots hold the snapshot before the pin is applied. + { + std::lock_guard lock(model.mutex()); + app->rows->bind(model); + } + double pre = 0.0; + double post = 0.0; + if (auto snap = model.event()) { + pre = snap->pre_s; + post = snap->post_s; + } + if (!(pre + post > 0.0)) { + // Nothing was captured (no samples in the ring yet): fall back to the + // requested window so the axis is at least readable. + const double span = std::max(1.0e-3, model.event_span()); + pre = std::min(0.99, model.trigger().pretrigger_pct / 100.0) * span; + post = span - pre; + } + app->rows->pin_event_window(pre, post); + app->rows->set_trigger_marker(0.0, true); // the edge sits at x = 0 + select_window_choice(*app, pre + post); + } + // The trigger instant is marked on every row while its event is the thing on + // screen, so the pre/post split around it can be read off the plot. + if (trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed)) { + // x is seconds from the event: the display epoch is minus the edge time, so the + // edge sits at exactly x = 0 wherever the user has zoomed. + } else { + app->rows->set_trigger_marker(0.0, false); + } + + { + // A bound slot with no published samples while its channel's ring has plenty + // means the consumer side has not caught up: this frame draws an empty plot, and + // the y auto-fit collapses with it. + int bound = 0; + int empty = 0; + for (int i = 0; i < app->rows->row_count(); ++i) { + RowSet::Row& r = app->rows->row(i); + for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { + const std::size_t si = static_cast(sl); + const int ch = r.channel[si]; + if (ch < 0) continue; + const ChannelBuffer* b = r.slots[si].bound(); + if (b == nullptr || static_cast(ch) >= model.channel_count()) continue; + ++bound; + if (b->size() == 0 && model.live().values(static_cast(ch)).produced() > 0) { + ++empty; + } + } + } + if (bound > 0 && empty == bound) ++app->dbg_blank_frames; + } + + // The gutter the rows need, measured from the frame just laid out, imposed as a + // floor that only grows: without it the axes' left edge follows the y tick + // labels and the x axis twitches by a few pixels as the scale changes. + double gutter = 0.0; + for (int i = 0; i < app->rows->row_count(); ++i) { + const RowSet::Row& r = app->rows->row(i); + if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width)); + } + app->rows->note_y_gutter(gutter); + app->rows->apply_y_gutter(); + + if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget)); + if (plot::Plot* p0 = app->rows->row(0).plot; + p0 != nullptr && p0->viewport().w > 100.0f) { // steady state only + const int ax = static_cast(p0->layout().axes.x); + const int gw = static_cast(p0->layout().y_axis_width); + app->dbg_axes_x_min = std::min(app->dbg_axes_x_min, ax); + app->dbg_axes_x_max = std::max(app->dbg_axes_x_max, ax); + app->dbg_gutter_min = std::min(app->dbg_gutter_min, gw); + app->dbg_gutter_max = std::max(app->dbg_gutter_max, gw); + const int aw = static_cast(p0->layout().axes.w); + app->dbg_axes_w_min = std::min(app->dbg_axes_w_min, aw); + app->dbg_axes_w_max = std::max(app->dbg_axes_w_max, aw); + // A min..max range alone cannot tell the twitch bug (a width that changes every + // frame) from one resize that happened once and stayed, which look identical in a + // summary and need opposite responses. Count the changes and keep the last one's + // frame, and whether the side panel was up for it -- hiding that column is the one + // legitimate reason the plot's width moves by itself. + if (app->dbg_axes_frames > 0 && aw != app->dbg_axes_w_prev) { + ++app->dbg_axes_w_changes; + app->dbg_axes_w_change_frame = app->dbg_axes_frames; + app->dbg_axes_w_change_side = gtk_widget_get_visible(app->side_box) ? 1 : 0; + } + app->dbg_axes_w_prev = aw; + app->dbg_vp_w_min = std::min(app->dbg_vp_w_min, static_cast(p0->viewport().w)); + app->dbg_vp_w_max = std::max(app->dbg_vp_w_max, static_cast(p0->viewport().w)); + const int nt = static_cast(p0->x_axis(0).ticks.size()); + app->dbg_xtick_min = std::min(app->dbg_xtick_min, nt); + app->dbg_xtick_max = std::max(app->dbg_xtick_max, nt); + if (app->area != nullptr) { + const float sc = app->area->device_scale(); + app->dbg_scale_min = std::min(app->dbg_scale_min, sc); + app->dbg_scale_max = std::max(app->dbg_scale_max, sc); + } + // The rows are members of one x link group, so they hold one window; a row that + // disagrees by more than a pixel of data means the group did not reach it. + const plot::Range x0 = p0->x_range(app->rows->row(0).axis_x); + const double tol = std::max(1.0e-12, x0.span() / std::max(1.0, double(aw))); + for (int i = 1; i < app->rows->row_count(); ++i) { + const RowSet::Row& r = app->rows->row(i); + if (r.plot == nullptr) continue; + const plot::Range xi = r.plot->x_range(r.axis_x); + if (std::fabs(xi.min - x0.min) > tol || std::fabs(xi.max - x0.max) > tol) { + ++app->dbg_x_desync; + break; + } + } + ++app->dbg_axes_frames; + } + + // Once a second: how many samples each active channel has *stored*, and the rate + // that implies. The app no longer decimates (stride 1, clock stride 1), so a + // stored rate far below the datasource's declared rate is the datasource's own + // wire rate -- this line is the receipt for that. + static gint64 next_channels = 0; + const gint64 now_c = g_get_monotonic_time(); + if (app->last_stored.size() != model.channel_count()) { + // Seed new entries with the current count: a fresh entry must not be counted + // as a delta from zero. + app->last_stored.assign(model.channel_count(), 0); + for (std::size_t c = 0; c < model.channel_count() && c < app->last_stored.size(); ++c) { + app->last_stored[c] = model.live().values(c).produced(); + } + } + if (now_c >= next_channels) { + const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6); + next_channels = now_c + G_TIME_SPAN_SECOND; + app->last_channels_meas = now_c; + std::lock_guard lock(model.mutex()); + for (std::size_t c = 0; c < model.channel_count(); ++c) { + const ChannelInfo& info = model.channels()[c]; + const u64 stored = model.live().values(c).produced(); + // `produced()` restarts at 0 whenever the buffer is rebuilt (a CONFIG, an + // enable/disable, a calibration change), so the delta can run backwards. As + // unsigned that wrapped to ~2^64 and the line reported a rate near 1.8e13 MS/s + // -- a number with no relation to the stream, which is actively misleading when + // this line is the evidence for a timing complaint. A rebuild has no measurable + // rate, so report none. + const u64 prev = c < app->last_stored.size() + ? app->last_stored[static_cast(c)] + : stored; + const double per_s = + stored >= prev && dt_s > 0.0 ? double(stored - prev) / dt_s : 0.0; + if (c < app->last_stored.size()) app->last_stored[static_cast(c)] = stored; + const double declared = info.dt > 0.0 ? 1.0 / info.dt : 0.0; + const ChannelBuffer& buf = model.live().channel(c); + const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0}; + std::fprintf(stderr, + "[udpscope] ch %zu '%s' stored=%llu (%.4g MS/s, declared %.4g S/s) " + "elements=%llu stride=1 clock=1 published=%llu y=[%.6g, %.6g]\n", + c, info.name.c_str(), static_cast(stored), per_s / 1e6, + declared, static_cast(info.elements), + static_cast(buf.size()), double(y.min), double(y.max)); + } + std::fflush(stderr); + } + + // Status line. The model's lists and partition are rebuilt on the producer + // thread, so every read of them takes the model mutex -- narrowly: GTK calls and + // model setters below must stay outside it (a setter takes the same lock, and a + // button toggle emits a handler that calls one). + std::size_t signal_count = 0; + std::size_t channel_count = 0; + double span_s = 0.0; + std::uint64_t time_stride = 1; + double trigger_level = 0.0; + double trigger_value = 0.0; + bool have_trigger_value = false; + std::string trigger_name; + { + std::lock_guard lock(model.mutex()); + signal_count = model.signals().size(); + channel_count = model.channel_count(); + span_s = model.partition().span_s; + time_stride = model.partition().time_stride; + const int chan = model.trigger().channel; + trigger_level = model.trigger().level; + if (chan >= 0 && chan < static_cast(model.channel_count())) { + trigger_name = model.channels()[static_cast(chan)].name; + } + for (std::size_t c = 0; c < model.channel_count(); ++c) { + if (static_cast(c) != chan) continue; + const ChannelBuffer& buf = model.live().channel(c); + if (buf.size() > 0) { + f32 v = 0.0f; + buf.raw(buf.size() - 1, buf.size(), &v); + trigger_value = double(v); + have_trigger_value = true; + } + break; + } + } + StreamStats& st = model.stats(); + const char* mode = "live"; + if (trigger_on) { + if (app->event_held) { + mode = "trigger: EVENT HELD (Arm to re-arm, or 'Go live' for streaming)"; + } else if (armed && have_trigger_value) { + static char waiting[192]; + std::snprintf(waiting, sizeof(waiting), + "trigger: armed on '%s' (now %.4g, level %.4g), waiting for an event", + trigger_name.c_str(), trigger_value, trigger_level); + mode = waiting; + } else if (armed) { + mode = "trigger: armed, waiting for an event"; + } else { + mode = "trigger: disarmed (view frozen)"; + } + } else if (app->acq->paused()) { + mode = "paused"; + } + int bound = 0; + std::size_t samples = 0; + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + const ChannelBuffer* b = app->rows->row(0).slots[static_cast(s)].bound(); + if (b != nullptr && b->size() > 0) { + ++bound; + samples += static_cast(b->size()); + } + } + char status[768]; + std::snprintf(status, sizeof(status), + "[%s] %s %s | packets %llu frames %llu gaps %llu | %zu signals, %zu " + "channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s span, " + "time x%llu", + mode, st.connected.load() ? "connected" : "waiting", + st.have_config.load() ? "configured" : "-", + static_cast(st.packets.load()), + static_cast(st.frames.load()), + static_cast(st.gaps.load()), signal_count, channel_count, bound, + samples, double(model.config().buffer_mb), span_s, + static_cast(time_stride)); + if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { + const std::string with_notice = app->notice + " | " + status; + gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); + } else { + app->notice.clear(); + gtk_label_set_text(GTK_LABEL(app->status_label), status); + } + return G_SOURCE_CONTINUE; +} + +// --- window construction ---------------------------------------------------- + +GtkWidget* build_trigger_panel(App& app) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_margin_top(box, 8); + gtk_widget_set_margin_bottom(box, 8); + gtk_widget_set_margin_start(box, 8); + gtk_widget_set_margin_end(box, 8); + + GtkWidget* heading = gtk_label_new("Trigger"); + gtk_widget_set_halign(heading, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), heading); + + app.trigger_signal_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr); + g_signal_connect(app.trigger_signal_drop, "notify::selected", + G_CALLBACK(on_trigger_signal_changed), &app); + gtk_box_append(GTK_BOX(box), labeled_box("signal", app.trigger_signal_drop)); + + const char* edges[] = {"rising", "falling", "both", nullptr}; + app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges); + gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop)); + + app.trigger_level_spin = spin(-1.0e12, 1.0e12, 0.01, 4); + g_signal_connect(app.trigger_level_spin, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) { + static_cast(u)->trigger_level_touched = true; + }), + &app); + gtk_box_append(GTK_BOX(box), labeled_box("level", app.trigger_level_spin)); + + app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), 50.0); + gtk_box_append(GTK_BOX(box), labeled_box("pre-trigger %", app.trigger_pre_spin)); + + app.trigger_holdoff_spin = spin(0.0, 10.0, 0.001, 4); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin), + app.model->config().trigger_holdoff_s); + gtk_box_append(GTK_BOX(box), labeled_box("hold-off s", app.trigger_holdoff_spin)); + + const char* modes[] = {"normal", "single", nullptr}; + app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes); + gtk_box_append(GTK_BOX(box), labeled_box("mode", app.trigger_mode_drop)); + + + app.arm_button = gtk_button_new_with_label("Arm"); + g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app); + gtk_box_append(GTK_BOX(box), app.arm_button); + + GtkWidget* live = gtk_button_new_with_label("Go live (trigger off)"); + gtk_widget_set_tooltip_text(live, "disable trigger mode and stream continuously"); + g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app); + gtk_box_append(GTK_BOX(box), live); + + for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin}) { + g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app); + } + g_signal_connect(app.trigger_edge_drop, "notify::selected", + G_CALLBACK(on_trigger_field_changed), &app); + g_signal_connect(app.trigger_mode_drop, "notify::selected", + G_CALLBACK(on_trigger_field_changed), &app); + return box; +} + +GtkWidget* build_signal_panel(App& app) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_margin_top(box, 8); + gtk_widget_set_margin_bottom(box, 8); + gtk_widget_set_margin_start(box, 8); + gtk_widget_set_margin_end(box, 8); + + GtkWidget* heading = gtk_label_new("Signals"); + gtk_widget_set_halign(heading, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), heading); + + GtkWidget* hint = gtk_label_new("drag a signal onto a plot"); + gtk_widget_set_halign(hint, GTK_ALIGN_START); + gtk_widget_add_css_class(hint, "dim-label"); + gtk_box_append(GTK_BOX(box), hint); + + app.signal_list = gtk_list_box_new(); + gtk_list_box_set_selection_mode(GTK_LIST_BOX(app.signal_list), GTK_SELECTION_NONE); + // Width only. A height floor here made the list occupy 120 px whatever it held, + // which hid the compact rows' height behind it and wasted the space on an empty + // stream; the scroller above it already caps how tall it can grow. + gtk_widget_set_size_request(app.signal_list, 260, -1); + gtk_box_append(GTK_BOX(box), app.signal_list); + return box; +} + +/// The header bar carries only the controls you reach for *while watching a signal*; +/// everything you set up once lives in the overflow menu. Nine text buttons in a +/// header bar is a wall of words to read past every time you want the one you need, +/// so the four that stay are icons with tooltips. +/// +/// [window ▾] [plots ▾] ............... [pause] [trigger] [panel] [menu] +GtkWidget* build_toolbar(App& app) { + GtkWidget* bar = gtk_header_bar_new(); + + app.window_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr); + g_signal_connect(app.window_drop, "notify::selected", G_CALLBACK(on_time_window_changed), &app); + gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), labeled_box("window", app.window_drop)); + refresh_window_choices(app); + + const char* layouts[] = {"1", "2", "3", "4", nullptr}; + GtkWidget* layout_drop = gtk_drop_down_new_from_strings(layouts); + gtk_drop_down_set_selected(GTK_DROP_DOWN(layout_drop), + static_cast(app.model->config().plots - 1)); + g_signal_connect(layout_drop, "notify::selected", G_CALLBACK(on_layout_changed), &app); + gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), labeled_box("plots", layout_drop)); + + // --- the overflow menu ---------------------------------------------------- + // Plain buttons in a popover rather than a GMenu: these act on the `RowSet` + // directly and two of them are toggles whose state the rest of the UI reads, so + // a GAction model would only add a second copy of that state. + GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_set_margin_top(menu_box, 6); + gtk_widget_set_margin_bottom(menu_box, 6); + gtk_widget_set_margin_start(menu_box, 6); + gtk_widget_set_margin_end(menu_box, 6); + + GtkWidget* fit = gtk_button_new_with_label("Fit to data"); + gtk_widget_set_tooltip_text(fit, "auto-scale every axis and frame the buffered data"); + g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app); + gtk_box_append(GTK_BOX(menu_box), fit); + + app.cursors_toggle = gtk_toggle_button_new_with_label("Cursors"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.cursors_toggle), false); + g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app); + gtk_box_append(GTK_BOX(menu_box), app.cursors_toggle); + + app.cursors_reset = gtk_button_new_with_label("Reset cursors"); + gtk_widget_set_tooltip_text(app.cursors_reset, + "re-place the cursors (only shown while they exist)"); + g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app); + gtk_box_append(GTK_BOX(menu_box), app.cursors_reset); + + gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + + app.signal_toggle = gtk_toggle_button_new_with_label("Signal list"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), true); + g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app); + gtk_box_append(GTK_BOX(menu_box), app.signal_toggle); + + GtkWidget* popover = gtk_popover_new(); + gtk_popover_set_child(GTK_POPOVER(popover), menu_box); + app.menu_button = gtk_menu_button_new(); + gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.menu_button), "open-menu-symbolic"); + gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.menu_button), popover); + gtk_widget_set_tooltip_text(app.menu_button, "view options"); + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.menu_button); + + // --- what stays on the bar (packed end: first call is rightmost) ---------- + app.sidebar_toggle = gtk_toggle_button_new(); + gtk_button_set_icon_name(GTK_BUTTON(app.sidebar_toggle), "sidebar-show-symbolic"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), true); + gtk_widget_set_tooltip_text(app.sidebar_toggle, "show the side panel"); + g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app); + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.sidebar_toggle); + + app.trigger_toggle = gtk_toggle_button_new(); + gtk_button_set_icon_name(GTK_BUTTON(app.trigger_toggle), "media-record-symbolic"); + gtk_widget_set_tooltip_text(app.trigger_toggle, + "trigger mode: freeze the view and show one captured event " + "at a time"); + g_signal_connect(app.trigger_toggle, "toggled", G_CALLBACK(on_trigger_toggled), &app); + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.trigger_toggle); + + app.pause_button = gtk_toggle_button_new(); + gtk_button_set_icon_name(GTK_BUTTON(app.pause_button), "media-playback-pause-symbolic"); + gtk_widget_set_tooltip_text(app.pause_button, "pause acquisition"); + g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app); + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.pause_button); + + return bar; +} + +void on_activate(GtkApplication* application, gpointer user) { + App* app = static_cast(user); + + install_css(); + app->window = gtk_application_window_new(application); + // A window closed by hand (a WM close button) must stop the frame tick: it holds + // pointers to widgets that are finalized with the window. + g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) { + static_cast(user)->window = nullptr; + }), + app); + gtk_window_set_title(GTK_WINDOW(app->window), "udpscope"); + gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800); + gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app)); + + GtkWidget* paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); + + app->plot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_box_set_homogeneous(GTK_BOX(app->plot_box), true); + gtk_widget_set_hexpand(app->plot_box, true); + gtk_widget_set_vexpand(app->plot_box, true); + + GtkWidget* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_append(GTK_BOX(left), app->plot_box); + gtk_widget_set_vexpand(app->plot_box, true); + + app->status_label = gtk_label_new("starting..."); + // The line is rewritten every frame and its text changes width as the counters + // grow. A label's *minimum* width is its text, so an unellipsized one pushes the + // paned divider around as the numbers change -- which resizes the plot, which + // re-lays out the axes and their ticks. Ellipsizing gives it a minimum of "…" and + // makes it fill whatever width it is given instead. + gtk_label_set_ellipsize(GTK_LABEL(app->status_label), PANGO_ELLIPSIZE_END); + gtk_widget_set_hexpand(app->status_label, true); + gtk_widget_set_halign(app->status_label, GTK_ALIGN_FILL); + gtk_widget_set_margin_top(app->status_label, 2); + gtk_widget_set_margin_bottom(app->status_label, 2); + gtk_widget_set_margin_start(app->status_label, 6); + gtk_box_append(GTK_BOX(left), app->status_label); + + // The right-hand side: two independently collapsible panels, each revealed by + // its toolbar toggle. No outer scrolled window -- the signal list scrolls + // inside its own panel -- so neither panel can be squeezed to nothing by the + // other's size request. + GtkWidget* side = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + app->side_box = side; + + // The two panels share a vertical box, so they collapse *vertically*: a + // horizontal slide would animate the width and leave the panel's full height + // reserved -- an invisible gap pushing the other panel down the column. + app->trigger_revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(app->trigger_revealer), + GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); + gtk_revealer_set_child(GTK_REVEALER(app->trigger_revealer), build_trigger_panel(*app)); + gtk_box_append(GTK_BOX(side), app->trigger_revealer); + gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + + app->signal_panel = build_signal_panel(*app); + app->signal_revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(app->signal_revealer), + GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); + gtk_revealer_set_child(GTK_REVEALER(app->signal_revealer), app->signal_panel); + gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer), TRUE); + gtk_box_append(GTK_BOX(side), app->signal_revealer); + gtk_widget_set_size_request(side, 300, -1); + + // The column scrolls as a whole, so a long signal list or a revealed trigger + // panel can never push either panel out of the window. + app->side_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(app->side_scroll), GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(app->side_scroll), side); + gtk_widget_set_size_request(app->side_scroll, 320, -1); + + gtk_paned_set_start_child(GTK_PANED(paned), left); + gtk_paned_set_end_child(GTK_PANED(paned), app->side_scroll); + gtk_paned_set_resize_end_child(GTK_PANED(paned), FALSE); + gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE); + gtk_window_set_child(GTK_WINDOW(app->window), paned); + // After the children exist, or the position is clamped against an empty pane. + gtk_paned_set_position(GTK_PANED(paned), 940); + + gtk_box_append(GTK_BOX(app->plot_box), make_area(*app)); + rebuild_signal_panels(*app); + g_timeout_add(kTickMs, tick, app); + gtk_window_present(GTK_WINDOW(app->window)); +} + +/// Headless self-check, step 2: read the panel geometry while the widgets still +/// exist, then close the window. Runs after the reveal animations have settled. +gboolean exit_probe_step3(gpointer user) { + App* a = static_cast(user); + a->dbg_mode_live = gtk_label_get_text(GTK_LABEL(a->status_label)); + GtkWidget* window = a->window; + a->window = nullptr; // stop the tick before the widgets go + a->dbg_side_w = gtk_widget_get_width(a->side_box); + a->dbg_side_h = gtk_widget_get_height(a->side_box); + a->dbg_signal_w = gtk_widget_get_width(a->signal_revealer); + a->dbg_signal_h = gtk_widget_get_height(a->signal_revealer); + a->dbg_trigger_w = gtk_widget_get_width(a->trigger_revealer); + a->dbg_trigger_h = gtk_widget_get_height(a->trigger_revealer); + // The height of one signal row, which is the thing "the channel toggle is too tall" + // was about. The revealer's own height cannot stand in for it: it also covers the + // heading, the hint and the box margins. + if (GtkWidget* first = gtk_widget_get_first_child(a->signal_list)) { + a->dbg_signal_row_h = gtk_widget_get_height(first); + } + if (a->area != nullptr) a->dbg_frames_painted = static_cast(a->area->frames_painted()); + a->dbg_reset_visible = a->cursors_reset != nullptr && gtk_widget_get_visible(a->cursors_reset) ? 1 : 0; + a->dbg_signal_revealed = + gtk_revealer_get_child_revealed(GTK_REVEALER(a->signal_revealer)) ? 1 : 0; + a->dbg_trigger_revealed = + gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0; + a->dbg_side_visible = gtk_widget_get_visible(a->side_box) ? 1 : 0; + a->dbg_side_scroll_after_hide = gtk_widget_get_visible(a->side_scroll) ? 1 : 0; + if (a->trigger_signal_drop != nullptr) { + GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop)); + a->dbg_trigger_items = model != nullptr ? static_cast(g_list_model_get_n_items(model)) : 0; + a->dbg_trigger_selected = static_cast( + gtk_drop_down_get_selected(GTK_DROP_DOWN(a->trigger_signal_drop))); + } + if (a->window_drop != nullptr) { + GtkStringList* options = + GTK_STRING_LIST(gtk_drop_down_get_model(GTK_DROP_DOWN(a->window_drop))); + const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(a->window_drop)); + if (options != nullptr && sel != GTK_INVALID_LIST_POSITION) { + a->dbg_window_drop_text = gtk_string_list_get_string(options, sel); + } else { + a->dbg_window_drop_text = "(none selected)"; + } + } + a->dbg_toggles_visible = + (gtk_widget_get_visible(a->signal_toggle) && gtk_widget_get_visible(a->trigger_toggle)) ? 1 + : 0; + if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window)); + return G_SOURCE_REMOVE; +} + +/// Step 2: record the status line while trigger mode is on, then leave trigger +/// mode the way the "Go live" button does, and stop the tick. +gboolean exit_probe_step2(gpointer user) { + App* a = static_cast(user); + a->dbg_mode_triggered = gtk_label_get_text(GTK_LABEL(a->status_label)); + if (a->rows->row(0).plot) { + a->dbg_marker_x = a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x; + a->dbg_marker_max = a->rows->row(0).plot->x_range(0).max; + a->dbg_trigger_time = a->model->trigger_time().load(); + a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0; + } + if (a->trigger_toggle != nullptr) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE); + } + // The tick keeps running for these 400 ms, so by step 3 the status line reads + // the state the app is actually in after leaving trigger mode. + g_timeout_add(400, exit_probe_step3, a); + return G_SOURCE_REMOVE; +} + +/// Headless self-check, step 1: toggle the trigger the way the toolbar button +/// does, then let the reveal animation settle before measuring. +/// Headless self-check: arm the trigger, then disarm it the way the button does, +/// so the check can prove no event latched after the disarm. +/// Finds a toolbar/panel button by its label, so the self-check drives the *user's* +/// path (a toggle's handler, a button's click) rather than poking the model -- which +/// the frame tick then legitimately corrects. +GtkWidget* find_labelled_button(GtkWidget* w, const char* label) { + if (w == nullptr) return nullptr; + if (GTK_IS_BUTTON(w)) { + const char* l = gtk_button_get_label(GTK_BUTTON(w)); + if (l != nullptr && std::strcmp(l, label) == 0) return w; + } + for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + if (GtkWidget* hit = find_labelled_button(c, label)) return hit; + } + return nullptr; +} + +/// Headless self-check: sample the trigger state machine and what the plot is +/// actually bound to, so the arm -> capture -> latch -> wait cycle is observable +/// headlessly. Two things in this line are assertions in disguise: +/// +/// * with `trigger=on`, `display=` must never read `live` -- in trigger mode the +/// picture changes only on a latch; +/// * `snap=[a..b]` and `axis=[a..b]` must agree, and `b` must be positive by +/// roughly the post-trigger share of the span. A post side that is empty, or a +/// snapshot that does not fill the axis, is the defect this probe exists for. +gboolean trig_trace_probe(gpointer user) { + App* a = static_cast(user); + // Before the first CONFIG the store does not exist yet: live() would hand back a + // null BufferStore. + if (a->window == nullptr || a->model->channel_count() == 0) return G_SOURCE_CONTINUE; + std::lock_guard lock(a->model->mutex()); + ScopeModel& m = *a->model; + + // What the first bound slot shows, read off the slot itself: the binding is the + // thing under test, so it must not be inferred from the trigger state. + const char* display = "nothing"; + const SlotSeries* shown = nullptr; + for (int i = 0; i < a->rows->row_count(); ++i) { + RowSet::Row& r = a->rows->row(i); + bool found = false; + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + if (r.channel[si] < 0) continue; + if (r.slots[si].bound_event() != nullptr) display = "event(frozen)"; + else if (r.slots[si].bound() != nullptr) display = "live"; + shown = &r.slots[si]; + found = true; + break; + } + if (found) break; + } + + // Where that slot's samples are *drawn*, reconstructed exactly as the renderer + // does it: `Series::gather()` emits x relative to `x0()`, and the renderer adds + // `x0()` back before mapping to the screen. So `x0() + gathered` must equal + // `x_at()`, and `drawn=` must equal `data=` to the f32's precision. + // + // This is the one defect in this area the picture alone shows and no other probe + // line does: a series that emits absolute x instead makes the renderer add `x0()` + // twice, so the trace lands a whole `x0()` away from the axis it was measured + // against -- off screen entirely once the view is narrower than that shift, while + // the crosshair (which reads `x_at()`) goes on reporting correct values. + double drawn_lo = 0.0, drawn_hi = 0.0, data_lo = 0.0, data_hi = 0.0; + if (shown != nullptr && shown->size() > 0) { + const u64 n = shown->size(); + std::vector xy; + shown->gather(0u, 0u, 1u, xy); + const std::size_t head = xy.size(); + shown->gather(0u, n - 1, n, xy); + if (head >= 2 && xy.size() >= head + 2) { + drawn_lo = shown->x0() + double(xy[0]); + drawn_hi = shown->x0() + double(xy[head]); + data_lo = shown->x_at(0); + data_hi = shown->x_at(n - 1); + } + } + + double snap_lo = 0.0; + double snap_hi = 0.0; + std::size_t snap_n = 0; + if (auto snap = m.event()) { + snap_lo = -snap->pre_s; + snap_hi = snap->post_s; + snap_n = snap->stamps.size(); + } + plot::Range axis{0.0, 0.0}; + if (a->rows->row_count() > 0 && a->rows->row(0).plot != nullptr) { + const RowSet::Row& r0 = a->rows->row(0); + axis = r0.plot->x_range(r0.axis_x); + } + // The renderer's own index window, recomputed exactly as `Renderer::draw_traces` + // does it: it maps the visible x range onto sample indices with + // `(x - x0()) / dx()` -- a *uniform grid* formula -- and gathers only + // `[first, last)`. `ChannelBuffer`'s x is a streamed timestamp, so that formula is + // only ever as good as `dx()` describing the real local spacing. `want=` is the + // same window found by binary search over the real time ring, which is what + // `x_at()`/`neighbours()` -- and therefore the crosshair -- use. + // + // **`got=` must contain `want=`.** When `dx()` comes out larger than the real + // spacing every index is overestimated, `first` lands past the first visible + // sample, and the samples between the two are in the buffer, readable by the + // crosshair, and never drawn: the view's older part goes blank while its newest + // part looks perfectly fine. `dx_ratio` is the cause in one number -- it is 1.0 + // when the grid assumption holds, and it inflates whenever a stamp inside the + // window is wrong (a sample whose time slot the ring already dropped reads 0). + long long got_lo = -1, got_hi = -1, want_lo = -1, want_hi = -1; + double dx_nominal = 0.0, dx_real = 0.0, bound_lo = 0.0, bound_hi = 0.0; + if (shown != nullptr && shown->size() > 0) { + const u64 n = shown->size(); + const f64 sx0 = shown->x0(); + dx_nominal = shown->dx(); + dx_real = n >= 2 ? shown->x_at(n - 1) - shown->x_at(n - 2) : 0.0; + bound_lo = shown->x_bounds().min; + bound_hi = shown->x_bounds().max; + if (dx_nominal > 0.0) { + const auto clamp_index = [n](double v) -> long long { + if (!(v > 0.0)) return 0; + return v >= double(n) ? (long long)n : (long long)v; + }; + got_lo = clamp_index(std::floor((axis.min - sx0) / dx_nominal) - 1.0); + got_hi = clamp_index(std::ceil((axis.max - sx0) / dx_nominal) + 1.0); + } + u64 i0 = 0, i1 = 0; + want_lo = shown->neighbours(axis.min, i0, i1) ? (long long)i0 + : (axis.min < shown->x_at(0) ? 0 : (long long)n); + want_hi = shown->neighbours(axis.max, i0, i1) ? (long long)i1 + 1 + : (axis.max < shown->x_at(0) ? 0 : (long long)n); + } + std::fprintf(stderr, + "[trig-probe/idx] n=%llu bounds=[%.4f..%.4f] dx=%.6g dx_real=%.6g " + "dx_ratio=%.3f got=[%lld..%lld) want=[%lld..%lld)\n", + static_cast(shown != nullptr ? shown->size() : 0), bound_lo, + bound_hi, dx_nominal, dx_real, + dx_real > 0.0 ? dx_nominal / dx_real : 0.0, got_lo, got_hi, want_lo, want_hi); + std::fprintf(stderr, + "[trig-probe] trigger=%s pending=%d display=%s latches=%llu armed=%d " + "live_pub=%llu snap=[%.4f..%.4f] n=%zu axis=[%.4f..%.4f] " + "data=[%.4f..%.4f] drawn=[%.4f..%.4f]\n", + m.trigger_enabled() ? "on" : "off", int(m.trigger_pending()), display, + static_cast(m.trigger_latches().load()), + int(m.trigger_armed().load()), + static_cast(m.live().values(0).produced()), snap_lo, snap_hi, + snap_n, double(axis.min), double(axis.max), data_lo, data_hi, drawn_lo, drawn_hi); + std::fflush(stderr); + return G_SOURCE_CONTINUE; +} + +/// Headless self-check: arms the trigger the way the toolbar's trigger switch does. +gboolean arm_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + // The toggle itself, not a label search: the header bar's buttons carry icons now, + // so there is no text to find them by. + if (a->trigger_toggle != nullptr) + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); + std::fprintf(stderr, "[arm-probe] trigger armed\n"); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Headless self-check: acquire every signal the CONFIG described, the way the +/// signal panel's switches would, and put each resulting channel on a plot, the way +/// a drag from the panel onto a row would. Without the second half nothing is bound +/// and every downstream number -- trace sizes, painted frames, what the trigger +/// displays -- reads zero whatever the pipeline is doing. +gboolean enable_all_probe(gpointer user) { + App* a = static_cast(user); + for (std::size_t i = 0; i < a->model->signals().size(); ++i) { + a->model->set_signal_enabled(i, true); + } + int row = 0; + int slot = 0; + for (std::size_t c = 0; c < a->model->channel_count(); ++c) { + if (row >= a->rows->row_count()) break; + const ChannelInfo& ch = a->model->channels()[c]; + a->rows->assign(row, slot, ch.name, ch.name, + signal_color_index(a->model->signals(), ch.signal)); + if (++slot >= RowSet::kSlotsPerRow) { + slot = 0; + ++row; + } + } + a->relayout_pending = true; + std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n", + a->model->channel_count()); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +gboolean disarm_probe_step2(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + // The Arm/Disarm button is in the trigger panel, not the title bar: search the + // whole window, and say so when it is not there rather than claiming a click. + GtkWidget* button = find_labelled_button(a->window, "Disarm"); + if (button == nullptr) button = find_labelled_button(a->window, "Arm"); + std::fprintf(stderr, "[disarm-probe] button %s\n", button ? "found, clicking" : "NOT FOUND"); + if (button != nullptr) g_signal_emit_by_name(button, "clicked"); + a->dbg_latches_at_disarm = a->model->trigger_latches().load(); + std::fprintf(stderr, "[disarm-probe] %llu events so far, armed now=%d\n", + (unsigned long long)a->dbg_latches_at_disarm, int(a->model->trigger().armed)); + return G_SOURCE_REMOVE; +} + +gboolean disarm_probe_step1(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + if (a->trigger_toggle != nullptr) + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); + g_timeout_add(2000, disarm_probe_step2, a); + return G_SOURCE_REMOVE; +} + +/// Headless self-check: pause the acquisition and reset the movement sample, so +/// what is measured is a still picture -- the state a wiggle is most visible in. +gboolean pause_probe(gpointer user) { + App* a = static_cast(user); + a->acq->set_paused(true); + a->dbg_axes_x_min = 1 << 20; + a->dbg_axes_x_max = -(1 << 20); + a->dbg_axes_w_min = 1 << 20; + a->dbg_axes_w_max = -(1 << 20); + a->dbg_axes_w_prev = -1; + a->dbg_axes_w_changes = 0; + a->dbg_axes_w_change_frame = -1; + a->dbg_axes_w_change_side = -1; + a->dbg_vp_w_min = 1 << 20; + a->dbg_vp_w_max = -(1 << 20); + a->dbg_xtick_min = 1 << 20; + a->dbg_xtick_max = -(1 << 20); + a->dbg_gutter_min = 1 << 20; + a->dbg_gutter_max = -(1 << 20); + a->dbg_axes_frames = 0; + a->dbg_x_desync = 0; + return G_SOURCE_REMOVE; +} + +gboolean exit_probe_step1(gpointer user) { + App* a = static_cast(user); + // Record the panel states before the toggles are exercised. + a->dbg_trigger_revealed_default = + gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0; + if (a->trigger_toggle != nullptr) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); + } + g_timeout_add(1700, exit_probe_step2, a); + return G_SOURCE_REMOVE; +} + +} // namespace + +int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& rows) { + (void)argc; + App app; + app.model = &model; + app.acq = &acq; + app.rows = &rows; + + GtkApplication* application = + gtk_application_new("org.udpscope.app", G_APPLICATION_DEFAULT_FLAGS); + g_signal_connect(application, "activate", G_CALLBACK(on_activate), &app); + + // Headless self-check: exercise the toolbar's toggles, then exit and print what + // the pipeline produced, so a CI run can assert the stream actually arrived and + // both panels were laid out. + double exit_after_ms = 0.0; + if (const char* env = g_getenv("UDPSCOPE_EXIT_AFTER_MS")) exit_after_ms = std::atof(env); + if (exit_after_ms > 0.0) { + g_timeout_add(static_cast(exit_after_ms), exit_probe_step1, &app); + } + if (const char* env = g_getenv("UDPSCOPE_ENABLE_ALL_MS")) { + g_timeout_add(static_cast(std::atoi(env)), enable_all_probe, &app); + } + if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) { + g_timeout_add(static_cast(std::atoi(env)), disarm_probe_step1, &app); + } + if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) { + g_timeout_add(static_cast(std::atoi(env)), arm_probe, &app); + } + if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) { + g_timeout_add(static_cast(std::atoi(env)), trig_trace_probe, &app); + } + if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) { + g_timeout_add(static_cast(std::atoi(env)), pause_probe, &app); + } + + // Our own options were consumed by parse_args(); GTK would reject them, so only + // the program name is handed to g_application_run(). + const int result = + g_application_run(G_APPLICATION(application), 1, const_cast(argv)); + g_object_unref(application); + + if (exit_after_ms > 0.0) { + const Partition& part = model.partition(); + std::printf("[udpscope] frames=%llu packets=%llu gaps=%llu channels=%zu span=%.3fs " + "stride=%llu\n", + static_cast(model.stats().frames.load()), + static_cast(model.stats().packets.load()), + static_cast(model.stats().gaps.load()), + model.channel_count(), part.span_s, + static_cast(part.time_stride)); + for (std::size_t i = 0; i < model.channel_count(); ++i) { + std::printf("[udpscope] channel %zu '%s' stored=%llu published=%llu\n", i, + model.channels()[i].name.c_str(), + static_cast(model.live().values(i).produced()), + static_cast(model.live().channel(i).size())); + } + // `frames_painted` is the area's, read from the snapshot taken before the window was + // destroyed (the PlotArea wrapper dies with its widget): one figure, one render per + // frame, so it is the same count for every row. + for (int i = 0; i < rows.row_count() && i < RowSet::kMaxRows; ++i) { + if (rows.row(i).plot == nullptr) continue; + std::printf("[udpscope] plot %d frames_painted=%d traces=%u\n", i, + app.dbg_frames_painted, rows.row(i).plot->trace_count()); + } + // Layout stability: every one of these must be a single value. A range means + // something is resizing or re-measuring the plot, which is what a wiggling axis + // looks like from the outside. + std::printf("[udpscope] layout over %d frames: axes.x %d..%d, axes.w %d..%d, " + "viewport.w %d..%d, y-gutter %d..%d, x-ticks %d..%d, x-desync %d\n", + app.dbg_axes_frames, app.dbg_axes_x_min, app.dbg_axes_x_max, app.dbg_axes_w_min, + app.dbg_axes_w_max, app.dbg_vp_w_min, app.dbg_vp_w_max, app.dbg_gutter_min, + app.dbg_gutter_max, app.dbg_xtick_min, app.dbg_xtick_max, app.dbg_x_desync); + std::printf("[udpscope] axes.w changes: %d (last at frame %d, side panel up=%d)\n", + app.dbg_axes_w_changes, app.dbg_axes_w_change_frame, + app.dbg_axes_w_change_side); + std::printf("[udpscope] reset button visible: %d\n", app.dbg_reset_visible); + std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames, + app.dbg_axes_frames); + std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it " + "means Disarm reached the engine)\n", + static_cast(model.trigger_latches().load()), + static_cast(app.dbg_latches_at_disarm)); + std::printf("[udpscope] window dropdown: '%s'\n", app.dbg_window_drop_text.c_str()); + std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d " + "(revealed=%d) trigger=%dx%d (revealed=%d) toggles=%d cursors=%d window=%.3fs " + "choices_cap=%.1fs\n", + app.dbg_side_w, app.dbg_side_h, app.dbg_side_visible, app.dbg_signal_w, + app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_revealed, + app.dbg_trigger_w, app.dbg_trigger_h, + app.dbg_trigger_revealed, app.dbg_toggles_visible, + rows.row(0).cursors_visible ? 1 : 0, rows.time_window(), + app.window_choices_limit); + std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str()); + std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f " + "valid=%d\n", + app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time, + app.dbg_trigger_time_valid); + std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str()); + std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; " + "signal list items=%d selected=%d; column visible after sidebar " + "toggle off=%d\n", + app.dbg_trigger_revealed_default, app.dbg_trigger_items, + app.dbg_trigger_selected, app.dbg_side_scroll_after_hide); + { + plot::Plot& p = *rows.row(0).plot; + const plot::PlotLayout& lay = p.layout(); + int visible = 0; + for (plot::u32 t = 0; t < p.trace_count(); ++t) { + if (p.trace(t).visible()) ++visible; + } + std::printf("[udpscope] row 0 plot: x=[%.3f,%.3f] y=[%.3f,%.3f] axes=%.0fx%.0f " + "visible_traces=%d legend_rows=%zu legend_panel=%.0fx%.0f offset=%.3f " + "trigger_marker_x=%.3f\n", + p.x_range(0).min, p.x_range(0).max, p.y_range(0).min, p.y_range(0).max, + double(lay.axes.w), double(lay.axes.h), visible, lay.legend.rows.size(), + double(lay.legend.panel.w), double(lay.legend.panel.h), p.x_offset(0), + p.cursor(rows.row(0).trigger_marker).x); + for (plot::u32 t = 0; t < p.trace_count(); ++t) { + const plot::Trace& tr = p.trace(t); + if (tr.series == nullptr) continue; + std::printf("[udpscope] trace %u '%s' size=%llu x0=%.3f dx=%.6f bounds=[%.3f,%.3f]\n", + t, tr.name.c_str(), static_cast(tr.series->size()), + tr.series->x0(), tr.series->dx(), tr.series->x_bounds().min, + tr.series->x_bounds().max); + } + } + for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) { + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + const int ch = rows.channel_of(r, s); + if (ch < 0) continue; + ChannelBuffer* buf = rows.row(r).slots[static_cast(s)].bound(); + std::printf("[udpscope] row %d slot %d -> channel %d '%s' samples=%llu\n", r, s, ch, + rows.row(r).name[static_cast(s)].c_str(), + static_cast(buf != nullptr ? buf->size() : 0)); + } + } + } + return result; +} + +} // namespace udpscope diff --git a/src/ui.hpp b/src/ui.hpp new file mode 100644 index 0000000..049c7dc --- /dev/null +++ b/src/ui.hpp @@ -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 diff --git a/tools/mock_udpstreamer.py b/tools/mock_udpstreamer.py new file mode 100644 index 0000000..fa465f3 --- /dev/null +++ b/tools/mock_udpstreamer.py @@ -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("= 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("= 17: + magic, ptype = struct.unpack_from(" 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()