# 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" ```