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