Files
udpscope/docs/superpowers/plans/2026-10-31-source-plugins.md

2278 lines
95 KiB
Markdown

# 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<void()>`;
- session keys are top-level TOML keys (`source_kind`, `source_opt_<key>`), written only for non-UDPS kinds, not a `[source]` table; non-UDPS session files are named by a pseudo-host `<kind>-<identity>` 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<std::pair<std::string,std::string>>;`, `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>)`, `SourceKind* find_kind(const std::string&)`, `const std::vector<SourceKind*>& 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 <string>
#include <utility>
#include <vector>
namespace udpscope {
/// A source's settings: an ordered string key/value map. It is the stored form (top-level
/// `source_opt_<key>` 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<std::pair<std::string, std::string>>;
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 <algorithm>
#include <cctype>
#include <cstdlib>
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<char>(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 <cstdint>
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 <cstddef>
#include <cstdint>
#include <deque>
#include <functional>
#include <mutex>
#include <vector>
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<void()>;
/// 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<SignalInfo>& 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<void(const std::uint8_t*, std::size_t, double)>;
/// 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<Command> cmds_;
};
} // namespace udpscope
```
- [ ] **Step 5: `data_source.cpp`**
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/data_source.hpp"
#include <utility>
namespace udpscope {
void DataSource::post(Command cmd) {
std::lock_guard<std::mutex> lk(cmd_mu_);
cmds_.push_back(std::move(cmd));
}
void DataSource::drain_commands() {
std::deque<Command> local;
{
std::lock_guard<std::mutex> 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 <gtk/gtk.h>
#include <functional>
#include <memory>
#include <string>
#include <vector>
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<GtkWidget*> 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<void(const SourceConfig&)> write;
/// The widgets as a `SourceConfig`.
std::function<SourceConfig()> read;
/// Whether the form is complete enough to connect.
std::function<bool()> valid;
/// Calls `fn` whenever any widget changes.
std::function<void(std::function<void()>)> 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<DataSource> 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<SourcePanel> 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 <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
/// Takes ownership. A second kind with the same id is ignored.
void register_kind(std::unique_ptr<SourceKind> kind);
SourceKind* find_kind(const std::string& id);
/// In registration order (the chooser's dropdown order).
const std::vector<SourceKind*>& 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<SourceKind> make_udps_kind();
std::unique_ptr<SourceKind> make_probe_kind();
/// The (host, port) pair that names a source's session file. UDPS keeps its legacy
/// `server`/`port`; any other kind is `"<kind>-<identity>"` (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 <algorithm>
#include <cstdlib>
namespace udpscope {
namespace {
std::vector<std::unique_ptr<SourceKind>>& owned() {
static std::vector<std::unique_ptr<SourceKind>> v;
return v;
}
std::vector<SourceKind*>& raw() {
static std::vector<SourceKind*> 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<SourceKind> 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<SourceKind*>& 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<std::uint16_t>(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 <udps_client.h>` 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<SourceKind> make_udps_kind()` with id `"udps"`, display name `"UDPStreamer"`, identity `"<server>:<port>"`, `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 <udps_client.h>
#include <string>
#include <vector>
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<SignalInfo> signals_;
std::vector<SignalValues> values_;
};
} // namespace udpscope
```
- [ ] **Step 2: `udps_source.cpp`**
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/udps_source.hpp"
#include <stdexcept>
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<std::uint16_t>(cfg.get_int("port", 8000))),
multicast_(cfg.get("multicast")),
interface_(cfg.get("interface")),
recv_buffer_(static_cast<int>(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<std::uint32_t>(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<UdpsSource*>(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<UdpsSource*>(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<UdpsSource*>(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<UdpsSource*>(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 <adwaita.h>
#include <memory>
namespace udpscope {
namespace {
using ChangeCb = std::shared_ptr<std::function<void()>>;
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<ChangeCb*>(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<ChangeCb*>(d); },
static_cast<GConnectFlags>(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<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<UdpsSource>(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<std::function<void()>>();
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<double>(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<long>(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<void()> fn) { *cb = std::move(fn); };
return f;
}
};
} // namespace
std::unique_ptr<SourceKind> make_udps_kind() { return std::make_unique<UdpsKind>(); }
} // 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 `<string>`. 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 <udps_client.h>` with `#include "source/data_source.hpp"` and add `#include <memory>`.
- 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<SignalInfo>& 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<DataSource> 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.<field>` 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 <udps_client.h>` (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<double>::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<SignalInfo>& 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<unsigned>(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<N>"`.
- `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_<key> = "<value>"`, 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 <atomic>
#include <cstdint>
#include <vector>
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<bool> open_{false};
bool configured_ = false;
std::uint32_t counter_ = 0;
std::uint64_t frames_ = 0;
double phase_ = 0.0;
std::vector<SignalInfo> signals_;
std::vector<double> batch_;
SignalValues values_;
std::atomic<std::uint64_t> 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 <adwaita.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <thread>
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<int>(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<double>(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<std::shared_ptr<std::function<void()>>*>(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<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<ProbeSource>(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<std::function<void()>>();
g_signal_connect_data(rate, "notify::value", G_CALLBACK(on_rate_changed),
new std::shared_ptr<std::function<void()>>(cb),
+[](gpointer d, GClosure*) {
delete static_cast<std::shared_ptr<std::function<void()>>*>(d);
},
static_cast<GConnectFlags>(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<double>(c.get_int("rate", 1000)));
};
f.read = [=]() {
SourceConfig c;
c.set("rate", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(rate)))));
return c;
};
f.valid = []() { return true; };
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return f;
}
};
} // namespace
std::unique_ptr<SourceKind> make_probe_kind() { return std::make_unique<ProbeKind>(); }
} // namespace udpscope
```
(Add `#include <functional>` and `#include <memory>` 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_<key>` 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, `<kind> <identity>` 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=<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
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwComboRow" id="kind_row">
<property name="title" translatable="yes">Source _type</property>
<property name="use-underline">True</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup" id="source_group">
<property name="title" translatable="yes">Connection</property>
</object>
</child>
```
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<SourceKind*> form_kinds; ///< dropdown order
ConfigForm form_cfg;
std::vector<GtkWidget*> 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<Chooser*>(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<Chooser*>(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<SourcePanelPage>`). `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=<delay ms>`.
- [ ] **Step 1: The probe kind's panel**
In `src/source/probe_source.cpp`, inside `class ProbeKind`, add after `make_config_form()`:
```cpp
std::vector<SourcePanel> 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<DataSource*>(user);
s->post([s] { static_cast<ProbeSource*>(s)->note_applied(); });
}),
src, nullptr, static_cast<GConnectFlags>(0));
SourcePanel p;
p.id = "poke";
p.title = "Probe";
p.widget = box;
return {p};
}
```
(The `static_cast<ProbeSource*>` 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<SourcePanelPage> 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 <adwaita.h>
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<guint>(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<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const ProbeSource* src = a->acq != nullptr ? dynamic_cast<const ProbeSource*>(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<App*>(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<ProbeSource*>(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-<id>`; disconnected means insensitive, not hidden.
- Session file naming for non-UDPS kinds (pseudo-host `<kind>-<identity>`, port 1; `source_kind` / `source_opt_<key>` 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"
```