95 KiB
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-runagainsttools/mock_udpstreamer.py; extend the probes insrc/ui_probe.cpprather than adding a harness. Readdocs/claude/verification.mdanddocs/claude/probe-readings.mdbefore running one. - A clean full build has exactly three warnings; an incremental build shows one. The run must show
gtk warnings: 0and noGtk-CRITICAL. - Every new source file starts with
// SPDX-License-Identifier: MITand// SPDX-FileCopyrightText: 2026 Martino Ferrari. - Exactly two threads: the producer (owns the source) and the GTK main thread. UI code never touches a
DataSourcedirectly exceptpost(). - UDPS behaviour must not change:
--server/--port/--multicast/--interfacekeep meaning UDPS; legacyhost_port.tomlsession files are read asudps; 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 withg_signal_emit_by_name(w, "clicked"). - Verification runs are short and serial; kill the mock afterwards; never point a run at the real
~/.config(useXDG_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
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:
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 nameUDPSCOPE_PROBE_SOURCESis the chooser discovery probe), its value is a delay in ms, and its presence also registers the stubprobekind; -
Commandisstd::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; -
Acquisitioncreates its source lazily instart(); -
Frameholds pointers into the source's buffers, valid only duringon_data; -
make_config_form()takes no argument (the chooser callsform.write(cfg)to fill it);ConfigFormis a list ofAdwPreferencesRowwidgets pluswrite/read/valid/on_changeclosures (so the chooser can place them in its own group); -
panels become pages of the existing
side_stack. -
Step 4: Commit
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):SourceConfigwithusing 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
// 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
// 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
// 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
// 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
// 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
// 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.hppandregistry.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
// 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:
/// `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
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 DataSourceconstructed from aSourceConfigwith keysserver(default127.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
// 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
// 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.
// 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
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()returningDataSource*(null beforestart()).- CLI:
--source NAME, repeatable--source-opt k=v.
-
Step 1:
config.hpp
Add #include "source/source_config.hpp" after <string>. After server_specified add:
/// `--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:
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]):
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:
} 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 {toclass Acquisition : private SourceSink {. -
Doc of the class: "Owns the data source and the producer thread...".
-
Add to the public section:
/// 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:
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)andbackfill_from(const Frame& frame, ...). -
Replace
udps_client* client_ = nullptr;withstd::unique_ptr<DataSource> source_;. -
In the doc comment of
build_stamps,frame->num_samplesbecomesframe.num_samples. -
Step 5:
acquisition.cpp, the mechanical part
Run, from the repo root:
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
- Delete
to_info(lines ~32-46),#include <udps_client.h>(line 8), the four trampolines at the end of the file (// Static trampolines ...throughevent_trampoline), anddatagram_trampoline(lines ~174-177). - Add includes
"source/registry.hpp". start()becomes:
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);
}
}
set_capture_want: replacecfg_.server/cfg_.portwithcfg_.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);.on_configsignature:void Acquisition::on_config(const std::vector<SignalInfo>& info, std::uint8_t publish_mode). Delete theinfovector construction and useinfo.size()forcountin the log line (%uwithstatic_cast<unsigned>(info.size())). The mode log chain becomeskTimePacket... already done by sed.on_event:
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:~2497layout_path(app->model->config().server, ...port)->layout_path(config().session_host(), config().session_port()); -
src/ui.cpp:~5917-5918app.source_host/source_port->cfg.session_host()/cfg.session_port()(keep theopen_waveform.empty()guards); -
src/ui_probe.cpp:~5414likewise; -
src/source_dialog.cpp:~1132-1135(apply_stored_settings) ->cfg.session_host()/cfg.session_port(). Leaveui.cpp:~5398(a log line),ui.cpp:5919-5920(source_multicast/source_interface) andsource_dialog.cpp:~35-38,1199(UDPS fields filled fromKnownSource) 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
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
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 DataSourcewithexplicit ProbeSource(const SourceConfig&),void note_applied(),std::uint64_t commands_applied() const; optionrate(Hz, default 1000).- Kind id
"probe", display name"Probe (test)", identity"rate<N>". LayoutState::source_kind(std::string, empty = UDPS) andLayoutState::source_opts(SourceConfig);KnownSource::kind(default"udps") andKnownSource::opts.App::source_kind,App::source_opts,App::source_title.- Session file keys: top-level
source_kind = "probe"andsource_opt_<key> = "<value>", written only for kinds other thanudps.
-
Step 1:
probe_source.hpp
// 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.
// 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:
/// 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:
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):
} 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):
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):
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):
/// 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):
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;:
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
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
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,...](withUDPSCOPE_PROBE_NEWHOSTstill 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:
<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 andapply_source
Includes: #include "source/registry.hpp".
In struct Chooser replace form_server, form_port, form_group, form_iface (lines ~108-111) with:
/// 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:
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_rebuildand the kind handler
Replace on_form_server_changed (lines ~435-444) with:
/// 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:
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:
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
- Remove the four
gtk_builder_get_objectlines forserver_row/port_row/group_row/iface_row, and in the null-check list dropform_server/form_port/form_group/form_iface. Add:
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:
// 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);
- Delete the
g_signal_connect(c.form_server, "changed", ...)line (it is replaced byon_change) and the finalgtk_widget_grab_focus(GTK_WIDGET(c.form_server));(the rebuild focused the kind'sfocuswidget). Note the rebuild'sgrab_focushappens before the dialog is presented, as the old code did after; if focus does not land (probe:mnemonics/connect_sensitivelines are unaffected), callif (c.form_cfg.focus) gtk_widget_grab_focus(c.form_cfg.focus);again afteradw_dialog_present. - In
form_close's caller and theclosedhandler, also clear the form: in theclosedlambda addChooser& 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 staleread()from ever being callable).
-
Step 6: Row titles, loading window,
report_pick -
chooser_populatetitle (line ~375):
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): forcfg.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 existingfprintf, add
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:
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:
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
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
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
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; };andApp::source_panels(std::vector<SourcePanelPage>).nameis the stack page name,"source-" + SourcePanel::id;contentis 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 inui_app.hpp, defined inui_source.cpp).gboolean source_kinds_probe(gpointer)(declared inui_app.hpp, defined inui_probe.cpp), scheduled byUDPSCOPE_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():
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:
/// 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):
/// 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:
/// 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
// 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
side_tab_title(line ~2981) becomesstatic const char* side_tab_title(const App& app, const char* name): after the two built-instrcmplines and beforereturn "";add
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)).
- In
on_activate, immediately after the block that selects thesignalspage and connectsnotify::visible-child-name(line ~5280-5284, ending}), add:
// 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);
-
In
tick(), directly afternote_panel_controls(*app);addsync_source_panels(*app);. -
In
run_ui, next to the otherg_getenv("UDPSCOPE_PROBE_*_MS")schedules (line ~6090, afterUDPSCOPE_PROBE_DENSITY_MS):
if (const char* env = g_getenv("UDPSCOPE_PROBE_SOURCE_KINDS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), source_kinds_probe, &app);
}
- The packet-capture button is for UDP sources only (spec: "left disabled, not hidden"). In
ui_app.hpp, aftersource_title, addbool source_has_datagrams = true; ///< the kind reports raw datagrams; false leaves the capture button disabled. Inrun_ui, after thesource_titleblock added in Task 4, addif (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams();. Atui.cpp:~4554changesnap.connected ? TRUE : FALSEtosnap.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):
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
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
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 futuredlopenloader would only callregister_kind(). -
DataSourcedelivers intoAcquisition(aSourceSink) from insidepoll();Frameis source-neutral and its pointers are valid only duringon_data. -
The GTK thread reaches a source only through
DataSource::post(Command); the queue is drained at the top ofpoll(). A panel never calls a source directly. -
UdpsSourcewrapsdeps/udpsclientunchanged;udps_*names appear only insrc/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_stacknamedsource-<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:
./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 meansregister_builtin_sources()was not called beforeparse_args(), or the env var gate is wrong.round_trip=0: a form'swriteandreaddisagree about a key (a renamedSourceConfigkey, or an absent key not defaulted).panel disconnected: sensitive=0 present=1: disable, don't hide --present=0is a panel that was hidden or never added;sensitive=1is a panel that stays live with no source.panel connected: sensitive=1:apply_source_panelsacted on the wrong widget.capture button: present=1 sensitive=0: a source with no datagrams leaves the capture UI disabled, not hidden;sensitive=1is the button live for a source that has nothing to capture,present=0a hidden one.drained=1: the command reached the producer thread throughpost();drained=0is a queue thatpoll()does not drain,aftergreater thanbefore + 1is 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.mdandCLAUDE.md -
README.mdline ~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 callspoll()". 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 withdeps/udpsclient" to "receives one data source -- a UDPStreamer stream throughdeps/udpsclientby default, or another registered source kind (seedocs/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
- 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. - UDPS self-check, then compare (number-normalised) with the baseline:
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
git add docs README.md CLAUDE.md
git commit -m "docs: the source-plugin framework, the --source options and the source-kinds probe"