diff --git a/docs/superpowers/plans/2026-10-31-source-plugins.md b/docs/superpowers/plans/2026-10-31-source-plugins.md new file mode 100644 index 0000000..6d28036 --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-source-plugins.md @@ -0,0 +1,2277 @@ +# Source Plugins: Framework and UDPS Port Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let udpscope read from pluggable data sources behind one `DataSource` interface, with the existing UDPStreamer client ported onto it and no change in UDPS behaviour. + +**Architecture:** A compiled-in `SourceRegistry` of `SourceKind` factories. `Acquisition` owns one `DataSource` and still runs the single producer thread, calling `source->poll(50)`; the GTK thread reaches the source only through `post(Command)`, a mutex-guarded queue drained inside `poll()`. A source-neutral `Frame` replaces `udps_frame_t`; each kind supplies a config form (`ConfigForm`) for the chooser and optional capability panels that become pages of the existing `side_stack`. + +**Tech Stack:** C++17, GTK4 + libadwaita, CMake, `deps/udpsclient` (wrapped, unchanged), `deps/plotter`. Python mock `tools/mock_udpstreamer.py`. + +**Spec:** `docs/superpowers/specs/2026-10-31-source-plugins-design.md` (Task 0 amends it with the implementation details settled here). + +## Global Constraints + +- There is **no unit test suite.** The only automated check is the headless self-check under `xvfb-run` against `tools/mock_udpstreamer.py`; extend the probes in `src/ui_probe.cpp` rather than adding a harness. Read `docs/claude/verification.md` and `docs/claude/probe-readings.md` before running one. +- A clean full build has **exactly three warnings**; an incremental build shows one. The run must show `gtk warnings: 0` and no `Gtk-CRITICAL`. +- Every new source file starts with `// SPDX-License-Identifier: MIT` and `// SPDX-FileCopyrightText: 2026 Martino Ferrari`. +- Exactly two threads: the producer (owns the source) and the GTK main thread. UI code never touches a `DataSource` directly except `post()`. +- UDPS behaviour must not change: `--server/--port/--multicast/--interface` keep meaning UDPS; legacy `host_port.toml` session files are read as `udps`; nothing is migrated destructively. +- Disable, don't hide: a panel for a disconnected source is insensitive, not hidden. +- GTK traps (see `docs/claude/invariants-gtk-and-chrome.md`): `notify::` handlers take `(GObject*, GParamSpec*, gpointer)`; probes click with `g_signal_emit_by_name(w, "clicked")`. +- Verification runs are short and serial; kill the mock afterwards; never point a run at the real `~/.config` (use `XDG_CONFIG_HOME=$(mktemp -d)`). +- Mock: `python3 tools/mock_udpstreamer.py --port 8000 &`. Self-check: `xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 ./build/udpscope --server 127.0.0.1 --port 8000` (dump on stdout, probes on stderr). +- Work happens on branch `feature/source-plugins`. Commit from the repo root. + +## File Structure + +New (all under `src/source/` unless noted): + +| File | Responsibility | +| --- | --- | +| `source_config.hpp/.cpp` | `SourceConfig`: ordered string key/value map with typed getters. | +| `frame.hpp` | Source-neutral `Frame` and `SignalValues` (header-only). | +| `data_source.hpp/.cpp` | `Command`, `SourceStats`, `SourceEvent`, `SourceSink`, `DatagramTap`, `DataSource` (command queue). | +| `source_kind.hpp` | `ConfigForm`, `SourcePanel`, `SourceKind` (includes GTK). | +| `registry.hpp/.cpp` | Registry functions, `register_builtin_sources()`, session-key helpers. | +| `udps_source.hpp/.cpp` | `UdpsSource`: wraps `deps/udpsclient`. | +| `udps_kind.cpp` | `UdpsKind`: id, identity, config form. | +| `probe_source.hpp/.cpp` | Test-only `probe` kind with one panel (registered only when the probe env var is set). | +| `src/ui_source.cpp` | Builds and syncs capability panels in the side stack. | + +Modified: `src/model.hpp`, `src/model.cpp`, `src/acquisition.hpp/.cpp`, `src/config.hpp/.cpp`, `src/main.cpp`, `src/layout_store.hpp/.cpp`, `src/source_dialog.cpp`, `src/ui/source-form.ui`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, and the docs. + +--- + +## Task 0: Baseline and spec amendment + +**Files:** +- Modify: `docs/superpowers/specs/2026-10-31-source-plugins-design.md` +- Create (outside repo): `/tmp/udps-baseline.out`, `/tmp/udps-baseline.err` + +**Interfaces:** Produces the baseline files every later UDPS check is compared against. + +- [ ] **Step 1: Build main's behaviour (the current branch has no code changes yet)** + +Run: `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:"` +Expected: a number (3 on a clean build, 1 if incremental). Note it. + +- [ ] **Step 2: Capture the baseline self-check** + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-baseline.out 2>/tmp/udps-baseline.err +kill $MOCK +grep -c "Gtk-CRITICAL" /tmp/udps-baseline.err; grep "gtk warnings" /tmp/udps-baseline.err +``` +Expected: `0`, then `gtk warnings: 0`. + +- [ ] **Step 2b: Capture the chooser baseline** + +The chooser's form probes (see `docs/claude/probe-readings.md`, "The chooser's form") are the reference for Task 5. Same fresh config dir for all three, mock running: + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +CFGB=$(mktemp -d) +echo "$CFGB" >/tmp/chooser-cfg-dir +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-baseline-new.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_EDITHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-baseline-edit.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>/tmp/chooser-baseline-list.err >/dev/null +kill $MOCK +grep -h "^\[sources\]" /tmp/chooser-baseline-new.err /tmp/chooser-baseline-edit.err /tmp/chooser-baseline-list.err +``` +Expected: `form mnemonics=8/8`, `form: name='probe-host' ... connect_sensitive=1`, `configuration created: 127.0.0.1:8000/probe-host`, `edit form: ... button='Save'`, `configuration saved`, and one `known:` row. These are the lines Task 5 must reproduce (apart from `8/8` becoming `9/9`, because the new Source type row has a mnemonic too). + +- [ ] **Step 3: Amend the spec** + +Edit the spec's Decisions/Design text to say: +- the new probe is `UDPSCOPE_PROBE_SOURCE_KINDS` (the name `UDPSCOPE_PROBE_SOURCES` is the chooser discovery probe), its value is a delay in ms, and its presence also registers the stub `probe` kind; +- `Command` is `std::function`; +- session keys are top-level TOML keys (`source_kind`, `source_opt_`), written only for non-UDPS kinds, not a `[source]` table; non-UDPS session files are named by a pseudo-host `-` with port 1; +- `Acquisition` creates its source lazily in `start()`; +- `Frame` holds pointers into the source's buffers, valid only during `on_data`; +- `make_config_form()` takes no argument (the chooser calls `form.write(cfg)` to fill it); `ConfigForm` is a list of `AdwPreferencesRow` widgets plus `write`/`read`/`valid`/`on_change` closures (so the chooser can place them in its own group); +- panels become pages of the existing `side_stack`. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/specs/2026-10-31-source-plugins-design.md +git commit -m "docs: settle source-plugin implementation details in the spec" +``` + +--- + +## Task 1: Core types, registry, neutral constants + +Compile-only: nothing uses the new code yet, so behaviour cannot change. + +**Files:** +- Create: `src/source/source_config.hpp`, `src/source/source_config.cpp`, `src/source/frame.hpp`, `src/source/data_source.hpp`, `src/source/data_source.cpp`, `src/source/source_kind.hpp`, `src/source/registry.hpp`, `src/source/registry.cpp` +- Modify: `src/model.hpp` (constants), `src/model.cpp:649`, `CMakeLists.txt` + +**Interfaces:** +- Produces (all in `namespace udpscope`): + - `SourceConfig` with `using Items = std::vector>;`, `explicit SourceConfig(Items)`, `void set(const std::string&, const std::string&)`, `bool has(const std::string&) const`, `std::string get(const std::string&, const std::string& def = {}) const`, `long get_int(key, long def) const`, `double get_double(key, double def) const`, `bool get_bool(key, bool def) const`, `bool parse_assignment(const std::string& kv)`, `const Items& items() const`, `bool empty() const`, `static bool valid_key(const std::string&)`. + - `Frame`, `SignalValues`, `Command`, `SourceStats`, `SourceEvent`, `SourceSink`, `DatagramTap`, `DataSource`, `ConfigForm`, `SourcePanel`, `SourceKind`. + - Registry: `void register_kind(std::unique_ptr)`, `SourceKind* find_kind(const std::string&)`, `const std::vector& kinds()`, `void register_builtin_sources()`, `std::string session_host(const std::string& kind, const SourceConfig&)`, `std::uint16_t session_port(const std::string& kind, const SourceConfig&)`. + - `model.hpp`: `constexpr std::uint8_t kTimePacket = 0, kTimeFullArray = 1, kTimeFirstSample = 2, kTimeLastSample = 3;` + +- [ ] **Step 1: `source_config.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include + +namespace udpscope { + +/// A source's settings: an ordered string key/value map. It is the stored form (top-level +/// `source_opt_` entries of a session), the `--source-opt k=v` form, and what a config form +/// reads and writes. Order is insertion order, so a stored session stays diff-friendly. +class SourceConfig { + public: + using Items = std::vector>; + + SourceConfig() = default; + explicit SourceConfig(Items items) : items_(std::move(items)) {} + + /// Replaces the value in place if the key exists, else appends. + void set(const std::string& key, const std::string& value); + bool has(const std::string& key) const; + std::string get(const std::string& key, const std::string& def = {}) const; + long get_int(const std::string& key, long def) const; + double get_double(const std::string& key, double def) const; + /// 1/true/yes/on and 0/false/no/off (case-insensitive); anything else gives `def`. + bool get_bool(const std::string& key, bool def) const; + + /// Parses `k=v`; false (and no change) if there is no `=` or the key is not `valid_key`. + bool parse_assignment(const std::string& kv); + + const Items& items() const noexcept { return items_; } + bool empty() const noexcept { return items_.empty(); } + + /// `[A-Za-z0-9_]+` -- the characters that survive as a TOML bare key suffix. + static bool valid_key(const std::string& key); + + private: + Items items_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `source_config.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +void SourceConfig::set(const std::string& key, const std::string& value) { + for (auto& kv : items_) { + if (kv.first == key) { + kv.second = value; + return; + } + } + items_.emplace_back(key, value); +} + +bool SourceConfig::has(const std::string& key) const { + return std::any_of(items_.begin(), items_.end(), + [&](const auto& kv) { return kv.first == key; }); +} + +std::string SourceConfig::get(const std::string& key, const std::string& def) const { + for (const auto& kv : items_) { + if (kv.first == key) return kv.second; + } + return def; +} + +long SourceConfig::get_int(const std::string& key, long def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const long n = std::strtol(v.c_str(), &end, 10); + return (end == v.c_str() || *end != '\0') ? def : n; +} + +double SourceConfig::get_double(const std::string& key, double def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const double d = std::strtod(v.c_str(), &end); + return (end == v.c_str() || *end != '\0') ? def : d; +} + +bool SourceConfig::get_bool(const std::string& key, bool def) const { + if (!has(key)) return def; + std::string v = get(key); + std::transform(v.begin(), v.end(), v.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (v == "1" || v == "true" || v == "yes" || v == "on") return true; + if (v == "0" || v == "false" || v == "no" || v == "off") return false; + return def; +} + +bool SourceConfig::valid_key(const std::string& key) { + return !key.empty() && std::all_of(key.begin(), key.end(), [](unsigned char c) { + return std::isalnum(c) || c == '_'; + }); +} + +bool SourceConfig::parse_assignment(const std::string& kv) { + const auto eq = kv.find('='); + if (eq == std::string::npos) return false; + const std::string key = kv.substr(0, eq); + if (!valid_key(key)) return false; + set(key, kv.substr(eq + 1)); + return true; +} + +} // namespace udpscope +``` + +- [ ] **Step 3: `frame.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "model.hpp" + +#include + +namespace udpscope { + +/// One signal's values in a frame. `values` points into the source's own buffer and is valid +/// only while the `on_data` call that received the frame is running. +struct SignalValues { + const double* values = nullptr; + std::uint32_t count = 0; +}; + +/// A data frame in source-neutral form; it mirrors `udps_frame_t`. A source with no packet +/// counter supplies a running one and `lost = 0`: the hole, backfill and time-base logic only +/// needs `counter`, `lost` and `late`. +struct Frame { + std::uint32_t counter = 0; + std::uint32_t lost = 0; + bool late = false; + double recv_time = 0.0; + std::uint8_t publish_mode = 0; + std::uint32_t num_samples = 0; + std::uint32_t num_signals = 0; + const SignalInfo* signals = nullptr; + const SignalValues* values = nullptr; + + /// Element `elem` of signal `sig` at sample `sample`. Mirrors `udps_frame_value`: a scalar + /// signal carried once per sample is indexed by `sample`, a vector/matrix signal by `elem`. + /// Out of range reads 0.0. + double value(std::uint32_t sig, std::uint32_t sample, std::uint32_t elem) const noexcept { + if (sig >= num_signals) return 0.0; + const SignalValues& v = values[sig]; + const std::uint32_t idx = + (v.count == num_samples && signals[sig].elements() == 1u) ? sample : elem; + return idx < v.count ? v.values[idx] : 0.0; + } +}; + +} // namespace udpscope +``` + +(`Frame::value` is `udps_frame_value` from `deps/udpsclient/udps_client.c:346` with the pointer replaced by `this`; keep the two identical.) + +- [ ] **Step 4: `data_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/frame.hpp" + +#include +#include +#include +#include +#include +#include + +namespace udpscope { + +/// Work for the producer thread. UI code posts these; `poll()` drains and runs them, so a +/// source's state is only ever touched by the thread that owns it. +using Command = std::function; + +/// The counters `StreamStats` shows, in source-neutral form. Producer thread only. +struct SourceStats { + std::uint64_t packets = 0; + std::uint64_t frames = 0; + std::uint64_t gaps = 0; + std::uint64_t late = 0; + std::uint64_t resequenced = 0; + std::uint64_t stale = 0; + std::uint64_t overflows = 0; + std::uint64_t reconnects = 0; + bool connected = false; +}; + +enum class SourceEvent { Connected, Disconnected, Error }; + +/// Where a source delivers, from inside `poll()`. +class SourceSink { + public: + virtual ~SourceSink() = default; + /// The signal set (re)defined. `publish_mode` is the stream's own publish mode. + virtual void on_config(const std::vector& signals, std::uint8_t publish_mode) = 0; + virtual void on_data(const Frame& frame) = 0; + virtual void on_event(SourceEvent event, const char* message) = 0; +}; + +/// Receives each raw datagram of a UDP-based source: bytes, length, receive time. +using DatagramTap = std::function; + +/// The per-connection object. `Acquisition` owns one and drives it from the producer thread. +class DataSource { + public: + virtual ~DataSource() = default; + + /// Allocates and connects. Throws `std::runtime_error` with a message fit for the user. + /// Does not deliver: CONFIG and `Connected` arrive from `poll()`. + virtual void open(SourceSink& sink) = 0; + /// Runs queued commands, then waits up to `timeout_ms` for work and delivers it. + virtual void poll(int timeout_ms) = 0; + virtual void close() = 0; + /// Producer thread only. + virtual SourceStats stats() const = 0; + virtual bool connected() const = 0; + /// Optional: UDP-based sources feed the raw-packet capture through this. + virtual void set_datagram_tap(DatagramTap) {} + + /// Thread-safe. The command runs inside a later `poll()`. + void post(Command cmd); + + protected: + /// For `poll()` implementations: runs and clears the queue. + void drain_commands(); + + private: + std::mutex cmd_mu_; + std::deque cmds_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 5: `data_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/data_source.hpp" + +#include + +namespace udpscope { + +void DataSource::post(Command cmd) { + std::lock_guard lk(cmd_mu_); + cmds_.push_back(std::move(cmd)); +} + +void DataSource::drain_commands() { + std::deque local; + { + std::lock_guard lk(cmd_mu_); + local.swap(cmds_); + } + for (auto& c : local) c(); +} + +} // namespace udpscope +``` + +- [ ] **Step 6: `source_kind.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { + +/// A kind's configuration UI, as pieces the chooser places in its own group. The widgets are +/// floating `AdwPreferencesRow`s; the closures capture them, so a form must not outlive its rows +/// (the chooser drops the form when it removes the rows). +struct ConfigForm { + std::vector rows; + /// Shown under the group's title; empty for none. + std::string description; + /// The widget that takes focus when the form is shown; may be null. + GtkWidget* focus = nullptr; + /// Puts `cfg` into the widgets (absent keys leave the kind's defaults). + std::function write; + /// The widgets as a `SourceConfig`. + std::function read; + /// Whether the form is complete enough to connect. + std::function valid; + /// Calls `fn` whenever any widget changes. + std::function)> on_change; +}; + +/// A capability panel (signal generator, digital I/O, ...) for the side area. +struct SourcePanel { + std::string id; ///< stack page name + std::string title; + std::string icon; ///< icon name for the tab, may be empty + GtkWidget* widget = nullptr; +}; + +/// One plugin type: its identity, factory and UI. +class SourceKind { + public: + virtual ~SourceKind() = default; + virtual std::string id() const = 0; ///< short, `[a-z0-9]+`; the CLI and session name + virtual std::string display_name() const = 0; + /// A stable, human-readable name for one configured source (`host:port`, a serial number). + virtual std::string identity(const SourceConfig& cfg) const = 0; + virtual std::unique_ptr make(const SourceConfig& cfg) const = 0; + virtual ConfigForm make_config_form() const = 0; + /// Panels for a live source. The widgets must reach the source only through `post()`. + virtual std::vector make_panels(DataSource& source) const { + (void)source; + return {}; + } + /// Whether the source reports raw datagrams (enables the packet-capture UI). + virtual bool has_datagrams() const { return false; } +}; + +} // namespace udpscope +``` + +- [ ] **Step 7: `registry.hpp` and `registry.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/source_kind.hpp" + +#include +#include +#include +#include + +namespace udpscope { + +/// Takes ownership. A second kind with the same id is ignored. +void register_kind(std::unique_ptr kind); +SourceKind* find_kind(const std::string& id); +/// In registration order (the chooser's dropdown order). +const std::vector& kinds(); +/// Registers the built-in kinds; idempotent. No static-init self-registration, which a linker +/// may drop. A later `dlopen` loader would only call `register_kind()`. +void register_builtin_sources(); + +/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`). +std::unique_ptr make_udps_kind(); +std::unique_ptr make_probe_kind(); + +/// The (host, port) pair that names a source's session file. UDPS keeps its legacy +/// `server`/`port`; any other kind is `"-"` (characters outside `[A-Za-z0-9._-]` +/// become `_`) on port 1 -- not 0, which the session-file parser rejects and waveform mode uses. +std::string session_host(const std::string& kind_id, const SourceConfig& cfg); +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg); + +} // namespace udpscope +``` + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" + +#include +#include + +namespace udpscope { +namespace { + +std::vector>& owned() { + static std::vector> v; + return v; +} +std::vector& raw() { + static std::vector v; + return v; +} + +std::string sanitize(const std::string& s) { + std::string out = s; + for (char& c : out) { + const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-'; + if (!ok) c = '_'; + } + return out; +} + +} // namespace + +void register_kind(std::unique_ptr kind) { + if (!kind || find_kind(kind->id())) return; + raw().push_back(kind.get()); + owned().push_back(std::move(kind)); +} + +SourceKind* find_kind(const std::string& id) { + for (SourceKind* k : raw()) { + if (k->id() == id) return k; + } + return nullptr; +} + +const std::vector& kinds() { return raw(); } + +void register_builtin_sources() { + static bool done = false; + if (done) return; + done = true; + register_kind(make_udps_kind()); + // The stub kind exists for the headless source-kinds probe only; a normal run never sees it. + if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind()); +} + +std::string session_host(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") return cfg.get("server", "127.0.0.1"); + SourceKind* k = find_kind(kind_id); + return sanitize(kind_id + "-" + (k ? k->identity(cfg) : std::string("unknown"))); +} + +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") { + const long p = cfg.get_int("port", 8000); + return static_cast(p > 0 && p <= 65535 ? p : 8000); + } + return 1; +} + +} // namespace udpscope +``` + +`make_udps_kind()` (Task 2) and `make_probe_kind()` (Task 6) are declared in `registry.hpp` but not defined yet, so in this task `register_builtin_sources()` must not call them: write its body as only `static bool done = false; if (done) return; done = true;` followed by the two comment lines `// Task 2: register_kind(make_udps_kind());` and `// Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set`. Task 2 Step 6 and Task 6 Step 3 replace them with the real calls. + +- [ ] **Step 8: Neutral time-mode constants** + +In `src/model.hpp`, inside `namespace udpscope`, above `struct SignalInfo`: + +```cpp +/// `SignalInfo::time_mode` values. They equal the wire protocol's `UDPS_TIME_*` +/// (`udps_source.cpp` static_asserts it), but the model must not name a source's own header. +constexpr std::uint8_t kTimePacket = 0; +constexpr std::uint8_t kTimeFullArray = 1; +constexpr std::uint8_t kTimeFirstSample = 2; +constexpr std::uint8_t kTimeLastSample = 3; +``` + +In `src/model.cpp:649` replace `UDPS_TIME_FULL_ARRAY` with `kTimeFullArray`. Check line 6 of `model.cpp` (`#include ` or similar): remove it only if nothing else in `model.cpp` needs a `udps_*` name (`grep -n "udps_\|UDPS_" src/model.cpp`). + +- [ ] **Step 9: CMake** + +In `CMakeLists.txt`, add after `src/acquisition.cpp`: + +``` + src/source/source_config.cpp + src/source/data_source.cpp + src/source/registry.cpp +``` + +- [ ] **Step 10: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; warning count equal to Step 1 of Task 0. + +- [ ] **Step 11: Commit** + +```bash +git add src/source src/model.hpp src/model.cpp CMakeLists.txt +git commit -m "source: DataSource, Frame, SourceConfig and the registry (unused yet)" +``` + +--- + +## Task 2: The UDPS source and kind + +Wraps `deps/udpsclient` unchanged. Compiled and registered, but `Acquisition` does not use it until Task 3, so the running program is still unchanged. + +**Files:** +- Create: `src/source/udps_source.hpp`, `src/source/udps_source.cpp`, `src/source/udps_kind.cpp` +- Modify: `src/source/registry.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: Task 1's `DataSource`, `SourceSink`, `Frame`, `SourceKind`, `ConfigForm`, `SourceConfig`, `kTime*`. +- Produces: `class UdpsSource final : public DataSource` constructed from a `SourceConfig` with keys `server` (default `127.0.0.1`), `port` (8000), `multicast`, `interface`, `recv_buffer` (8388608); `std::unique_ptr make_udps_kind()` with id `"udps"`, display name `"UDPStreamer"`, identity `":"`, `has_datagrams() == true`. + +- [ ] **Step 1: `udps_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include + +namespace udpscope { + +/// The UDPStreamer client behind the `DataSource` interface. A thin adapter: it maps +/// `udps_signal_t`/`udps_frame_t` to `SignalInfo`/`Frame` and forwards the datagram observer; +/// all protocol work stays in `deps/udpsclient`. +class UdpsSource final : public DataSource { + public: + explicit UdpsSource(const SourceConfig& cfg); + ~UdpsSource() override; + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override; + void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); } + + private: + static void config_cb(const udps_signal_t* signals, std::uint32_t count, + std::uint8_t publish_mode, void* user); + static void data_cb(const udps_frame_t* frame, void* user); + static void event_cb(udps_event_t event, const char* detail, void* user); + static void datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time, + void* user); + + std::string server_; + std::uint16_t port_ = 8000; + std::string multicast_; + std::string interface_; + int recv_buffer_ = 8 * 1024 * 1024; + + udps_client_t* client_ = nullptr; + SourceSink* sink_ = nullptr; + DatagramTap tap_; + std::vector signals_; + std::vector values_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `udps_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/udps_source.hpp" + +#include + +namespace udpscope { +namespace { + +static_assert(kTimePacket == UDPS_TIME_PACKET && kTimeFullArray == UDPS_TIME_FULL_ARRAY && + kTimeFirstSample == UDPS_TIME_FIRST_SAMPLE && + kTimeLastSample == UDPS_TIME_LAST_SAMPLE, + "model.hpp's kTime* must equal the wire protocol's UDPS_TIME_*"); + +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 + +UdpsSource::UdpsSource(const SourceConfig& cfg) + : server_(cfg.get("server", "127.0.0.1")), + port_(static_cast(cfg.get_int("port", 8000))), + multicast_(cfg.get("multicast")), + interface_(cfg.get("interface")), + recv_buffer_(static_cast(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {} + +UdpsSource::~UdpsSource() { close(); } + +void UdpsSource::open(SourceSink& sink) { + close(); + sink_ = &sink; + udps_client_config_t c; + udps_client_config_init(&c); + c.server_addr = server_.c_str(); + c.server_port = port_; + c.multicast_group = multicast_.empty() ? nullptr : multicast_.c_str(); + c.interface_addr = interface_.empty() ? nullptr : interface_.c_str(); + c.recv_buffer_bytes = static_cast(recv_buffer_); + 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 " + server_ + ":" + + std::to_string(port_)); + } + udps_client_set_callbacks(client_, &UdpsSource::config_cb, &UdpsSource::data_cb, + &UdpsSource::event_cb, this); + udps_client_set_datagram_cb(client_, &UdpsSource::datagram_cb, this); +} + +void UdpsSource::poll(int timeout_ms) { + drain_commands(); + if (client_ != nullptr) udps_client_poll(client_, timeout_ms); +} + +void UdpsSource::close() { + if (client_ != nullptr) { + udps_client_destroy(client_); + client_ = nullptr; + } +} + +SourceStats UdpsSource::stats() const { + SourceStats out; + if (client_ == nullptr) return out; + udps_stats_t s; + udps_client_stats(client_, &s); + out.packets = s.packets_received; + out.frames = s.frames_delivered; + out.gaps = s.counter_gaps; + out.late = s.late_delivered; + out.resequenced = s.late_resequenced; + out.stale = s.stale_packets; + out.overflows = s.reorder_overflows; + out.reconnects = s.reconnects; + out.connected = udps_client_is_connected(client_) != 0; + return out; +} + +bool UdpsSource::connected() const { + return client_ != nullptr && udps_client_is_connected(client_) != 0; +} + +void UdpsSource::config_cb(const udps_signal_t* signals, std::uint32_t count, + std::uint8_t publish_mode, void* user) { + auto* self = static_cast(user); + self->signals_.clear(); + self->signals_.reserve(count); + for (std::uint32_t i = 0; i < count; ++i) self->signals_.push_back(to_info(signals[i])); + self->sink_->on_config(self->signals_, publish_mode); +} + +void UdpsSource::data_cb(const udps_frame_t* f, void* user) { + auto* self = static_cast(user); + // The frame's signals are the CONFIG's; the cache is rebuilt only if a frame ever disagrees. + if (self->signals_.size() != f->num_signals) { + self->signals_.clear(); + for (std::uint32_t i = 0; i < f->num_signals; ++i) { + self->signals_.push_back(to_info(f->signals[i])); + } + } + self->values_.resize(f->num_signals); + for (std::uint32_t i = 0; i < f->num_signals; ++i) { + self->values_[i].values = f->values[i].values; + self->values_[i].count = f->values[i].count; + } + Frame fr; + fr.counter = f->counter; + fr.lost = f->lost; + fr.late = f->late != 0; + fr.recv_time = f->recv_time; + fr.publish_mode = f->publish_mode; + fr.num_samples = f->num_samples; + fr.num_signals = f->num_signals; + fr.signals = self->signals_.data(); + fr.values = self->values_.data(); + self->sink_->on_data(fr); +} + +void UdpsSource::event_cb(udps_event_t event, const char* detail, void* user) { + auto* self = static_cast(user); + SourceEvent e = SourceEvent::Error; + if (event == UDPS_EVENT_CONNECTED) e = SourceEvent::Connected; + else if (event == UDPS_EVENT_DISCONNECTED) e = SourceEvent::Disconnected; + self->sink_->on_event(e, detail); +} + +void UdpsSource::datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time, + void* user) { + auto* self = static_cast(user); + if (self->tap_) self->tap_(bytes, len, recv_time); +} + +} // namespace udpscope +``` + +- [ ] **Step 3: `udps_kind.cpp`, the kind and its form** + +The four rows keep the titles, mnemonics and defaults of `src/ui/source-form.ui` (Server, Port 1..65535 default 8000, Multicast group, Interface) so the chooser reads the same. + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" +#include "source/udps_source.hpp" + +#include + +#include + +namespace udpscope { +namespace { + +using ChangeCb = std::shared_ptr>; + +GtkWidget* entry_row(const char* title) { + GtkWidget* r = adw_entry_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + return r; +} + +void on_edited(GtkEditable*, gpointer user) { + const auto& cb = *static_cast(user); + if (cb && *cb) (*cb)(); +} + +void hook(GtkWidget* entry, const ChangeCb& cb) { + g_signal_connect_data(entry, "changed", G_CALLBACK(on_edited), new ChangeCb(cb), + +[](gpointer d, GClosure*) { delete static_cast(d); }, + static_cast(0)); +} + +std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); } + +class UdpsKind final : public SourceKind { + public: + std::string id() const override { return "udps"; } + std::string display_name() const override { return "UDPStreamer"; } + bool has_datagrams() const override { return true; } + + std::string identity(const SourceConfig& cfg) const override { + return cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 8000)); + } + + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + GtkWidget* server = entry_row("_Server"); + GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port"); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE); + adw_spin_row_set_value(ADW_SPIN_ROW(port), 8000); + GtkWidget* group = entry_row("Multicast _group"); + GtkWidget* iface = entry_row("_Interface"); + + ChangeCb cb = std::make_shared>(); + hook(server, cb); + hook(group, cb); + hook(iface, cb); + + ConfigForm f; + f.rows = {server, port, group, iface}; + f.description = "Leave the multicast group and the interface empty for unicast on the default route"; + f.focus = server; + f.write = [=](const SourceConfig& c) { + gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str()); + adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast(c.get_int("port", 8000))); + gtk_editable_set_text(GTK_EDITABLE(group), c.get("multicast").c_str()); + gtk_editable_set_text(GTK_EDITABLE(iface), c.get("interface").c_str()); + }; + f.read = [=]() { + SourceConfig c; + c.set("server", text_of(server)); + c.set("port", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(port))))); + if (!text_of(group).empty()) c.set("multicast", text_of(group)); + if (!text_of(iface).empty()) c.set("interface", text_of(iface)); + return c; + }; + f.valid = [=]() { return !text_of(server).empty(); }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_udps_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +- [ ] **Step 4: Register it** + +In `src/source/registry.cpp` replace the `// Task 2:` comment with `register_kind(make_udps_kind());`. + +- [ ] **Step 5: CMake** + +Add after the Task 1 entries: + +``` + src/source/udps_source.cpp + src/source/udps_kind.cpp +``` + +- [ ] **Step 6: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; same warning count as Task 0. + +- [ ] **Step 7: Commit** + +```bash +git add src/source CMakeLists.txt +git commit -m "source: the UDPS client behind DataSource, with its config form" +``` + +--- + +## Task 3: Acquisition, Config and CLI on the registry + +The switch-over. After this task the program reads UDPS through `DataSource` and the self-check must read exactly like the Task 0 baseline. + +**Files:** +- Modify: `src/acquisition.hpp`, `src/acquisition.cpp`, `src/config.hpp`, `src/config.cpp`, `src/main.cpp`, `src/ui.cpp` (lines ~2497, ~5398, ~5917), `src/ui_probe.cpp` (~5414), `src/source_dialog.cpp` (lines ~35-38, ~1132-1135, ~1199) + +**Interfaces:** +- Consumes: Task 1 and 2 (`find_kind`, `session_host`, `session_port`, `Frame`, `SourceSink`, `kTime*`). +- Produces: + - `Config::source_kind` (`"udps"`), `Config::source_specified`, `Config::source_opts` (`SourceConfig`), `bool Config::has_source() const noexcept`, `SourceConfig Config::source_config() const`, `std::string Config::session_host() const`, `std::uint16_t Config::session_port() const`. + - `Acquisition::source()` returning `DataSource*` (null before `start()`). + - CLI: `--source NAME`, repeatable `--source-opt k=v`. + +- [ ] **Step 1: `config.hpp`** + +Add `#include "source/source_config.hpp"` after ``. After `server_specified` add: + +```cpp + /// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/ + /// --multicast/--interface` keep meaning UDPS. + std::string source_kind = "udps"; + /// Whether `--source` was given (the other half of "did the user choose a source"). + bool source_specified = false; + /// Repeatable `--source-opt k=v`: the kind's own settings. For `udps` they overlay the fields above. + SourceConfig source_opts; + + /// A source was named on the command line (`--server` or `--source`), so the chooser is skipped. + bool has_source() const noexcept { return server_specified || source_specified; } + /// The settings the kind's `make()` receives: for UDPS the four connection fields plus the receive + /// buffer, then `source_opts` on top. + SourceConfig source_config() const; + /// The (host, port) that name this source's session file; see `session_host()` in the registry. + std::string session_host() const; + std::uint16_t session_port() const; +``` + +- [ ] **Step 2: `config.cpp`** + +Add `#include "source/registry.hpp"` after the `plot_host.hpp` include. Add before `print_usage`: + +```cpp +SourceConfig Config::source_config() const { + SourceConfig out; + if (source_kind == "udps") { + out.set("server", server); + out.set("port", std::to_string(port)); + if (!multicast.empty()) out.set("multicast", multicast); + if (!interface_addr.empty()) out.set("interface", interface_addr); + out.set("recv_buffer", std::to_string(recv_buffer_bytes)); + } + for (const auto& kv : source_opts.items()) out.set(kv.first, kv.second); + return out; +} + +std::string Config::session_host() const { return udpscope::session_host(source_kind, source_config()); } +std::uint16_t Config::session_port() const { return udpscope::session_port(source_kind, source_config()); } +``` + +In `print_usage`, after the `--interface` line add (and extend the usage line with `[--source NAME]`): + +```cpp + std::string names; + for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id(); +``` +placed before the `fprintf`, and inside the format string after `--interface`: + +``` + " --source NAME source kind (%s; default udps). --server/--port/\n" + " --multicast/--interface configure udps\n" + " --source-opt K=V a setting for the source kind; repeatable\n" +``` +with `names.c_str()` inserted as the second `fprintf` argument (after `argv0`; the first `%s` is `argv0`, so keep argument order matching the format). + +In `parse_args`, before `--buffer-mb`: + +```cpp + } else if (a == "--source" || a.rfind("--source=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--source"); + if (find_kind(v) == nullptr) { + std::string names; + for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id(); + fail("unknown source '" + v + "' (available: " + names + ")"); + } + c.source_kind = v; + c.source_specified = true; + } else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--source-opt"); + if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v); +``` + +(`--source=` does not match `--source-opt=`: the prefix includes `=`.) + +- [ ] **Step 3: `main.cpp`** + +Add `#include "source/registry.hpp"`. Immediately before `cfg = udpscope::parse_args(argc, argv);` (line ~21) add `udpscope::register_builtin_sources();`. Replace `cfg.server_specified` with `cfg.has_source()` at lines ~60 and ~66. + +- [ ] **Step 4: `acquisition.hpp`** + +- Replace `#include ` with `#include "source/data_source.hpp"` and add `#include `. +- Change `class Acquisition {` to `class Acquisition : private SourceSink {`. +- Doc of the class: "Owns the data source and the producer thread...". +- Add to the public section: + +```cpp + /// The source this acquisition drives, for the UI's capability panels: it may only call + /// `post()` on it. Null until `start()`. + DataSource* source() noexcept { return source_.get(); } +``` +- Replace the three `on_*` declarations and the four trampoline declarations (lines ~71-80) with: + +```cpp + void on_config(const std::vector& signals, std::uint8_t publish_mode) override; + void on_data(const Frame& frame) override; + void on_event(SourceEvent event, const char* detail) override; +``` +- `build_stamps(const Frame& frame, int time_signal, bool counter_gap_refused)` and `backfill_from(const Frame& frame, ...)`. +- Replace `udps_client* client_ = nullptr;` with `std::unique_ptr source_;`. +- In the doc comment of `build_stamps`, `frame->num_samples` becomes `frame.num_samples`. + +- [ ] **Step 5: `acquisition.cpp`, the mechanical part** + +Run, from the repo root: + +```bash +sed -i \ + -e 's/const udps_frame_t\* frame/const Frame\& frame/g' \ + -e 's/frame->/frame./g' \ + -e 's/udps_frame_value(frame, /frame.value(/g' \ + -e 's/udps_signal_num_elements(&\([^()]*\))/\1.elements()/g' \ + -e 's/const udps_signal_t&/const SignalInfo\&/g' \ + -e 's/UDPS_TIME_PACKET/kTimePacket/g' \ + -e 's/UDPS_TIME_FULL_ARRAY/kTimeFullArray/g' \ + -e 's/UDPS_TIME_FIRST_SAMPLE/kTimeFirstSample/g' \ + -e 's/UDPS_TIME_LAST_SAMPLE/kTimeLastSample/g' \ + src/acquisition.cpp +grep -no "frame\.[a-z_]*" src/acquisition.cpp | cut -d: -f2 | sort | uniq -c +grep -n "num_rows\|num_cols\|num_dimensions\|hrt" src/acquisition.cpp +``` +Expected: every `frame.` is one of `counter lost late recv_time publish_mode num_samples num_signals signals values value`; the second grep prints nothing. If either differs, fix by hand (`Frame` has no `hrt`; `SignalInfo` names the dimensions `rows`/`cols`). + +- [ ] **Step 6: `acquisition.cpp`, the structural part** + +1. Delete `to_info` (lines ~32-46), `#include ` (line 8), the four trampolines at the end of the file (`// Static trampolines ...` through `event_trampoline`), and `datagram_trampoline` (lines ~174-177). +2. Add includes `"source/registry.hpp"`. +3. `start()` becomes: + +```cpp +void Acquisition::start() { + // A started -- or *restarted* -- source is a new stream. Going live from a file view is the restart that + // matters: the store still holds the event's samples, and the new stream's clock can sit behind their + // stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing + // was the symptom -- every packet pinned flat at the file view's last stamp. + model_.reset_stream(); + last_stamp_ = -std::numeric_limits::infinity(); + if (!source_) { + SourceKind* kind = find_kind(cfg_.source_kind); + if (kind == nullptr) throw std::runtime_error("unknown source kind: " + cfg_.source_kind); + source_ = kind->make(cfg_.source_config()); + } + source_->set_datagram_tap([this](const std::uint8_t* bytes, std::size_t len, double recv_time) { + on_datagram(bytes, len, recv_time); + }); + source_->open(*this); + // `--capture-packets`: armed before the first datagram, so the first CONNECT and + // its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring. + // The open itself still happens on the producer thread, at the first datagram. + if (cfg_.capture_packets > 0) { + set_capture_want(true, cfg_.capture_packets); + } + + 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) && thread_.joinable()) thread_.join(); + if (source_) source_->close(); +} + +void Acquisition::run() { + while (running_.load(std::memory_order_acquire)) { + source_->poll(50); + const SourceStats s = source_->stats(); + StreamStats& out = model_.stats(); + out.packets.store(s.packets, std::memory_order_relaxed); + out.frames.store(s.frames, std::memory_order_relaxed); + out.gaps.store(s.gaps, std::memory_order_relaxed); + out.resequenced.store(s.resequenced, std::memory_order_relaxed); + out.late.store(s.late, std::memory_order_relaxed); + out.stale.store(s.stale, std::memory_order_relaxed); + out.overflows.store(s.overflows, std::memory_order_relaxed); + out.reconnects.store(s.reconnects, std::memory_order_relaxed); + out.connected.store(s.connected, std::memory_order_relaxed); + } +} +``` +4. `set_capture_want`: replace `cfg_.server`/`cfg_.port` with `cfg_.session_host()`/`cfg_.session_port()` for the directory name, and guard the peer address: `if (cfg_.source_kind == "udps" && ::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) { ... } capture_.set_peer(addr, cfg_.source_kind == "udps" ? cfg_.port : 0);`. +5. `on_config` signature: `void Acquisition::on_config(const std::vector& info, std::uint8_t publish_mode)`. Delete the `info` vector construction and use `info.size()` for `count` in the log line (`%u` with `static_cast(info.size())`). The mode log chain becomes `kTimePacket`... already done by sed. +6. `on_event`: + +```cpp +void Acquisition::on_event(SourceEvent event, const char* detail) { + (void)detail; + if (event == SourceEvent::Connected) { + model_.stats().connected.store(true, std::memory_order_relaxed); + } else if (event == SourceEvent::Disconnected) { +``` +leaving the body of the old `else if` untouched. + +- [ ] **Step 7: Every other reader of `cfg.server`/`cfg.port`** + +Run: `grep -n "config()\.server\|config()\.port\|cfg\.server\|cfg\.port" src/*.cpp src/*.hpp` and change each that names a *session file or capture directory* to `cfg.session_host()` / `cfg.session_port()`: +- `src/ui.cpp:~2497` `layout_path(app->model->config().server, ...port)` -> `layout_path(config().session_host(), config().session_port())`; +- `src/ui.cpp:~5917-5918` `app.source_host`/`source_port` -> `cfg.session_host()`/`cfg.session_port()` (keep the `open_waveform.empty()` guards); +- `src/ui_probe.cpp:~5414` likewise; +- `src/source_dialog.cpp:~1132-1135` (`apply_stored_settings`) -> `cfg.session_host()`/`cfg.session_port()`. +Leave `ui.cpp:~5398` (a log line), `ui.cpp:5919-5920` (`source_multicast`/`source_interface`) and `source_dialog.cpp:~35-38,1199` (UDPS fields filled from `KnownSource`) alone: they stay correct for UDPS and Task 5 reworks them for other kinds. + +- [ ] **Step 8: CMake and build** + +No new files. Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors, warning count as in Task 0 (a full rebuild of `acquisition.cpp` and others is incremental, so 1 or 3). Fix any leftover `udps_`/`->` compile errors by hand. + +- [ ] **Step 9: Self-check against the baseline** + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-new.out 2>/tmp/udps-new.err +kill $MOCK +grep -c "Gtk-CRITICAL" /tmp/udps-new.err; grep "gtk warnings" /tmp/udps-new.err +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.out) <(sed 's/[0-9.]\+/N/g' /tmp/udps-new.out) | head -30 +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.err) <(sed 's/[0-9.]\+/N/g' /tmp/udps-new.err) | head -30 +``` +Expected: `0`, `gtk warnings: 0`, and both diffs empty or limited to timing noise (a line present in one and not the other by a count that varies with timing); anything structural (a missing probe line, a changed message) is a regression to fix before continuing. Also run `./build/udpscope --help | grep -A3 -- --source` (expect the new options with `udps`) and `./build/udpscope --source nope` (expect `unknown source 'nope' (available: udps)`, exit 2). + +- [ ] **Step 10: Commit** + +```bash +git add -A src +git commit -m "acquisition: drive a DataSource from the registry; --source and --source-opt" +``` + +--- + +## Task 4: The `probe` kind and per-kind session persistence + +Adds the test-only `probe` kind (a synthetic sine, no panel yet) so a non-UDPS source can run end to end, and makes sessions remember which kind they belong to. + +**Files:** +- Create: `src/source/probe_source.hpp`, `src/source/probe_source.cpp` +- Modify: `src/source/registry.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_layout.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: Tasks 1-3. +- Produces: + - `class ProbeSource final : public DataSource` with `explicit ProbeSource(const SourceConfig&)`, `void note_applied()`, `std::uint64_t commands_applied() const`; option `rate` (Hz, default 1000). + - Kind id `"probe"`, display name `"Probe (test)"`, identity `"rate"`. + - `LayoutState::source_kind` (`std::string`, empty = UDPS) and `LayoutState::source_opts` (`SourceConfig`); `KnownSource::kind` (default `"udps"`) and `KnownSource::opts`. + - `App::source_kind`, `App::source_opts`, `App::source_title`. + - Session file keys: top-level `source_kind = "probe"` and `source_opt_ = ""`, written only for kinds other than `udps`. + +- [ ] **Step 1: `probe_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +/// A synthetic source for the headless self-check: one 1 V sine signal, `kBatch` samples per +/// frame at `rate` Hz, no socket. It exists to prove the framework end to end (open, CONFIG, +/// data, commands, panels) and is registered only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set. +class ProbeSource final : public DataSource { + public: + static constexpr std::uint32_t kBatch = 10; + + explicit ProbeSource(const SourceConfig& cfg); + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override { return open_.load(std::memory_order_relaxed); } + + /// What a panel command does, so the probe can see that `post()` reached the producer thread. + void note_applied() noexcept { applied_.fetch_add(1, std::memory_order_relaxed); } + std::uint64_t commands_applied() const noexcept { + return applied_.load(std::memory_order_relaxed); + } + + private: + double rate_hz_; + SourceSink* sink_ = nullptr; + std::atomic open_{false}; + bool configured_ = false; + std::uint32_t counter_ = 0; + std::uint64_t frames_ = 0; + double phase_ = 0.0; + std::vector signals_; + std::vector batch_; + SignalValues values_; + std::atomic applied_{0}; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `probe_source.cpp`** + +The kind's `make_panels` is added in Task 6; the form lives here. + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/probe_source.hpp" + +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { + +ProbeSource::ProbeSource(const SourceConfig& cfg) + : rate_hz_(std::max(1.0, cfg.get_double("rate", 1000.0))), batch_(kBatch, 0.0) { + SignalInfo s; + s.name = "probe"; + s.unit = "V"; + s.type_code = 9; // float64 + s.rows = 1; + s.cols = 1; + s.range_min = -1.0; + s.range_max = 1.0; + s.time_mode = kTimePacket; + s.sampling_rate = rate_hz_; + signals_.push_back(s); +} + +void ProbeSource::open(SourceSink& sink) { + sink_ = &sink; + configured_ = false; + open_.store(true, std::memory_order_relaxed); +} + +void ProbeSource::close() { open_.store(false, std::memory_order_relaxed); } + +SourceStats ProbeSource::stats() const { + SourceStats s; + s.packets = frames_; + s.frames = frames_; + s.connected = open_.load(std::memory_order_relaxed); + return s; +} + +void ProbeSource::poll(int timeout_ms) { + drain_commands(); + if (!open_.load(std::memory_order_relaxed) || sink_ == nullptr) return; + if (!configured_) { + configured_ = true; + sink_->on_config(signals_, /*publish_mode=*/1); + sink_->on_event(SourceEvent::Connected, ""); + } + // One batch takes kBatch / rate seconds to "arrive". + const int period_ms = std::max(1, static_cast(1000.0 * kBatch / rate_hz_)); + std::this_thread::sleep_for(std::chrono::milliseconds(std::min(period_ms, timeout_ms))); + const double step = 2.0 * M_PI * 5.0 / rate_hz_; // a 5 Hz sine + for (std::uint32_t k = 0; k < kBatch; ++k) { + batch_[k] = std::sin(phase_); + phase_ += step; + } + values_.values = batch_.data(); + values_.count = kBatch; + Frame f; + f.counter = counter_++; + f.lost = 0; + f.late = false; + f.recv_time = std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()) + .count(); + f.publish_mode = 1; + f.num_samples = kBatch; + f.num_signals = 1; + f.signals = signals_.data(); + f.values = &values_; + ++frames_; + sink_->on_data(f); +} + +namespace { + +void on_rate_changed(GObject*, GParamSpec*, gpointer user) { + const auto& cb = *static_cast>*>(user); + if (cb && *cb) (*cb)(); +} + +class ProbeKind final : public SourceKind { + public: + std::string id() const override { return "probe"; } + std::string display_name() const override { return "Probe (test)"; } + std::string identity(const SourceConfig& cfg) const override { + return "rate" + std::to_string(cfg.get_int("rate", 1000)); + } + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + GtkWidget* rate = GTK_WIDGET(adw_spin_row_new_with_range(1, 100000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(rate), "_Rate (Hz)"); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(rate), TRUE); + adw_spin_row_set_value(ADW_SPIN_ROW(rate), 1000); + + auto cb = std::make_shared>(); + g_signal_connect_data(rate, "notify::value", G_CALLBACK(on_rate_changed), + new std::shared_ptr>(cb), + +[](gpointer d, GClosure*) { + delete static_cast>*>(d); + }, + static_cast(0)); + + ConfigForm f; + f.rows = {rate}; + f.description = "A synthetic 5 Hz sine, for the self-check"; + f.focus = rate; + f.write = [=](const SourceConfig& c) { + adw_spin_row_set_value(ADW_SPIN_ROW(rate), static_cast(c.get_int("rate", 1000))); + }; + f.read = [=]() { + SourceConfig c; + c.set("rate", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(rate))))); + return c; + }; + f.valid = []() { return true; }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_probe_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +(Add `#include ` and `#include ` at the top if the compiler asks.) + +- [ ] **Step 3: Register it** + +In `src/source/registry.cpp`, replace the `// Task 6:` comment with the real call from the listing in Task 1 Step 7: `if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());`. Add `src/source/probe_source.cpp` to `CMakeLists.txt` after `udps_kind.cpp`. + +- [ ] **Step 4: `layout_store.hpp`** + +Add `#include "source/source_config.hpp"`. In `LayoutState`, after `interface_addr`: + +```cpp + /// Which source kind this session belongs to. Empty means `udps`: every file written before the + /// plugins existed. Only a kind other than `udps` is written, so a UDPS session file is + /// byte-identical to what earlier versions wrote. + std::string source_kind; + /// That kind's settings (`SourceConfig`), stored as top-level `source_opt_` entries. + SourceConfig source_opts; +``` + +In `KnownSource`, after `interface_addr`: + +```cpp + std::string kind = "udps"; ///< the session's source kind + SourceConfig opts; ///< that kind's settings (empty for UDPS, whose fields are above) +``` + +- [ ] **Step 5: `layout_store.cpp`** + +Parser, next to `key == "interface"` (line ~559): + +```cpp + } else if (key == "source_kind") { + st.source_kind = strip_quote(value); + } else if (key.rfind("source_opt_", 0) == 0) { + const std::string k = key.substr(11); + if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value)); +``` +Writer, right after the `interface` block (line ~700): + +```cpp + if (!state.source_kind.empty() && state.source_kind != "udps") { + o << "source_kind = " << toml_escape(state.source_kind) << "\n"; + for (const auto& kv : state.source_opts.items()) { + o << "source_opt_" << kv.first << " = " << toml_escape(kv.second) << "\n"; + } + } +``` +`known_sources()`, after `s.interface_addr = st.interface_addr;` (line ~324): + +```cpp + if (!st.source_kind.empty()) { + s.kind = st.source_kind; + s.opts = st.source_opts; + } +``` + +- [ ] **Step 6: App plumbing** + +`src/ui_app.hpp`: add `#include "source/source_config.hpp"` and after `source_interface` (line ~839): + +```cpp + /// The source kind and its settings, so the session can say which kind it belongs to, and the + /// words that name the source in dialogs (`host:port` for UDPS, ` ` otherwise). + std::string source_kind = "udps"; + SourceConfig source_opts; + std::string source_title; +``` +`src/ui.cpp` in `run_ui`, after `app.source_interface = cfg.interface_addr;` (line ~5920): + +```cpp + app.source_kind = cfg.source_kind; + app.source_opts = cfg.source_opts; + if (cfg.source_kind != "udps" && find_kind(cfg.source_kind) != nullptr) { + app.source_title = find_kind(cfg.source_kind)->display_name() + " " + + find_kind(cfg.source_kind)->identity(cfg.source_config()); + } else { + app.source_title = app.source_host + ":" + std::to_string(app.source_port); + } +``` +(add `#include "source/registry.hpp"` to `ui.cpp` if not present). At `ui.cpp:~103` replace `app.source_host + ":" + std::to_string(app.source_port)` with `app.source_title`. +`src/ui_layout.cpp:~99`, after `st.interface_addr = app.source_interface;`: + +```cpp + st.source_kind = app.source_kind; + st.source_opts = app.source_opts; +``` + +- [ ] **Step 7: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c`. Expected: no errors, baseline warning count. + +- [ ] **Step 8: Verify a non-UDPS source runs and its session is tagged** + +```bash +CFG=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFG UDPSCOPE_PROBE_SOURCE_KINDS=1500 UDPSCOPE_EXIT_AFTER_MS=4000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --source probe --source-opt rate=500 >/tmp/probe.out 2>/tmp/probe.err +grep -c "Gtk-CRITICAL" /tmp/probe.err; grep "gtk warnings" /tmp/probe.err +grep "CONFIG:" /tmp/probe.err +grep -rn "source_" $(find $CFG -name '*.toml') +find $CFG -name '*.toml' +``` +Expected: `0`, `gtk warnings: 0`, `[udpscope] CONFIG: 1 signals, publish=accumulate`, a `'probe' 1x1 ... rate=500 Hz` line, and a session file under a directory named `probe-rate500_1` containing `source_kind = "probe"` and `source_opt_rate = "500"`. If no `.toml` appears, find where the exit path saves the session (`save_layout_now` in `src/ui_layout.cpp`) and what condition skips it, before changing anything. + +Then confirm UDPS sessions are untouched: re-run the Task 3 Step 9 self-check, then `grep -rl source_kind $CFG2` over its config dir must print nothing, and both diffs against the baseline must still be empty. + +- [ ] **Step 9: Commit** + +```bash +git add src CMakeLists.txt +git commit -m "source: a synthetic probe kind, and sessions that remember their source kind" +``` + +--- + +## Task 5: The chooser picks a source type + +The connect dialog gets a "Source type" dropdown; choosing a kind swaps in that kind's `ConfigForm`. For UDPS it must behave as before. + +**Files:** +- Modify: `src/ui/source-form.ui`, `src/source_dialog.cpp` + +**Interfaces:** +- Consumes: `ConfigForm`, `SourceKind`, `kinds()`, `find_kind()`, `session_host/port`, `KnownSource::kind/opts`, `LayoutState::source_kind/source_opts`, `Config::source_kind/source_opts`. +- Produces: new chooser probe variable `UDPSCOPE_PROBE_NEWHOST_KIND=[:k=v,...]` (with `UDPSCOPE_PROBE_NEWHOST` still required to open the form; its value is ignored for a non-UDPS kind) and, for a non-UDPS pick, one extra stderr line `[sources] picked kind=K opts=k=v,... session=HOST:PORT`. + +- [ ] **Step 1: `src/ui/source-form.ui`** + +Update the header comment ("eight rows" -> "nine rows, the connection ones supplied by the chosen source kind"). Replace the whole first `AdwPreferencesGroup` (title `Connection`, with the four rows) by these two groups: + +```xml + + + + + Source _type + True + + + + + + + Connection + + +``` +The Configuration group stays as it is. + +- [ ] **Step 2: `source_dialog.cpp`, the Chooser struct and `apply_source`** + +Includes: `#include "source/registry.hpp"`. + +In `struct Chooser` replace `form_server`, `form_port`, `form_group`, `form_iface` (lines ~108-111) with: + +```cpp + /// The source-type dropdown and the group the chosen kind's rows live in. The rows themselves, + /// and the closures that read and write them, are the kind's (`ConfigForm`); the chooser only + /// swaps them when the dropdown moves. + AdwComboRow* form_kind_row = nullptr; + AdwPreferencesGroup* form_source_group = nullptr; + std::vector form_kinds; ///< dropdown order + ConfigForm form_cfg; + std::vector form_cfg_rows; ///< the rows currently in `form_source_group` +``` + +`apply_source` becomes: + +```cpp +void apply_source(Config& cfg, const KnownSource& src) { + cfg.source_kind = src.kind; + if (src.kind == "udps") { + cfg.server = src.host; + cfg.port = src.port; + cfg.multicast = src.multicast; + cfg.interface_addr = src.interface_addr; + cfg.source_opts = SourceConfig(); + } else { + cfg.source_opts = src.opts; + } + // (the rest of the function is unchanged) +``` + +- [ ] **Step 3: `form_rebuild` and the kind handler** + +Replace `on_form_server_changed` (lines ~435-444) with: + +```cpp +/// The kind selected in the dropdown, or null. +SourceKind* form_selected_kind(const Chooser& c) { + if (c.form_kind_row == nullptr) return nullptr; + const guint i = adw_combo_row_get_selected(c.form_kind_row); + return i < c.form_kinds.size() ? c.form_kinds[i] : nullptr; +} + +/// Swaps the Connection group's rows for `kind`'s form and fills them from `initial`. The +/// connect button follows the form's own `valid()`: the one validation here is the kind's. +void form_rebuild(Chooser& c, SourceKind& kind, const SourceConfig& initial) { + for (GtkWidget* r : c.form_cfg_rows) adw_preferences_group_remove(c.form_source_group, r); + c.form_cfg_rows.clear(); + c.form_cfg = kind.make_config_form(); + for (GtkWidget* r : c.form_cfg.rows) { + adw_preferences_group_add(c.form_source_group, r); + c.form_cfg_rows.push_back(r); + } + adw_preferences_group_set_description( + c.form_source_group, + c.form_cfg.description.empty() ? nullptr : c.form_cfg.description.c_str()); + c.form_cfg.write(initial); + c.form_cfg.on_change([&c] { + if (c.form_connect != nullptr) { + gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE); + } + }); + if (c.form_connect != nullptr) { + gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE); + } + if (c.form_cfg.focus != nullptr) gtk_widget_grab_focus(c.form_cfg.focus); +} + +void on_form_kind_changed(GObject*, GParamSpec*, gpointer user) { + Chooser& c = *static_cast(user); + if (SourceKind* k = form_selected_kind(c)) form_rebuild(c, *k, SourceConfig()); +} +``` +(`form_rebuild` is called with a `Chooser&` that outlives the form, as the existing comment on the struct explains, so `[&c]` is safe.) + +- [ ] **Step 4: `on_form_connect`** + +Replace the head of the function (up to and including the `c.cfg->interface_addr = iface;` line) with: + +```cpp + SourceKind* kind = form_selected_kind(c); + if (kind == nullptr || !c.form_cfg.valid()) return; + const SourceConfig scfg = c.form_cfg.read(); + const bool udps = kind->id() == "udps"; + const std::string host = session_host(kind->id(), scfg); + const std::uint16_t port = session_port(kind->id(), scfg); + const std::string group = udps ? scfg.get("multicast") : std::string(); + const std::string iface = udps ? scfg.get("interface") : std::string(); + std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name)); + const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note)); + const double buffer_mb = adw_spin_row_get_value(c.form_buffer); + const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE; + if (name.empty()) name = "default"; + + c.cfg->source_kind = kind->id(); + if (udps) { + c.cfg->server = scfg.get("server"); + c.cfg->port = port; + c.cfg->multicast = group; + c.cfg->interface_addr = iface; + c.cfg->source_opts = SourceConfig(); + } else { + c.cfg->source_opts = scfg; + } +``` +and after `st.interface_addr = iface;` add: + +```cpp + st.source_kind = udps ? std::string() : kind->id(); + st.source_opts = udps ? SourceConfig() : scfg; +``` +Everything else in the function (the save, the `moved` test, the log line) stays: it already works on `host`, `port`, `name`. + +- [ ] **Step 5: `open_form`** + +1. Remove the four `gtk_builder_get_object` lines for `server_row/port_row/group_row/iface_row`, and in the null-check list drop `form_server/form_port/form_group/form_iface`. Add: + +```cpp + c.form_kind_row = ADW_COMBO_ROW(gtk_builder_get_object(c.form_ui, "kind_row")); + c.form_source_group = + ADW_PREFERENCES_GROUP(gtk_builder_get_object(c.form_ui, "source_group")); +``` +and add both to the null check. +2. Delete the four value lines for server/port/group/iface. After the `form_relative` line, add: + +```cpp + // The dropdown lists the registered kinds. A new host starts on the kind the command line named + // (UDPS by default); an edit starts on the configuration's own kind and cannot change it -- + // disabled, not hidden, like every other control that does not apply. + c.form_kinds = kinds(); + GtkStringList* names = gtk_string_list_new(nullptr); + guint selected = 0; + const std::string want_kind = src != nullptr ? src->kind : c.cfg->source_kind; + for (guint i = 0; i < c.form_kinds.size(); ++i) { + gtk_string_list_append(names, c.form_kinds[i]->display_name().c_str()); + if (c.form_kinds[i]->id() == want_kind) selected = i; + } + adw_combo_row_set_model(c.form_kind_row, G_LIST_MODEL(names)); + adw_combo_row_set_selected(c.form_kind_row, selected); + gtk_widget_set_sensitive(GTK_WIDGET(c.form_kind_row), src == nullptr); + + SourceConfig initial; + if (src != nullptr && src->kind == "udps") { + initial.set("server", src->host); + initial.set("port", std::to_string(src->port)); + if (!src->multicast.empty()) initial.set("multicast", src->multicast); + if (!src->interface_addr.empty()) initial.set("interface", src->interface_addr); + } else if (src != nullptr) { + initial = src->opts; + } else if (c.cfg->source_kind != "udps") { + initial = c.cfg->source_opts; + } + if (!c.form_kinds.empty()) form_rebuild(c, *c.form_kinds[selected], initial); + // Connected after the first rebuild: setting `selected` above must not rebuild a second time. + g_signal_connect(c.form_kind_row, "notify::selected", G_CALLBACK(on_form_kind_changed), &c); +``` +3. Delete the `g_signal_connect(c.form_server, "changed", ...)` line (it is replaced by `on_change`) and the final `gtk_widget_grab_focus(GTK_WIDGET(c.form_server));` (the rebuild focused the kind's `focus` widget). Note the rebuild's `grab_focus` happens before the dialog is presented, as the old code did after; if focus does not land (probe: `mnemonics`/`connect_sensitive` lines are unaffected), call `if (c.form_cfg.focus) gtk_widget_grab_focus(c.form_cfg.focus);` again after `adw_dialog_present`. +4. In `form_close`'s caller and the `closed` handler, also clear the form: in the `closed` lambda add `Chooser& ch = *static_cast(user); ch.form = nullptr; ch.form_cfg = ConfigForm(); ch.form_cfg_rows.clear(); ch.form_kind_row = nullptr; ch.form_source_group = nullptr;` (the rows are gone with the dialog; dropping the closures that captured them keeps a stale `read()` from ever being callable). + +- [ ] **Step 6: Row titles, loading window, `report_pick`** + +- `chooser_populate` title (line ~375): + +```cpp + SourceKind* sk = s.kind == "udps" ? nullptr : find_kind(s.kind); + const std::string where = sk != nullptr ? sk->display_name() + " " + sk->identity(s.opts) + : s.host + ":" + std::to_string(s.port); + const std::string title = where + " \u00b7 " + s.config; +``` + (a non-UDPS session whose kind is not registered keeps its pseudo-host as the title.) +- `loading_show` (line ~1199): for `cfg.source_kind != "udps"` and a registered kind, `head = "Connecting to " + kind->display_name() + " " + kind->identity(cfg.source_config());` instead of the host:port form; the UDPS branch is unchanged. +- `report_pick`: after the existing `fprintf`, add + +```cpp + if (c.cfg->source_kind != "udps") { + std::string opts; + for (const auto& kv : c.cfg->source_opts.items()) { + opts += (opts.empty() ? "" : ",") + kv.first + "=" + kv.second; + } + std::fprintf(stderr, "[sources] picked kind=%s opts=%s session=%s:%u\n", + c.cfg->source_kind.c_str(), opts.c_str(), c.cfg->session_host().c_str(), + c.cfg->session_port()); + std::fflush(stderr); + } +``` + The first `picked` line prints `c.cfg->server`; for a non-UDPS pick it prints the default server, which is why the second line exists. Leave it. + +- [ ] **Step 7: The probes** + +In the `UDPSCOPE_PROBE_NEWHOST` block, replace the `gtk_editable_set_text(... form_server ...)` / `adw_spin_row_set_value(c.form_port ...)` lines with: + +```cpp + SourceConfig sc; + if (const char* kspec = g_getenv("UDPSCOPE_PROBE_NEWHOST_KIND")) { + // "kind" or "kind:k=v,k=v": pick the kind in the dropdown (which rebuilds the form), then + // write the options into it. + std::string ks = kspec; + std::string opts; + if (const std::size_t sep = ks.find(':'); sep != std::string::npos) { + opts = ks.substr(sep + 1); + ks = ks.substr(0, sep); + } + for (guint i = 0; i < c.form_kinds.size(); ++i) { + if (c.form_kinds[i]->id() == ks) adw_combo_row_set_selected(c.form_kind_row, i); + } + for (std::size_t pos = 0; pos < opts.size();) { + const std::size_t end = std::min(opts.find(',', pos), opts.size()); + sc.parse_assignment(opts.substr(pos, end - pos)); + pos = end + 1; + } + } else { + sc.set("server", colon == std::string::npos ? s : s.substr(0, colon)); + if (colon != std::string::npos) sc.set("port", s.substr(colon + 1)); + } + c.form_cfg.write(sc); +``` +(Keep the `colon` computation above it.) Change the mnemonic line to count the kind row and the form's rows: + +```cpp + int underlines = row_underlines(G_OBJECT(c.form_kind_row)) + row_underlines(G_OBJECT(c.form_name)) + + row_underlines(G_OBJECT(c.form_note)) + row_underlines(G_OBJECT(c.form_buffer)) + + row_underlines(G_OBJECT(c.form_relative)); + for (GtkWidget* r : c.form_cfg.rows) underlines += row_underlines(G_OBJECT(r)); + std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines, 5 + c.form_cfg.rows.size()); +``` +For UDPS this prints `9/9`. (The old `/8` was a hard-coded total; the new total follows the kind's row count.) + +The EDITHOST probe needs no code change: it opens the form with `open_form(c, idx)` and edits the config rows. Its match `known[i].host:port == want` still works for UDPS, and for another kind `want` is the pseudo-host (`probe-rate250:1`). + +- [ ] **Step 8: Build and run the UDPS chooser probes against the baseline** + +```bash +cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +CFGN=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-new-new.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_EDITHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-new-edit.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>/tmp/chooser-new-list.err >/dev/null +kill $MOCK +for k in new edit list; do + diff <(grep -h "^\[sources\]" /tmp/chooser-baseline-$k.err | sed 's/[0-9.]\+/N/g') \ + <(grep -h "^\[sources\]" /tmp/chooser-new-$k.err | sed 's/[0-9.]\+/N/g') +done +grep -c "Gtk-CRITICAL" /tmp/chooser-new-*.err +``` +Expected: no errors; the only diff is `form mnemonics=8/8` -> `form mnemonics=9/9`; `Gtk-CRITICAL` counts all `0`. + +- [ ] **Step 9: Run the non-UDPS path through the form** + +```bash +CFGP=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFGP UDPSCOPE_PROBE_SOURCE_KINDS=9999 UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=x UDPSCOPE_PROBE_NEWHOST_KIND=probe:rate=250 ./build/udpscope 2>/tmp/chooser-probe.err >/dev/null +grep "^\[sources\]" /tmp/chooser-probe.err +xvfb-run -a env XDG_CONFIG_HOME=$CFGP UDPSCOPE_PROBE_SOURCE_KINDS=9999 UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>&1 >/dev/null | grep "^\[sources\]" +``` +Expected: `form mnemonics=6/6`, `configuration created: probe-rate250:1/probe-host`, `picked kind=probe opts=rate=250 session=probe-rate250:1`, then `CONFIG: 1 signals` in the app half; and the second run lists one `known:` row for `probe-rate250:1`. + +- [ ] **Step 10: Commit** + +```bash +git add src/ui/source-form.ui src/source_dialog.cpp +git commit -m "chooser: pick the source type; each kind supplies its own connection form" +``` + +--- + +--- + +## Task 6: Capability panels in the side stack, and the source-kinds probe + +A kind's `make_panels()` widgets become pages of the existing `side_stack`, next to Measure and Signals. The `probe` kind carries one panel (a "Poke" button) so the headless run can prove the whole path: page built, insensitive while disconnected, a clicked button's command drained by `poll()`. + +**Files:** +- Create: `src/ui_source.cpp` +- Modify: `src/source/probe_source.cpp` (the panel), `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: `SourceKind::make_panels(DataSource&)`, `SourcePanel{id,title,icon,widget}`, `Acquisition::source()`, `App::source_kind`, `ScopeModel::stats().connected` (an atomic the producer keeps current), `ProbeSource::commands_applied()`. +- Produces: + - `struct SourcePanelPage { std::string name; std::string title; GtkWidget* content = nullptr; };` and `App::source_panels` (`std::vector`). `name` is the stack page name, `"source-" + SourcePanel::id`; `content` is the kind's own widget (the page wraps it in a scroller). + - `void build_source_panels(App&)`, `void apply_source_panels(App&, bool connected)`, `void sync_source_panels(App&)` (declared in `ui_app.hpp`, defined in `ui_source.cpp`). + - `gboolean source_kinds_probe(gpointer)` (declared in `ui_app.hpp`, defined in `ui_probe.cpp`), scheduled by `UDPSCOPE_PROBE_SOURCE_KINDS=`. + +- [ ] **Step 1: The probe kind's panel** + +In `src/source/probe_source.cpp`, inside `class ProbeKind`, add after `make_config_form()`: + +```cpp + std::vector make_panels(DataSource& source) const override { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + 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); + GtkWidget* label = gtk_label_new("Test panel"); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), label); + // The probe finds the button as the box's last child. It reaches the source only through + // `post()`: the command runs inside the next `poll()`, on the producer thread. + GtkWidget* poke = gtk_button_new_with_label("Poke"); + gtk_box_append(GTK_BOX(box), poke); + DataSource* src = &source; + g_signal_connect_data( + poke, "clicked", + G_CALLBACK(+[](GtkButton*, gpointer user) { + DataSource* s = static_cast(user); + s->post([s] { static_cast(s)->note_applied(); }); + }), + src, nullptr, static_cast(0)); + SourcePanel p; + p.id = "poke"; + p.title = "Probe"; + p.widget = box; + return {p}; + } +``` + +(The `static_cast` is sound because only `ProbeKind::make` creates the source this kind's panels are built for. `src` outlives the button: `Acquisition` owns the source and outlives the window.) + +- [ ] **Step 2: `ui_app.hpp`** + +Above `struct App` add: + +```cpp +/// One capability panel a source kind contributed to the side stack. `content` is the kind's own widget +/// (made insensitive while the source is disconnected); the page wrapping it is named `name`. +struct SourcePanelPage { + std::string name; ///< stack page name: "source-" + the kind's panel id + std::string title; ///< the pill's tooltip and accessible name; the page's own title stays empty + GtkWidget* content = nullptr; +}; +``` + +In `App`, after `source_title` (added in Task 4): + +```cpp + /// The panels the source kind added to `side_stack`, in tab order. Empty for UDPS and in file mode. + std::vector source_panels; +``` + +Next to `void save_layout_now(App& app);` add: + +```cpp +/// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as +/// pages of `side_stack`, once, before the first tick. `apply_source_panels` sets every panel's +/// sensitivity from `connected` -- disabled, never hidden: the tab switcher owns visibility. +/// `sync_source_panels` is the per-tick call: it applies the model's own connection flag. +void build_source_panels(App& app); +void apply_source_panels(App& app, bool connected); +void sync_source_panels(App& app); +``` + +Next to `gboolean side_tab_probe(gpointer user);` add `gboolean source_kinds_probe(gpointer user);`. + +- [ ] **Step 3: `src/ui_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" +#include "ui_app.hpp" + +#include + +namespace udpscope { + +void build_source_panels(App& app) { + if (app.side_stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; + SourceKind* kind = find_kind(app.source_kind); + if (kind == nullptr) return; + for (const SourcePanel& p : kind->make_panels(*app.acq->source())) { + if (p.widget == nullptr || p.id.empty()) continue; + // Each page scrolls on its own, like Measure and Signals: panels differ in height. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget); + const std::string name = "source-" + p.id; + // An empty title on purpose, as for the built-in tabs: the pill is an icon, and + // `name_side_tabs()` puts `title` on it as tooltip and accessible name. + adw_view_stack_add_titled_with_icon( + ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), "", + p.icon.empty() ? "applications-engineering-symbolic" : p.icon.c_str()); + app.source_panels.push_back({name, p.title, p.widget}); + } + // Disconnected until the first Connected event says otherwise. + apply_source_panels(app, false); +} + +void apply_source_panels(App& app, bool connected) { + for (const SourcePanelPage& p : app.source_panels) { + if (gtk_widget_get_sensitive(p.content) != (connected ? TRUE : FALSE)) { + gtk_widget_set_sensitive(p.content, connected); + } + } +} + +void sync_source_panels(App& app) { + if (app.source_panels.empty() || app.model == nullptr) return; + apply_source_panels(app, app.model->stats().connected.load(std::memory_order_relaxed)); +} + +} // namespace udpscope +``` + +Add `src/ui_source.cpp` to `CMakeLists.txt` after `src/ui_layout.cpp`. + +- [ ] **Step 4: Hook it into `ui.cpp`** + +1. `side_tab_title` (line ~2981) becomes `static const char* side_tab_title(const App& app, const char* name)`: after the two built-in `strcmp` lines and before `return "";` add + +```cpp + if (name != nullptr) { + for (const SourcePanelPage& p : app.source_panels) { + if (p.name == name) return p.title.c_str(); + } + } +``` +and in `name_side_tabs` (line ~3003) call `side_tab_title(app, adw_view_stack_page_get_name(page))`. + +2. In `on_activate`, immediately after the block that selects the `signals` page and connects `notify::visible-child-name` (line ~5280-5284, ending `}`), add: + +```cpp + // The source kind's own tabs (a generator, digital I/O). Before the first tick: that is when + // `name_side_tabs()` puts the words on the pills, and it must see these pages too. + build_source_panels(*app); +``` + +3. In `tick()`, directly after `note_panel_controls(*app);` add `sync_source_panels(*app);`. + +4. In `run_ui`, next to the other `g_getenv("UDPSCOPE_PROBE_*_MS")` schedules (line ~6090, after `UDPSCOPE_PROBE_DENSITY_MS`): + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_SOURCE_KINDS")) { + g_timeout_add(static_cast(std::atoi(env)), source_kinds_probe, &app); + } +``` + +5. The packet-capture button is for UDP sources only (spec: "left disabled, not hidden"). In `ui_app.hpp`, after `source_title`, add `bool source_has_datagrams = true; ///< the kind reports raw datagrams; false leaves the capture button disabled`. In `run_ui`, after the `source_title` block added in Task 4, add `if (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams();`. At `ui.cpp:~4554` change `snap.connected ? TRUE : FALSE` to `snap.connected && app->source_has_datagrams ? TRUE : FALSE`. + +- [ ] **Step 5: The probe, in `ui_probe.cpp`** + +Add `#include "source/probe_source.hpp"` and `#include "source/registry.hpp"` to the includes, and, next to `side_tab_probe` (define the report first): + +```cpp +namespace { +std::uint64_t g_source_kinds_before = 0; +} + +/// Phase two: the command the Poke button posted must have been run by `poll()` on the producer. +gboolean source_kinds_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const ProbeSource* src = a->acq != nullptr ? dynamic_cast(a->acq->source()) + : nullptr; + const std::uint64_t after = src != nullptr ? src->commands_applied() : 0; + std::fprintf(stderr, "[source-kinds] command: before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT + " drained=%d\n", + g_source_kinds_before, after, after == g_source_kinds_before + 1 ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// What the registry offers and what a source kind brings: the kinds, each config form's +/// round trip, the panel page (insensitive while disconnected, still present), and a posted command. +gboolean source_kinds_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + + std::string ids; + for (const SourceKind* k : kinds()) ids += (ids.empty() ? "" : ",") + k->id(); + std::fprintf(stderr, "[source-kinds] kinds=%s\n", ids.c_str()); + + // Each kind's form must hand back what it was given. + const auto sample = [](const std::string& id) { + SourceConfig c; + if (id == "udps") { + c.set("server", "10.0.0.5"); + c.set("port", "9001"); + c.set("multicast", "239.1.2.3"); + c.set("interface", "10.0.0.9"); + } else if (id == "probe") { + c.set("rate", "750"); + } + return c; + }; + for (const SourceKind* k : kinds()) { + ConfigForm f = k->make_config_form(); + const SourceConfig in = sample(k->id()); + f.write(in); + const SourceConfig out = f.read(); + bool same = !in.empty(); + for (const auto& kv : in.items()) same = same && out.get(kv.first) == kv.second; + std::fprintf(stderr, "[source-kinds] form %s: round_trip=%d valid=%d\n", k->id().c_str(), + same ? 1 : 0, f.valid() ? 1 : 0); + // The rows were never parented; sink and release them so the form leaves nothing behind. + for (GtkWidget* row : f.rows) { + g_object_ref_sink(row); + g_object_unref(row); + } + } + + // The panel page: built, insensitive while disconnected, present either way. + const SourcePanelPage* page = a->source_panels.empty() ? nullptr : &a->source_panels.front(); + const auto report = [&](const char* state) { + const bool present = + page != nullptr && a->side_stack != nullptr && + adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page->name.c_str()) != + nullptr; + std::fprintf(stderr, "[source-kinds] panel %s: panels=%zu present=%d sensitive=%d\n", state, + a->source_panels.size(), present ? 1 : 0, + page != nullptr && gtk_widget_get_sensitive(page->content) ? 1 : 0); + }; + apply_source_panels(*a, false); + report("disconnected"); + apply_source_panels(*a, true); + report("connected"); + + // The probe kind has no datagrams, so the packet-capture button stays disabled, and is still there. + std::fprintf(stderr, "[source-kinds] capture button: present=%d sensitive=%d\n", + a->capture_button != nullptr ? 1 : 0, + a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0); + + // Click the panel's button: it posts a command, and a later `poll()` runs it. + ProbeSource* src = a->acq != nullptr ? dynamic_cast(a->acq->source()) : nullptr; + g_source_kinds_before = src != nullptr ? src->commands_applied() : 0; + if (page != nullptr) { + if (GtkWidget* poke = gtk_widget_get_last_child(page->content)) { + g_signal_emit_by_name(poke, "clicked"); + } + } + std::fflush(stderr); + g_timeout_add(500, source_kinds_probe_report, a); + return G_SOURCE_REMOVE; +} +``` + +Add the declaration `gboolean source_kinds_probe_report(gpointer user);` beside `source_kinds_probe` in `ui_app.hpp`. If `ProbeSource`/`SourceKind` names need `udpscope::` qualification the compiler will say so (the probe file is inside `namespace udpscope`). + +(The probe forces `apply_source_panels(..., true)` last, and the per-tick `sync_source_panels` also drives it from the model flag. The producer never sets the flag false in this run, so the two agree.) + +- [ ] **Step 6: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; baseline warning count. + +- [ ] **Step 7: Verify** + +```bash +CFG=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFG UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 \ + ./build/udpscope --source probe --source-opt rate=500 >/tmp/sk.out 2>/tmp/sk.err +grep -c "Gtk-CRITICAL" /tmp/sk.err; grep "gtk warnings" /tmp/sk.err; grep "^\[source-kinds\]" /tmp/sk.err +``` +Expected: + +``` +0 +gtk warnings: 0 +[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 +``` +(Every line of the form `[source-kinds] form ...` appears once per registered kind.) If `drained=0` the command was posted but `poll()` never ran it: check that `ProbeSource::poll` calls `drain_commands()` before returning on any path. If `sensitive=0` on the connected line, `apply_source_panels` is not reaching `content` (it must act on the kind's widget, not the scroller). + +Then the regression checks, serially: the Task 3 Step 9 UDPS self-check against the mock (both diffs against `/tmp/udps-baseline.*` still empty; no `source-` page appears, because UDPS has no panels), and the Task 5 chooser runs. + +- [ ] **Step 8: Commit** + +```bash +git add src CMakeLists.txt +git commit -m "source: capability panels as side-stack pages, with a probe for the path" +``` + +--- + +## Task 7: Documentation and the final check + +**Files:** +- Modify: `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `README.md`, `CLAUDE.md` + +**Interfaces:** none; documentation only. + +- [ ] **Step 1: `architecture.md`** + +In "Threads" (line ~13) replace "a producer thread in `Acquisition` owns `udps_client_t` and calls `udps_client_poll()`" with "a producer thread in `Acquisition` owns a `DataSource` and calls `source->poll(50)`". Add a "### Sources" section after "### Threads", covering exactly: + +- The kinds live in `src/source/`; `register_builtin_sources()` fills the registry explicitly (no static-init registration); a future `dlopen` loader would only call `register_kind()`. +- `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`; `Frame` is source-neutral and its pointers are valid only during `on_data`. +- 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`. +- The frame is single-timebase; a multi-timebase source (ABScope) will change the interface and the data path together. +- Panels are pages of `side_stack` named `source-`; disconnected means insensitive, not hidden. +- Session file naming for non-UDPS kinds (pseudo-host `-`, port 1; `source_kind` / `source_opt_` keys; UDPS files unchanged). + +- [ ] **Step 2: `build-and-run.md`** + +After the two example launches (line ~15) add: + +```sh +./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port +``` +and say that `--help` lists the registered kinds. In the Options line (line ~99) add `--source NAME` and the repeatable `--source-opt k=v`, and say `--server/--port/--multicast/--interface` populate the UDPS entries. + +- [ ] **Step 3: `verification.md`** + +Add under the probe list, after the `UDPSCOPE_PROBE_SIDE_TAB_MS` entry (line ~415): + +``` +- `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`. +``` +Also state, in the baselines section, that the UDPS runs must be unchanged by the plugin work. + +- [ ] **Step 4: `probe-readings.md`** + +Add a `## [source-kinds]` section after `[side-tab-probe]` containing the seven expected lines from Task 6 Step 7 and, for each, the defect a wrong reading names: +- `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 not defaulted). +- `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. +- `panel connected: sensitive=1`: `apply_source_panels` acted on the wrong widget. +- `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. +- `drained=1`: the command reached the producer thread through `post()`; `drained=0` is a queue that `poll()` does not drain, `after` greater than `before + 1` is a command run twice. + +Then update every existing `mnemonics=` / `form mnemonics=8/8` mention (`grep -n "8/8" docs/claude/*.md README.md`) to `9/9` with one sentence: the chooser's new Source type row has a mnemonic. + +- [ ] **Step 5: `README.md` and `CLAUDE.md`** + +- `README.md` line ~25 (Options): add `--source`, `--source-opt`. Line ~101 (Threading): "one producer thread owns the data source (the UDPStreamer client is the built-in one) and calls `poll()`". Add a short "Data sources" subsection: the chooser's Source type, the UDPS kind, the stub that exists only for the self-check, that legacy sessions are read as UDPS, and that the flat UDP, MCC E-1608 and ABScope kinds are planned (separate sub-projects). +- `CLAUDE.md`: in "What this project is", change "receives one UDP stream with `deps/udpsclient`" to "receives one data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered source kind (see `docs/claude/architecture.md`)"; change "a producer that owns the UDP client" to "a producer that owns the data source". Add to the Architecture row of the table: "the source plugins". + +- [ ] **Step 6: Final check** + +1. Clean full build: `rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:"`. Expected: `3`. +2. UDPS self-check, then compare (number-normalised) with the baseline: + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-final.out 2>/tmp/udps-final.err +kill $MOCK +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.out) <(sed 's/[0-9.]\+/N/g' /tmp/udps-final.out) +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.err) <(sed 's/[0-9.]\+/N/g' /tmp/udps-final.err) +grep -c "Gtk-CRITICAL" /tmp/udps-final.err; grep "gtk warnings" /tmp/udps-final.err +``` +Expected: both diffs empty, `0`, `gtk warnings: 0`. +3. The Task 6 Step 7 run and the Task 5 chooser runs, once each. +4. `grep -rn "udps_" src --include=*.cpp --include=*.hpp -l` must list only `src/source/udps_source.cpp`, `src/source/udps_source.hpp`, and `src/source/udps_kind.cpp` (if it names a `udps_*` symbol). Any other file is a leak of the old coupling; fix it. +5. Dev hygiene: `pgrep -f mock_udpstreamer` prints nothing. + +- [ ] **Step 7: Commit** + +```bash +git add docs README.md CLAUDE.md +git commit -m "docs: the source-plugin framework, the --source options and the source-kinds probe" +```