diff --git a/CMakeLists.txt b/CMakeLists.txt index 90f9cd1..9080f6b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -241,6 +241,7 @@ add_executable(udpscope src/source/registry.cpp src/source/udps_source.cpp src/source/udps_kind.cpp + src/source/probe_source.cpp src/plot_host.cpp src/export.cpp src/search.cpp diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 857cfb9..0b42d37 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -322,6 +322,10 @@ std::vector known_sources() { s.capture_packets = st.capture_packets; s.multicast = st.multicast; s.interface_addr = st.interface_addr; + if (!st.source_kind.empty()) { + s.kind = st.source_kind; + s.opts = st.source_opts; + } s.rows = static_cast(st.rows.size()); for (const LayoutState::Row& row : st.rows) { for (const std::string& sig : row.signals) { @@ -558,6 +562,11 @@ bool load_layout(const std::string& path, LayoutState& out) { st.multicast = strip_quote(value); } else if (key == "interface") { st.interface_addr = strip_quote(value); + } 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)); } else if (key == "comment") { st.comment = strip_quote(value); } else if (key == "buffer_mb") { @@ -699,6 +708,12 @@ bool save_layout(const std::string& path, const LayoutState& state) { if (!state.interface_addr.empty()) { o << "interface = " << toml_escape(state.interface_addr) << "\n"; } + 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"; + } + } // Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last // of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly, // never saw it and a session's enable state was silently lost. diff --git a/src/layout_store.hpp b/src/layout_store.hpp index c919289..eb7db18 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -8,6 +8,7 @@ #include "math_channel.hpp" #include "measure.hpp" +#include "source/source_config.hpp" namespace udpscope { @@ -54,6 +55,12 @@ struct LayoutState { /// unicast; empty `interface_addr` is the default route. std::string multicast; std::string interface_addr; + /// Which source kind this session belongs to. Empty means `udps`: every file written before the + /// plugins existed. Only a kind other than `udps` is written, so a UDPS session file is + /// byte-identical to what earlier versions wrote. + std::string source_kind; + /// That kind's settings (`SourceConfig`), stored as top-level `source_opt_` entries. + SourceConfig source_opts; /// The derived channels, graphs and all: the editor's work is part of the session, not of the /// process. A graph is stored as one string per node so the file stays in this store's TOML @@ -144,6 +151,8 @@ struct KnownSource { std::uint16_t port = 0; std::string multicast; ///< empty = unicast std::string interface_addr; ///< empty = the default route + std::string kind = "udps"; ///< the session's source kind + SourceConfig opts; ///< that kind's settings (empty for UDPS, whose fields are above) int rows = 0; ///< plots the session had int signals = 0; ///< named slots across those plots std::int64_t mtime = 0; ///< orders the list; never shown diff --git a/src/source/probe_source.cpp b/src/source/probe_source.cpp new file mode 100644 index 0000000..218c126 --- /dev/null +++ b/src/source/probe_source.cpp @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/probe_source.hpp" + +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +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(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(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>*>(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 make(const SourceConfig& cfg) const override { + return std::make_unique(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>(); + g_signal_connect_data(rate, "notify::value", G_CALLBACK(on_rate_changed), + new std::shared_ptr>(cb), + +[](gpointer d, GClosure*) { + delete static_cast>*>(d); + }, + static_cast(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(c.get_int("rate", 1000))); + }; + f.read = [=]() { + SourceConfig c; + c.set("rate", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(rate))))); + return c; + }; + f.valid = []() { return true; }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_probe_kind() { return std::make_unique(); } + +} // namespace udpscope diff --git a/src/source/probe_source.hpp b/src/source/probe_source.hpp new file mode 100644 index 0000000..0e1b8ab --- /dev/null +++ b/src/source/probe_source.hpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include +#include +#include + +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 open_{false}; + bool configured_ = false; + std::uint32_t counter_ = 0; + std::uint64_t frames_ = 0; + double phase_ = 0.0; + std::vector signals_; + std::vector batch_; + SignalValues values_; + std::atomic applied_{0}; +}; + +} // namespace udpscope diff --git a/src/source/registry.cpp b/src/source/registry.cpp index 2c2f1ac..5b06128 100644 --- a/src/source/registry.cpp +++ b/src/source/registry.cpp @@ -50,7 +50,7 @@ void register_builtin_sources() { if (done) return; done = true; register_kind(make_udps_kind()); - // Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set + 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) { diff --git a/src/ui.cpp b/src/ui.cpp index 959554e..60a8cb6 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -33,6 +33,7 @@ #include "model.hpp" #include "plot_host.hpp" #include "search.hpp" +#include "source/registry.hpp" #include "source_dialog.hpp" #include @@ -100,8 +101,7 @@ void open_configurations(App& app) { GtkWidget* title = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(title), - ("" + app.source_host + ":" + std::to_string(app.source_port) + - "") + ("" + app.source_title + "") .c_str()); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), title); @@ -5918,6 +5918,14 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.source_port = cfg.open_waveform.empty() ? cfg.session_port() : 0; app.source_multicast = cfg.open_waveform.empty() ? cfg.multicast : std::string(); app.source_interface = cfg.interface_addr; + 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); + } // The event cache is keyed on the source, like the packet capture. A waveform has no source, so a // file-mode run has no cache either -- there are no latches in a file. if (!app.source_host.empty() && app.source_port != 0) { diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 2af2da5..144e5a6 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -29,6 +29,7 @@ #include "plot_host.hpp" #include "search.hpp" #include "settings.hpp" +#include "source/source_config.hpp" #include "ui_prefs.hpp" #include "shortcuts.hpp" #include "status_text.hpp" @@ -837,6 +838,11 @@ struct App { /// chooser needs both to reconnect to a multicast source from a stored row. std::string source_multicast; std::string source_interface; + /// 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, ` ` otherwise). + std::string source_kind = "udps"; + SourceConfig source_opts; + std::string source_title; guint layout_save_source = 0; bool layout_loaded = false; unsigned signal_generation = ~0u; diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index d6c059f..329dc57 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -97,6 +97,8 @@ LayoutState collect_layout(App& app) { // The connection is the session's too: the file's name carries only host and port. st.multicast = app.source_multicast; st.interface_addr = app.source_interface; + st.source_kind = app.source_kind; + st.source_opts = app.source_opts; // The configuration's own identity and the startup settings it carries. The buffer size is a // *startup* parameter -- the rings are allocated before any stream is known -- so a configuration // is the only place it can be remembered per source, and the chooser applies it on open.