diff --git a/CMakeLists.txt b/CMakeLists.txt index e13e79f..0a95d3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -236,6 +236,9 @@ add_executable(udpscope src/waveform.cpp src/event_store.cpp src/acquisition.cpp + src/source/source_config.cpp + src/source/data_source.cpp + src/source/registry.cpp src/plot_host.cpp src/export.cpp src/search.cpp diff --git a/src/model.cpp b/src/model.cpp index 79fe9f1..765459f 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -3,8 +3,6 @@ #include "model.hpp" -#include - #include #include #include @@ -646,7 +644,7 @@ bool ScopeModel::is_time_signal(const std::vector& signals, // signal: it has stamps but no cadence of its own, because it is not data. // Data channels can also be declared full-array (per-element stamps), and they // keep a declared rate -- treating them as time signals hid every real channel. - if (s.time_mode == UDPS_TIME_FULL_ARRAY && s.elements() > 1 && s.sampling_rate == 0.0) { + if (s.time_mode == kTimeFullArray && s.elements() > 1 && s.sampling_rate == 0.0) { return true; } return false; diff --git a/src/model.hpp b/src/model.hpp index 338caef..88e4def 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -24,6 +24,13 @@ namespace udpscope { +/// `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; + /// One streamed signal, as the CONFIG packet described it. struct SignalInfo { std::string name; diff --git a/src/source/data_source.cpp b/src/source/data_source.cpp new file mode 100644 index 0000000..d6eeae2 --- /dev/null +++ b/src/source/data_source.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/data_source.hpp" + +#include + +namespace udpscope { + +void DataSource::post(Command cmd) { + std::lock_guard lk(cmd_mu_); + cmds_.push_back(std::move(cmd)); +} + +void DataSource::drain_commands() { + std::deque local; + { + std::lock_guard lk(cmd_mu_); + local.swap(cmds_); + } + for (auto& c : local) c(); +} + +} // namespace udpscope diff --git a/src/source/data_source.hpp b/src/source/data_source.hpp new file mode 100644 index 0000000..b6feb58 --- /dev/null +++ b/src/source/data_source.hpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/frame.hpp" + +#include +#include +#include +#include +#include +#include + +namespace udpscope { + +/// Work for the producer thread. UI code posts these; `poll()` drains and runs them, so a +/// source's state is only ever touched by the thread that owns it. +using Command = std::function; + +/// The counters `StreamStats` shows, in source-neutral form. Producer thread only. +struct SourceStats { + std::uint64_t packets = 0; + std::uint64_t frames = 0; + std::uint64_t gaps = 0; + std::uint64_t late = 0; + std::uint64_t resequenced = 0; + std::uint64_t stale = 0; + std::uint64_t overflows = 0; + std::uint64_t reconnects = 0; + bool connected = false; +}; + +enum class SourceEvent { Connected, Disconnected, Error }; + +/// Where a source delivers, from inside `poll()`. +class SourceSink { + public: + virtual ~SourceSink() = default; + /// The signal set (re)defined. `publish_mode` is the stream's own publish mode. + virtual void on_config(const std::vector& signals, std::uint8_t publish_mode) = 0; + virtual void on_data(const Frame& frame) = 0; + virtual void on_event(SourceEvent event, const char* message) = 0; +}; + +/// Receives each raw datagram of a UDP-based source: bytes, length, receive time. +using DatagramTap = std::function; + +/// The per-connection object. `Acquisition` owns one and drives it from the producer thread. +class DataSource { + public: + virtual ~DataSource() = default; + + /// Allocates and connects. Throws `std::runtime_error` with a message fit for the user. + /// Does not deliver: CONFIG and `Connected` arrive from `poll()`. + virtual void open(SourceSink& sink) = 0; + /// Runs queued commands, then waits up to `timeout_ms` for work and delivers it. + virtual void poll(int timeout_ms) = 0; + virtual void close() = 0; + /// Producer thread only. + virtual SourceStats stats() const = 0; + virtual bool connected() const = 0; + /// Optional: UDP-based sources feed the raw-packet capture through this. + virtual void set_datagram_tap(DatagramTap) {} + + /// Thread-safe. The command runs inside a later `poll()`. + void post(Command cmd); + + protected: + /// For `poll()` implementations: runs and clears the queue. + void drain_commands(); + + private: + std::mutex cmd_mu_; + std::deque cmds_; +}; + +} // namespace udpscope diff --git a/src/source/frame.hpp b/src/source/frame.hpp new file mode 100644 index 0000000..15ff9e1 --- /dev/null +++ b/src/source/frame.hpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "model.hpp" + +#include + +namespace udpscope { + +/// One signal's values in a frame. `values` points into the source's own buffer and is valid +/// only while the `on_data` call that received the frame is running. +struct SignalValues { + const double* values = nullptr; + std::uint32_t count = 0; +}; + +/// A data frame in source-neutral form; it mirrors `udps_frame_t`. A source with no packet +/// counter supplies a running one and `lost = 0`: the hole, backfill and time-base logic only +/// needs `counter`, `lost` and `late`. +struct Frame { + std::uint32_t counter = 0; + std::uint32_t lost = 0; + bool late = false; + double recv_time = 0.0; + std::uint8_t publish_mode = 0; + std::uint32_t num_samples = 0; + std::uint32_t num_signals = 0; + const SignalInfo* signals = nullptr; + const SignalValues* values = nullptr; + + /// Element `elem` of signal `sig` at sample `sample`. Mirrors `udps_frame_value`: a scalar + /// signal carried once per sample is indexed by `sample`, a vector/matrix signal by `elem`. + /// Out of range reads 0.0. + double value(std::uint32_t sig, std::uint32_t sample, std::uint32_t elem) const noexcept { + if (sig >= num_signals) return 0.0; + const SignalValues& v = values[sig]; + const std::uint32_t idx = + (v.count == num_samples && signals[sig].elements() == 1u) ? sample : elem; + return idx < v.count ? v.values[idx] : 0.0; + } +}; + +} // namespace udpscope diff --git a/src/source/registry.cpp b/src/source/registry.cpp new file mode 100644 index 0000000..3c3c88e --- /dev/null +++ b/src/source/registry.cpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" + +#include +#include + +namespace udpscope { +namespace { + +std::vector>& owned() { + static std::vector> v; + return v; +} +std::vector& raw() { + static std::vector v; + return v; +} + +std::string sanitize(const std::string& s) { + std::string out = s; + for (char& c : out) { + const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-'; + if (!ok) c = '_'; + } + return out; +} + +} // namespace + +void register_kind(std::unique_ptr kind) { + if (!kind || find_kind(kind->id())) return; + raw().push_back(kind.get()); + owned().push_back(std::move(kind)); +} + +SourceKind* find_kind(const std::string& id) { + for (SourceKind* k : raw()) { + if (k->id() == id) return k; + } + return nullptr; +} + +const std::vector& kinds() { return raw(); } + +void register_builtin_sources() { + static bool done = false; + if (done) return; + done = true; + // Task 2: register_kind(make_udps_kind()); + // Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set +} + +std::string session_host(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") return cfg.get("server", "127.0.0.1"); + SourceKind* k = find_kind(kind_id); + return sanitize(kind_id + "-" + (k ? k->identity(cfg) : std::string("unknown"))); +} + +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") { + const long p = cfg.get_int("port", 8000); + return static_cast(p > 0 && p <= 65535 ? p : 8000); + } + return 1; +} + +} // namespace udpscope diff --git a/src/source/registry.hpp b/src/source/registry.hpp new file mode 100644 index 0000000..5293000 --- /dev/null +++ b/src/source/registry.hpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/source_kind.hpp" + +#include +#include +#include +#include + +namespace udpscope { + +/// Takes ownership. A second kind with the same id is ignored. +void register_kind(std::unique_ptr kind); +SourceKind* find_kind(const std::string& id); +/// In registration order (the chooser's dropdown order). +const std::vector& kinds(); +/// Registers the built-in kinds; idempotent. No static-init self-registration, which a linker +/// may drop. A later `dlopen` loader would only call `register_kind()`. +void register_builtin_sources(); + +/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`). +std::unique_ptr make_udps_kind(); +std::unique_ptr make_probe_kind(); + +/// The (host, port) pair that names a source's session file. UDPS keeps its legacy +/// `server`/`port`; any other kind is `"-"` (characters outside `[A-Za-z0-9._-]` +/// become `_`) on port 1 -- not 0, which the session-file parser rejects and waveform mode uses. +std::string session_host(const std::string& kind_id, const SourceConfig& cfg); +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg); + +} // namespace udpscope diff --git a/src/source/source_config.cpp b/src/source/source_config.cpp new file mode 100644 index 0000000..99f810f --- /dev/null +++ b/src/source/source_config.cpp @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +void SourceConfig::set(const std::string& key, const std::string& value) { + for (auto& kv : items_) { + if (kv.first == key) { + kv.second = value; + return; + } + } + items_.emplace_back(key, value); +} + +bool SourceConfig::has(const std::string& key) const { + return std::any_of(items_.begin(), items_.end(), + [&](const auto& kv) { return kv.first == key; }); +} + +std::string SourceConfig::get(const std::string& key, const std::string& def) const { + for (const auto& kv : items_) { + if (kv.first == key) return kv.second; + } + return def; +} + +long SourceConfig::get_int(const std::string& key, long def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const long n = std::strtol(v.c_str(), &end, 10); + return (end == v.c_str() || *end != '\0') ? def : n; +} + +double SourceConfig::get_double(const std::string& key, double def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const double d = std::strtod(v.c_str(), &end); + return (end == v.c_str() || *end != '\0') ? def : d; +} + +bool SourceConfig::get_bool(const std::string& key, bool def) const { + if (!has(key)) return def; + std::string v = get(key); + std::transform(v.begin(), v.end(), v.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (v == "1" || v == "true" || v == "yes" || v == "on") return true; + if (v == "0" || v == "false" || v == "no" || v == "off") return false; + return def; +} + +bool SourceConfig::valid_key(const std::string& key) { + return !key.empty() && std::all_of(key.begin(), key.end(), [](unsigned char c) { + return std::isalnum(c) || c == '_'; + }); +} + +bool SourceConfig::parse_assignment(const std::string& kv) { + const auto eq = kv.find('='); + if (eq == std::string::npos) return false; + const std::string key = kv.substr(0, eq); + if (!valid_key(key)) return false; + set(key, kv.substr(eq + 1)); + return true; +} + +} // namespace udpscope diff --git a/src/source/source_config.hpp b/src/source/source_config.hpp new file mode 100644 index 0000000..01fa0ec --- /dev/null +++ b/src/source/source_config.hpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include + +namespace udpscope { + +/// A source's settings: an ordered string key/value map. It is the stored form (top-level +/// `source_opt_` entries of a session), the `--source-opt k=v` form, and what a config form +/// reads and writes. Order is insertion order, so a stored session stays diff-friendly. +class SourceConfig { + public: + using Items = std::vector>; + + SourceConfig() = default; + explicit SourceConfig(Items items) : items_(std::move(items)) {} + + /// Replaces the value in place if the key exists, else appends. + void set(const std::string& key, const std::string& value); + bool has(const std::string& key) const; + std::string get(const std::string& key, const std::string& def = {}) const; + long get_int(const std::string& key, long def) const; + double get_double(const std::string& key, double def) const; + /// 1/true/yes/on and 0/false/no/off (case-insensitive); anything else gives `def`. + bool get_bool(const std::string& key, bool def) const; + + /// Parses `k=v`; false (and no change) if there is no `=` or the key is not `valid_key`. + bool parse_assignment(const std::string& kv); + + const Items& items() const noexcept { return items_; } + bool empty() const noexcept { return items_.empty(); } + + /// `[A-Za-z0-9_]+` -- the characters that survive as a TOML bare key suffix. + static bool valid_key(const std::string& key); + + private: + Items items_; +}; + +} // namespace udpscope diff --git a/src/source/source_kind.hpp b/src/source/source_kind.hpp new file mode 100644 index 0000000..fac37bc --- /dev/null +++ b/src/source/source_kind.hpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { + +/// A kind's configuration UI, as pieces the chooser places in its own group. The widgets are +/// floating `AdwPreferencesRow`s; the closures capture them, so a form must not outlive its rows +/// (the chooser drops the form when it removes the rows). +struct ConfigForm { + std::vector rows; + /// Shown under the group's title; empty for none. + std::string description; + /// The widget that takes focus when the form is shown; may be null. + GtkWidget* focus = nullptr; + /// Puts `cfg` into the widgets (absent keys leave the kind's defaults). + std::function write; + /// The widgets as a `SourceConfig`. + std::function read; + /// Whether the form is complete enough to connect. + std::function valid; + /// Calls `fn` whenever any widget changes. + std::function)> on_change; +}; + +/// A capability panel (signal generator, digital I/O, ...) for the side area. +struct SourcePanel { + std::string id; ///< stack page name + std::string title; + std::string icon; ///< icon name for the tab, may be empty + GtkWidget* widget = nullptr; +}; + +/// One plugin type: its identity, factory and UI. +class SourceKind { + public: + virtual ~SourceKind() = default; + virtual std::string id() const = 0; ///< short, `[a-z0-9]+`; the CLI and session name + virtual std::string display_name() const = 0; + /// A stable, human-readable name for one configured source (`host:port`, a serial number). + virtual std::string identity(const SourceConfig& cfg) const = 0; + virtual std::unique_ptr make(const SourceConfig& cfg) const = 0; + virtual ConfigForm make_config_form() const = 0; + /// Panels for a live source. The widgets must reach the source only through `post()`. + virtual std::vector make_panels(DataSource& source) const { + (void)source; + return {}; + } + /// Whether the source reports raw datagrams (enables the packet-capture UI). + virtual bool has_datagrams() const { return false; } +}; + +} // namespace udpscope