From 4d859d6a37bbe82fcde60f8c1796b7b6e7433249 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 20:22:22 +0100 Subject: [PATCH] docs: the source-plugin framework, the --source options and the source-kinds probe Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 7 ++++--- README.md | 22 +++++++++++++++++++-- docs/claude/architecture.md | 26 ++++++++++++++++++++++-- docs/claude/build-and-run.md | 7 ++++++- docs/claude/probe-readings.md | 37 +++++++++++++++++++++++++++++++++-- docs/claude/verification.md | 8 +++++++- 6 files changed, 96 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 312d49e..d93414c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this project is udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one -UDP stream with `deps/udpsclient`, buffers it in a large ring (default 200 MB), and draws it +data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered +source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 200 MB), and draws it with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of the live view it provides the things a bench scope provides: a trigger with a pre/post event window, a pause that freezes the visible window, cursors, statistical measurements, @@ -20,7 +21,7 @@ Two documents bound the work and are **not** interchangeable with this one: Three things shape almost every change here, and the linked files expand on each: -- **There are exactly two threads** — a producer that owns the UDP client and the GTK main +- **There are exactly two threads** — a producer that owns the data source and the GTK main thread that owns every widget and the GL context — and they share `ScopeModel` under one mutex. Samples cross through lock-free rings, never through the lock. - **There is no unit test suite.** The only automated check is the application's own headless @@ -45,7 +46,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e | File | What it covers | | --- | --- | -| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`). | +| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`); the source plugins. | ### Invariants worth knowing before editing diff --git a/README.md b/README.md index 64bbd6b..231e806 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` window the toolbar offers, default 600), `--holdoff` (default trigger hold-off), `--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are added and removed from the **Layout editor** afterwards), `--relative-time` (see below), +`--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; see "Data sources" below), `--capture-packets N` (see "Packet capture" below), `--renderer NAME` (GTK's renderer: `cairo`, `gl` or `vulkan`; `cairo` is the software preset that exports both `GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH @@ -98,13 +99,30 @@ src/event_series.hpp an immutable captured event as a plot::data::Series 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. +Threading: one producer thread owns the data source (the UDPStreamer client is the built-in +one) and calls `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. +## Data sources + +What udpscope shows is fed by a *source kind*; the connection chooser's **Source type** row picks one and +swaps in that kind's own connection form. The built-in kind is **UDPS**, the UDPStreamer client this +document describes everywhere else, and it is the default: `--server`, `--port`, `--multicast` and +`--interface` populate its entries, and the same thing is spelled `--source udps --source-opt server=...`. +`--help` lists the registered kinds. A second kind, `probe` (a synthetic sine with one panel), is registered +only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-check. + +A session file written before kinds existed has no `source_kind`, and is read as UDPS; a UDPS file is +written exactly as before. A kind's capability panels (for instance a source's own controls) appear as +pages of the side stack, and are insensitive rather than hidden while no source is connected. + +Planned, each as its own sub-project: a flat UDP kind, the MCC E-1608 and ABScope. ABScope has more than one +timebase, so it will change the frame and the data path together rather than fit the present single-timebase +frame. + ## The x axis is relative to now **A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index c97252b..84fe7e0 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -10,8 +10,8 @@ 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 +Exactly two: a producer thread in `Acquisition` owns a `DataSource` and calls +`source->poll(50)`; 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 @@ -21,6 +21,28 @@ consumer) and never through the lock. Cross-thread scalars are atomics on `Scope before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a capture open sees which edge it belongs to. +### Sources + +What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. + +- The kinds live in `src/source/`. `register_builtin_sources()` fills the registry explicitly -- + there is no static-initialiser registration, which a linker may drop -- and a future `dlopen` + loader would only call `register_kind()`. +- A `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`. `Frame` is + source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept. +- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained + at the top of `poll()`. A panel never calls a source directly. +- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in + `src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling). +- The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and + the data path together; do not bend one frame to carry two clocks. +- A kind's capability panels are pages of `side_stack`, named `source-`. A disconnected source + leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`). +- Session file naming for a non-UDPS kind: the pseudo-host is `-` (characters outside + `[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is + stored as `source_kind` and its settings as `source_opt_`. A UDPS file keeps `_` and + has neither key, so a legacy session is read as UDPS and nothing existing moves. + ### Data path `ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index 5e23f4a..31ca56d 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -13,8 +13,11 @@ 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 +./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port ``` +`--help` lists the registered source kinds. + The app can be launched from the shell rather than only from a terminal, but only on request: ```sh @@ -96,7 +99,9 @@ prefix-wide target *is* that rule, reached through the `Help` component (`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every component, so its behaviour is unchanged. -Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per +Options: `--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; +`--server`, `--port`, `--multicast` and `--interface` populate the UDPS entries of the same options), +`--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards), `--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000, diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 0f20a67..0de0a95 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1824,6 +1824,38 @@ are unrealized and measure 0x0. - `rows` is the measurement count and `accums` the accumulator count; the two must agree, because one accumulator per measurement row is what makes `advanced` a statement about every row. +## `[source-kinds]` + +``` +[source-kinds] kinds=udps,probe +[source-kinds] form udps: round_trip=1 valid=1 +[source-kinds] form probe: round_trip=1 valid=1 +[source-kinds] panel disconnected: panels=1 present=1 sensitive=0 +[source-kinds] panel connected: panels=1 present=1 sensitive=1 +[source-kinds] capture button: present=1 sensitive=0 +[source-kinds] command: before=0 after=1 drained=1 +``` + +`UDPSCOPE_PROBE_SOURCE_KINDS=2000`, run as `--source probe --source-opt rate=500`, is the one reading for the +source-plugin path from the registry to the producer thread. The variable also registers the test-only `probe` +kind, so a run without it never shows that kind. The `form` line appears once per registered kind. + +- `kinds=udps,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`, + or the env var gate is wrong. +- `round_trip=0`: a form's `write` and `read` disagree about a key -- a renamed `SourceConfig` key, or an + absent key that was not defaulted. `valid=0` is a default configuration the form refuses. +- `panel disconnected: sensitive=0 present=1`: **disable, don't hide.** `present=0` is a panel that was hidden + or never added; `sensitive=1` is a panel that stays live with no source behind it. +- `panel connected: sensitive=1`: anything else means `apply_source_panels` acted on the wrong widget (it must + act on the kind's own widget, not the scroller around it). +- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI **disabled, not + hidden.** `sensitive=1` is the button live for a source that has nothing to capture, `present=0` a hidden one. +- `command: before=0 after=1 drained=1`: the command reached the producer thread through `post()`. + `drained=0` is a queue that `poll()` does not drain on some path; `after` greater than `before + 1` is a + command run twice. + +The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no panels. + ## `[prefs-probe]` ``` @@ -1901,14 +1933,15 @@ copy and never writes is exactly the failure this has to catch. ``` [prefs-probe] mnemonics=4/4 -[sources] form mnemonics=8/8 +[sources] form mnemonics=9/9 ``` How many of the dialog's rows have `use-underline` set -- the property that makes a `_` in a row's title a mnemonic. It is FALSE on `AdwPreferencesRow` by default, which is why these titles were drawn with a literal underscore; anything below the full count is that bug back. Read by property name rather than through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form's line appears only when -`UDPSCOPE_PROBE_NEWHOST` drives the form. +`UDPSCOPE_PROBE_NEWHOST` drives the form. It reads 9/9 since the chooser gained its Source type row, which has a +mnemonic of its own (a non-UDPS kind's form has fewer rows: the `probe` kind reads 6/6). ## `trigger panel:` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 87525f4..1c71ee1 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -13,7 +13,9 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ ./build/udpscope --server 127.0.0.1 --port 8000 ``` -Two baselines every run is read against. A clean build emits **three** warnings and no more: two +Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**: +a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it. +A clean build emits **three** warnings and no more: two `-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons' generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with the seven action icons and the two app icons that remain (a note about a generated file, which GCC @@ -418,6 +420,10 @@ precedent, not an exception. defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other probe ahead of it. The reading's own entry is in `probe-readings.md`. +- `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500` + -- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a + synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms. + Seven `[source-kinds]` lines; the reading's own entry is in `probe-readings.md`. - `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.