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

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-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
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 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

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

// 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.hpp and registry.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 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

// 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() 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:

  /// `--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 { to class 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) 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:

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:
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);
  }
}
  1. 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);.
  2. 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.
  3. 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:~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
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 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

// 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,...] (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:

                <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:

  /// 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_rebuild and 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
  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:
  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);
  1. 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.
  2. 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):

    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
  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; }; 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():

  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
  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
  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)).

  1. In on_activate, immediately after the block that selects the signals page and connects notify::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);
  1. In tick(), directly after note_panel_controls(*app); add sync_source_panels(*app);.

  2. In run_ui, next to the other g_getenv("UDPSCOPE_PROBE_*_MS") schedules (line ~6090, after UDPSCOPE_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);
  }
  1. 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):

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 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:

./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:
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"