From 78a29fbc396b697a0a8436ac3c590a8b609c4a48 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 01:05:38 +0100 Subject: [PATCH] flatudp: the source, kind, mock sender and stream probes Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 2 + docs/claude/build-and-run.md | 19 ++ docs/claude/probe-readings.md | 55 ++++- docs/claude/verification.md | 20 +- src/source/flatudp/flatudp_kind.cpp | 206 +++++++++++++++++++ src/source/flatudp/flatudp_source.cpp | 286 ++++++++++++++++++++++++++ src/source/flatudp/flatudp_source.hpp | 104 ++++++++++ src/source/registry.cpp | 1 + src/source/registry.hpp | 3 +- src/ui.cpp | 3 + src/ui_app.hpp | 7 +- src/ui_probe.cpp | 134 +++++++++++- tools/mock_flatudp.py | 129 ++++++++++++ 13 files changed, 962 insertions(+), 7 deletions(-) create mode 100644 src/source/flatudp/flatudp_kind.cpp create mode 100644 src/source/flatudp/flatudp_source.cpp create mode 100644 src/source/flatudp/flatudp_source.hpp create mode 100755 tools/mock_flatudp.py diff --git a/CMakeLists.txt b/CMakeLists.txt index fb64fdd..eb8c066 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -248,6 +248,8 @@ add_executable(udpscope src/source/e1608/e1608_kind.cpp src/source/e1608/e1608_panels.cpp src/source/flatudp/flatudp_signals.cpp + src/source/flatudp/flatudp_source.cpp + src/source/flatudp/flatudp_kind.cpp src/plot_host.cpp src/export.cpp src/search.cpp diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index c7d2a76..8e819c2 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -135,6 +135,25 @@ python3 tools/mock_e1608.py --port 54711 & --source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1 ``` +`--source flatudp` receives the MARTe2 `UDPSender` datasource (one datagram per cycle: the signals +concatenated in declaration order, no header, no padding). The datagram carries **no description**, so the +settings are the whole contract: `port` 44488, `bind`, `multicast` and `iface` (a group to join and the local +interface for it), `rate` (the sender's cycle rate in Hz; **required**, it sizes the ring and the ingest +decimation and spaces the samples within a frame), `endian` `little|big`, and `signals` +`name:type[count],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64` (a count above 1 +makes a vector signal: one channel whose `count` values per datagram are consecutive samples, as a UDPStreamer +block signal), e.g. `Trigger:uint8,Ia:float32,Wave:float32[16]`. A datagram of any other size than +the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as +`[flatudp] ...`. Loss is never reported: the stream has no counter. `tools/mock_flatudp.py` is a sender for +a signal spec (`--rate`, `--signals`, `--big-endian`, `--gap S,D`, `--bad-every S`, `--multicast G --mcast-if A`, +`--log FILE`). + +```sh +python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals Trigger:uint8,Ia:float32,Wave:int16[4] & +./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \ + --source-opt signals=Trigger:uint8,Ia:float32,Wave:int16[4] +``` + `tools/mock_udpstreamer.py` has waveform-injection options used by the probes: - `--square-harmonics N` shapes `square`: `N` odd harmonics of the ideal step's Fourier diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index dac2c6d..a862689 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1827,9 +1827,10 @@ are unrealized and measure 0x0. ## `[source-kinds]` ``` -[source-kinds] kinds=udps,e1608,probe +[source-kinds] kinds=udps,e1608,flatudp,probe [source-kinds] form udps: round_trip=1 valid=1 [source-kinds] form e1608: round_trip=1 valid=1 +[source-kinds] form flatudp: 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 @@ -1841,10 +1842,10 @@ are unrealized and measure 0x0. source-plugin path from the registry to the producer thread. The variable also registers the test-only `probe` kind, so a run without it never shows that kind. The `form` line appears once per registered kind. -- `kinds=udps,e1608,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`, +- `kinds=udps,e1608,flatudp,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 that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1 and plot0; a mismatch names one of those. + absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1 and plot0; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals. - `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 behind it. - `panel connected: sensitive=1`: anything else means `apply_source_panels` acted on the wrong widget (it must @@ -1907,6 +1908,54 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped proves nothing). +## `[flatudp]` + +``` +[flatudp] codec: ok=1 +[flatudp] datagram of 22 bytes, expected 21, check the signal list +[flatudp] stream: connected=1 signals=4 names=Trigger,Ia,Cnt,Wave expect=Trigger,Ia,Cnt,Wave frames=3735 rate=1000.000 ok=1 +[flatudp] channels: count=4 expect=4 ok=1 +[flatudp] continuity: ch0_breaks=0 ch1_breaks=0 ok=1 +[flatudp] wrong size: count=3 message='datagram of 22 bytes, expected 21, check the signal list' ok=1 +[flatudp] capture button: present=1 sensitive=1 +[flatudp] state: valid=3735 wrong=3 +[flatudp] silence: connected=0 model_connected=0 ok=1 +[flatudp] resumed: connected=1 frames_before=4990 frames_after=7147 ok=1 +``` + +`UDPSCOPE_PROBE_FLATUDP=2500` (the first lines; counts vary) and `UDPSCOPE_PROBE_FLATUDP_GAP=8000` (the last two) +run as `--source flatudp` against `tools/mock_flatudp.py` with the options in `verification.md`. The second line is +the source's own stderr message, printed once per distinct wrong size, not a probe line. The `continuity` line +above is the scalar-list run (`Trigger,Ia,Cnt`, big endian); the mixed-list run with `Wave:int16[4]` reads +`continuity: skipped (...)`. With no sender the stream lines read `connected=0 ... frames=0 ... ok=0`, `continuity` +is skipped and the capture button reads `sensitive=0`: a reading, not a failure. A run without `--bad-every` reads +`wrong size: count=0 ... ok=0` for the same reason. + +- `codec: ok=1`: the `signals` text round-trips, a hand-built 24-byte datagram decodes to the hand-computed + values in both byte orders (`uint8` 254, `int16` -2, `float32` 1.5, `uint64[2]` 1 and 4294967298, `int8` -1) + and the validity rules refuse what they should. It needs no source, so `--source probe` shows it too. + `ok=0` is a codec defect: a swapped byte order, a wrong width or a rule that does not fire. +- `stream: ... ok=1`: the model's signals are the list's rows in order, at the declared rate. `names` out of + order is a decode that mixed rows; `rate` of 1000 against a different declared rate is a `sampling_rate` + that did not reach the model (the ring and the decimation are then sized for the wrong cadence). +- `channels: count=4`: one channel per signal. A vector signal is **not** one channel per element: the model + reads its `count` values as consecutive samples of one channel, as for a UDPStreamer block signal. +- `continuity: ch0_breaks=0 ch1_breaks=0`: consecutive samples of the mock's first two signals step by 1 and + by 2 modulo 100. A break is a lost or reordered datagram, a batch flushed with the wrong number of samples, + a wrong byte order or ingest decimation that is not 1:1. It is skipped for a list with a vector row: the + model spans each frame by its largest element block, so the scalar channels of a mixed list are padded + with zeros (`Frame::value` reads 0 out of range) and a continuity check on them would read nearly every + step as a break. +- `wrong size: ok=1`: wrong-size datagrams were counted and the message names the received and expected size. + The message must also be the only stderr line of its kind: one per distinct size, however many arrive. +- `capture button: present=1 sensitive=1`: the kind reports datagrams, so the capture UI is live (contrast + `[source-kinds]`, where the probe kind leaves it disabled). With no stream it reads `sensitive=0`. +- `silence: connected=0 model_connected=0`: 2 s without a valid datagram raised `Disconnected`. `connected=1` + is the timeout not firing, wrong-size datagrams refreshing the silence clock (they must not), or the mock's + gap not covering the probe (see the timing note in `verification.md`). +- `resumed: ok=1`: `Connected` was raised again and frames resumed. `connected=0` here means the source never + recovered after the sender came back. + ## `[prefs-probe]` ``` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 43e11fb..57cf967 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -423,7 +423,7 @@ precedent, not an exception. - `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. - Eight `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `probe`); the + Nine `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `flatudp`, `probe`); the reading's own entry is in `probe-readings.md`. - `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against `tools/mock_e1608.py` -- the MCC E-1608 end to end: panels present and sensitive, a DAC write, a @@ -441,6 +441,24 @@ precedent, not an exception. - **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop. - `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms later checks the frame count moved on. The reading's own entry is in `probe-readings.md`. +- `UDPSCOPE_PROBE_FLATUDP=2500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source flatudp` against + `tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check (it + needs no source), the stream's signal names and declared rate, the channel count (one per signal), value + continuity across the first two signals (all-scalar lists only), the wrong-size accounting and the capture + button. It enables the signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS`. Runs, each short and + serial, with the mock killed afterwards (quote the `signals` value: zsh globs the `[4]`): + - **mixed list, gap and bad datagrams:** mock `--rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] + --gap 9,5 --bad-every 1 --log /tmp/flatudp-mock.log` started 1 s before the app, the same `signals` as a + `--source-opt`, with `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_PROBE_FLATUDP_GAP=8000 UDPSCOPE_EXIT_AFTER_MS=14500`; + stderr carries exactly one `datagram of 22 bytes, expected 21` line. The mock's clock starts about 4 s + before the app's probe clock (`xvfb-run` and the app's own startup), so a gap of 9 s to 14 s on the mock's + clock covers the app's 8 s probe (silence) and ends before its 12 s one (resumed); a mock gap measured in + the app's own seconds would miss both; + - **scalar list, big endian (batching, continuity):** mock `--signals Trigger:uint8,Ia:float32,Cnt:uint64 --big-endian`, + app `--source-opt endian=big`, `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`; + - **no sender:** `connected=0` throughout, a reading and not a failure. +- `UDPSCOPE_PROBE_FLATUDP_GAP=8000` -- reads the source inside the mock's silence (2 s after its last datagram + the source is disconnected) and 4 s later checks it reconnected and the stream moved on. - `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`. diff --git a/src/source/flatudp/flatudp_kind.cpp b/src/source/flatudp/flatudp_kind.cpp new file mode 100644 index 0000000..bfa6b3e --- /dev/null +++ b/src/source/flatudp/flatudp_kind.cpp @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/flatudp/flatudp_source.hpp" +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { +namespace { + +using namespace flatudp; +using ChangeCb = std::shared_ptr>; + +std::string num(double v) { + char b[48]; + std::snprintf(b, sizeof b, "%.10g", v); + return b; +} + +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; +} + +GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) { + GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast(digits)); + adw_spin_row_set_value(ADW_SPIN_ROW(r), def); + return r; +} + +GtkWidget* combo_row(const char* title, const char* const* items) { + GtkWidget* r = adw_combo_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items))); + return r; +} + +GtkWidget* expander(const char* title, std::initializer_list rows) { + GtkWidget* e = adw_expander_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title); + for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r); + return e; +} + +std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); } +double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); } +guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); } + +void mark(GtkWidget* w, bool bad) { + if (bad) { + gtk_widget_add_css_class(w, "error"); + } else { + gtk_widget_remove_css_class(w, "error"); + } +} + +/// The form's widgets, shared by every signal closure and every `ConfigForm` closure. +struct Form { + GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *signals; + ChangeCb changed = std::make_shared>(); + bool updating = false; + bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing + + /// A bad address is marked with the `error` style; the rest of the problem is `valid()`'s. + void refresh() { + mark(bind, !text_of(bind).empty() && !valid_ipv4(text_of(bind))); + mark(multicast, !text_of(multicast).empty() && !valid_multicast(text_of(multicast))); + mark(iface, !text_of(iface).empty() && !valid_ipv4(text_of(iface))); + } + + SourceConfig read() const { + SourceConfig c; + c.set("port", num(value_of(port))); + if (!text_of(bind).empty()) c.set("bind", text_of(bind)); + if (!text_of(multicast).empty()) c.set("multicast", text_of(multicast)); + if (!text_of(iface).empty()) c.set("iface", text_of(iface)); + c.set("rate", num(value_of(rate))); + c.set("endian", sel_of(endian) == 1 ? "big" : "little"); + c.set("signals", text_of(signals)); + return c; + } + + void write(const SourceConfig& cfg) { + const FlatUdpSettings s = FlatUdpSettings::from(cfg); + updating = true; + adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port); + gtk_editable_set_text(GTK_EDITABLE(bind), s.bind.c_str()); + gtk_editable_set_text(GTK_EDITABLE(multicast), s.multicast.c_str()); + gtk_editable_set_text(GTK_EDITABLE(iface), s.iface.c_str()); + if (s.rate > 0.0) adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.rate); + adw_combo_row_set_selected(ADW_COMBO_ROW(endian), s.big_endian ? 1 : 0); + gtk_editable_set_text(GTK_EDITABLE(signals), cfg.get("signals").c_str()); + updating = false; + refresh(); + } + + bool valid() const { return FlatUdpSettings::from(read()).problem().empty(); } +}; + +using FormPtr = std::shared_ptr
; + +void on_notify(GObject*, GParamSpec*, gpointer user) { + Form& f = **static_cast(user); + if (!f.alive) return; + f.refresh(); + if (!f.updating && f.changed && *f.changed) (*f.changed)(); +} + +void on_edited(GtkEditable*, gpointer user) { + Form& f = **static_cast(user); + if (!f.alive) return; + f.refresh(); + if (!f.updating && f.changed && *f.changed) (*f.changed)(); +} + +void free_form(gpointer d, GClosure*) { delete static_cast(d); } + +/// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone. +void on_form_destroy(GtkWidget*, gpointer user) { (**static_cast(user)).alive = false; } + +void hook_destroy(GtkWidget* w, const FormPtr& f) { + g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form, + static_cast(0)); +} + +void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) { + g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form, + static_cast(0)); +} + +void hook_entry(GtkWidget* w, const FormPtr& f) { + g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form, + static_cast(0)); +} + +class FlatUdpKind final : public SourceKind { + public: + std::string id() const override { return "flatudp"; } + std::string display_name() const override { return "Flat UDP (MARTe2 UDPSender)"; } + bool has_datagrams() const override { return true; } + + std::string identity(const SourceConfig& cfg) const override { + return FlatUdpSettings::from(cfg).identity(); + } + + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + auto f = std::make_shared(); + static const char* const kEndian[] = {"Little endian", "Big endian", nullptr}; + + f->port = spin_row("_Port", 1, 65535, 1, kDefaultPort, 0); + f->bind = entry_row("_Bind address"); + f->multicast = entry_row("_Multicast group"); + f->iface = entry_row("Multicast _interface"); + f->rate = spin_row("Cycle _rate (Hz)", 0.001, 1000000, 1, 1000, 3); + f->endian = combo_row("_Byte order", kEndian); + f->signals = entry_row("_Signals"); + + GtkWidget* net = expander("Network", {f->port, f->bind, f->multicast, f->iface}); + GtkWidget* timing = expander("Timing and format", {f->rate, f->endian}); + GtkWidget* sig = expander("Signals", {f->signals}); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(net), TRUE); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(sig), TRUE); + + for (GtkWidget* w : {net, timing, sig}) hook_destroy(w, f); + for (GtkWidget* w : {f->bind, f->multicast, f->iface, f->signals}) hook_entry(w, f); + for (GtkWidget* w : {f->port, f->rate}) hook_notify(w, "notify::value", f); + hook_notify(f->endian, "notify::selected", f); + f->refresh(); + + ConfigForm out; + out.rows = {net, timing, sig}; + out.description = + "The signal list must match the sender's exactly, in order: the datagrams carry no " + "description. Write name:type[count], comma separated, e.g. Trigger:uint8,Ia:float32," + "Wave:float32[16]"; + out.focus = f->signals; + out.write = [f](const SourceConfig& c) { f->write(c); }; + out.read = [f]() { return f->read(); }; + out.valid = [f]() { return f->valid(); }; + ChangeCb cb = f->changed; + out.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return out; + } +}; + +} // namespace + +std::unique_ptr make_flatudp_kind() { return std::make_unique(); } + +} // namespace udpscope diff --git a/src/source/flatudp/flatudp_source.cpp b/src/source/flatudp/flatudp_source.cpp new file mode 100644 index 0000000..93b8f58 --- /dev/null +++ b/src/source/flatudp/flatudp_source.cpp @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/flatudp/flatudp_source.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope::flatudp { +namespace { + +using namespace std::chrono_literals; + +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +std::string trim(const std::string& s) { + std::size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +double epoch_seconds() { + return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +constexpr std::size_t kMaxReportedSizes = 8; ///< a sender of random sizes must not flood stderr + +} // namespace + +bool valid_ipv4(const std::string& s) { + in_addr a{}; + return inet_pton(AF_INET, s.c_str(), &a) == 1; +} + +bool valid_multicast(const std::string& s) { + in_addr a{}; + if (inet_pton(AF_INET, s.c_str(), &a) != 1) return false; + return (ntohl(a.s_addr) >> 28) == 0xE; +} + +// --- FlatUdpSettings ------------------------------------------------------------------------ + +FlatUdpSettings FlatUdpSettings::from(const SourceConfig& c) { + FlatUdpSettings s; + s.port = static_cast(c.get_int("port", kDefaultPort)); + s.bind = trim(c.get("bind")); + s.multicast = trim(c.get("multicast")); + s.iface = trim(c.get("iface")); + s.rate = c.get_double("rate", 0.0); + const std::string e = lower(trim(c.get("endian", "little"))); + s.endian_ok = e == "little" || e == "big"; + s.big_endian = e == "big"; + s.signals_error = parse(c.get("signals"), s.rows); + return s; +} + +std::string FlatUdpSettings::problem() const { + if (port < 1 || port > 65535) return "the port must be 1 to 65535"; + if (!signals_error.empty()) return signals_error; + const std::string p = flatudp::problem(rows); + if (!p.empty()) return p; + if (!(rate > 0.0) || !std::isfinite(rate)) return "give the sender's cycle rate in Hz (rate)"; + if (!endian_ok) return "endian must be little or big"; + if (!bind.empty() && !valid_ipv4(bind)) return "the bind address '" + bind + "' is not an IPv4 address"; + if (!multicast.empty() && !valid_multicast(multicast)) + return "'" + multicast + "' is not a multicast group (224.0.0.0 to 239.255.255.255)"; + if (!iface.empty() && !valid_ipv4(iface)) + return "the interface address '" + iface + "' is not an IPv4 address"; + return {}; +} + +std::string FlatUdpSettings::identity() const { + return (multicast.empty() ? std::string() : multicast + ":") + std::to_string(port); +} + +std::size_t FlatUdpSettings::block() const { + if (!all_scalar(rows) || !(rate > 0.0) || !std::isfinite(rate)) return 1; + const long b = std::lround(rate / 100.0); + return static_cast(std::min(1024L, std::max(1L, b))); +} + +// --- FlatUdpSource -------------------------------------------------------------------------- + +FlatUdpSource::FlatUdpSource(const SourceConfig& cfg) : cfg_(FlatUdpSettings::from(cfg)) {} + +FlatUdpSource::~FlatUdpSource() { close_fd(); } + +std::string FlatUdpSource::last_message() const { + std::lock_guard lk(msg_mu_); + return last_message_; +} + +void FlatUdpSource::note(const std::string& msg) { + { + std::lock_guard lk(msg_mu_); + last_message_ = msg; + } + std::fprintf(stderr, "[flatudp] %s\n", msg.c_str()); + std::fflush(stderr); +} + +void FlatUdpSource::close_fd() { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } +} + +void FlatUdpSource::open(SourceSink& sink) { + const std::string p = cfg_.problem(); + if (!p.empty()) throw std::runtime_error(p); + sink_ = &sink; + + signals_.clear(); + for (const Row& r : cfg_.rows) { + SignalInfo s; + s.name = r.name; + s.type_code = static_cast(r.type); + s.rows = r.count; + s.cols = 1; + s.time_mode = kTimePacket; + s.sampling_rate = cfg_.rate; + signals_.push_back(s); + } + expected_ = datagram_size(cfg_.rows); + block_ = cfg_.block(); + pending_.assign(cfg_.rows.size(), {}); + pending_n_ = 0; + rx_.assign(65536, 0); + + const auto fail = [this](const std::string& what) { + const std::string msg = what + ": " + std::strerror(errno); + close_fd(); + throw std::runtime_error(msg); + }; + + fd_ = ::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (fd_ < 0) fail("cannot create a UDP socket"); + // Receivers of one multicast group share its port; a unicast port is the one app's. + if (!cfg_.multicast.empty()) { + const int one = 1; + ::setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + } + const int rcvbuf = 4 << 20; // best effort: a 1 kHz sender at 64 KiB would not fit a default one + ::setsockopt(fd_, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(cfg_.port)); + addr.sin_addr.s_addr = htonl(INADDR_ANY); + if (!cfg_.bind.empty()) inet_pton(AF_INET, cfg_.bind.c_str(), &addr.sin_addr); + if (::bind(fd_, reinterpret_cast(&addr), sizeof addr) < 0) + fail("cannot bind UDP port " + std::to_string(cfg_.port)); + + if (!cfg_.multicast.empty()) { + ip_mreq mreq{}; + inet_pton(AF_INET, cfg_.multicast.c_str(), &mreq.imr_multiaddr); + if (cfg_.iface.empty()) { + mreq.imr_interface.s_addr = htonl(INADDR_ANY); + } else { + inet_pton(AF_INET, cfg_.iface.c_str(), &mreq.imr_interface); + } + if (::setsockopt(fd_, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof mreq) < 0) + fail("cannot join multicast group " + cfg_.multicast); + } +} + +void FlatUdpSource::close() { + close_fd(); + connected_.store(false, std::memory_order_relaxed); +} + +SourceStats FlatUdpSource::stats() const { + SourceStats s; + s.packets = valid_.load(std::memory_order_relaxed) + wrong_.load(std::memory_order_relaxed); + s.frames = frames_; + s.stale = wrong_.load(std::memory_order_relaxed); + s.reconnects = reconnects_; + s.connected = connected_.load(std::memory_order_relaxed); + return s; +} + +void FlatUdpSource::poll(int timeout_ms) { + drain_commands(); + // Never wait long: the partial-block flush and the silence timeout are checked on this cadence. + const int wait = std::clamp(timeout_ms, 0, 20); + if (fd_ < 0 || sink_ == nullptr) { + std::this_thread::sleep_for(std::chrono::milliseconds(wait)); + return; + } + if (!configured_) { + configured_ = true; + sink_->on_config(signals_, 1); + } + pollfd p{}; + p.fd = fd_; + p.events = POLLIN; + if (::poll(&p, 1, wait) > 0 && (p.revents & POLLIN)) drain_socket(); + + const auto now = Clock::now(); + if (pending_n_ > 0 && now - batch_start_ >= 100ms) flush(); // a stopped sender holds nothing back + if (connected_.load(std::memory_order_relaxed) && now - last_valid_ >= 2s) { + connected_.store(false, std::memory_order_relaxed); + sink_->on_event(SourceEvent::Disconnected, "no datagram for 2 s"); + } +} + +void FlatUdpSource::drain_socket() { + for (int i = 0; i < 8192; ++i) { // bounded, so a flood cannot starve the timers + const ssize_t n = ::recv(fd_, rx_.data(), rx_.size(), 0); + if (n < 0) { + if (errno == EINTR) continue; + break; // EAGAIN: drained + } + const double t = epoch_seconds(); + const std::size_t len = static_cast(n); + if (tap_) tap_(rx_.data(), len, t); + if (len == expected_) { + accept(rx_.data()); + } else { + reject(len); + } + } +} + +void FlatUdpSource::accept(const std::uint8_t* data) { + valid_.fetch_add(1, std::memory_order_relaxed); + last_valid_ = Clock::now(); + if (!connected_.load(std::memory_order_relaxed)) { + if (ever_connected_) ++reconnects_; + ever_connected_ = true; + connected_.store(true, std::memory_order_relaxed); + sink_->on_event(SourceEvent::Connected, ""); + } + decode_datagram(cfg_.rows, data, cfg_.big_endian, pending_); + if (pending_n_ == 0) batch_start_ = last_valid_; + if (++pending_n_ >= block_) flush(); +} + +void FlatUdpSource::reject(std::size_t len) { + wrong_.fetch_add(1, std::memory_order_relaxed); + if (reported_sizes_.size() >= kMaxReportedSizes || !reported_sizes_.insert(len).second) return; + const std::string msg = "datagram of " + std::to_string(len) + " bytes, expected " + + std::to_string(expected_) + ", check the signal list"; + note(msg); + sink_->on_event(SourceEvent::Error, msg.c_str()); +} + +void FlatUdpSource::flush() { + const std::size_t n = signals_.size(); + views_.resize(n); + for (std::size_t i = 0; i < n; ++i) + views_[i] = SignalValues{pending_[i].data(), static_cast(pending_[i].size())}; + Frame f; + f.counter = frame_counter_++; + f.lost = 0; + f.late = false; + f.recv_time = epoch_seconds(); + f.publish_mode = 1; + f.num_samples = static_cast(pending_n_); + f.num_signals = static_cast(n); + f.signals = signals_.data(); + f.values = views_.data(); + ++frames_; + sink_->on_data(f); + for (auto& c : pending_) c.clear(); + pending_n_ = 0; +} + +} // namespace udpscope::flatudp diff --git a/src/source/flatudp/flatudp_source.hpp b/src/source/flatudp/flatudp_source.hpp new file mode 100644 index 0000000..412565f --- /dev/null +++ b/src/source/flatudp/flatudp_source.hpp @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/flatudp/flatudp_signals.hpp" +#include "source/source_config.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope::flatudp { + +constexpr int kDefaultPort = 44488; ///< `UDPSender`'s own default + +bool valid_ipv4(const std::string& s); +/// An IPv4 address in 224.0.0.0/4. +bool valid_multicast(const std::string& s); + +/// Everything a configured flat UDP source needs, parsed once from the flat `SourceConfig`. +struct FlatUdpSettings { + int port = kDefaultPort; + std::string bind; ///< local address; empty is any + std::string multicast; ///< group to join; empty is plain unicast + std::string iface; ///< local interface address for the join; empty is the system's choice + double rate = 0.0; ///< the sender's cycle rate in Hz; 0 means not given + bool big_endian = false; + bool endian_ok = true; ///< false when `endian` is neither "little" nor "big" + std::vector rows; + std::string signals_error; ///< why the `signals` text did not parse; `rows` is empty then + + static FlatUdpSettings from(const SourceConfig& cfg); + /// The first reason this cannot connect, in words for the user; empty when it can. + std::string problem() const; + /// `[multicast:]port`; names the session file. + std::string identity() const; + /// Datagrams per frame: 1 for a list with a vector, else `clamp(round(rate / 100), 1, 1024)`. + std::size_t block() const; +}; + +class FlatUdpSource final : public DataSource { + public: + explicit FlatUdpSource(const SourceConfig& cfg); + ~FlatUdpSource() override; + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override { return connected_.load(std::memory_order_relaxed); } + void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); } + + const FlatUdpSettings& settings() const { return cfg_; } + /// Datagrams of the expected size / of any other size; for the probe, readable from any thread. + std::uint64_t valid_datagrams() const { return valid_.load(std::memory_order_relaxed); } + std::uint64_t wrong_size_datagrams() const { return wrong_.load(std::memory_order_relaxed); } + std::string last_message() const; + + private: + using Clock = std::chrono::steady_clock; + + void close_fd(); + void drain_socket(); + void accept(const std::uint8_t* data); + void reject(std::size_t len); + void flush(); + void note(const std::string& msg); + + FlatUdpSettings cfg_; + SourceSink* sink_ = nullptr; + DatagramTap tap_; + int fd_ = -1; + bool configured_ = false; + bool ever_connected_ = false; + std::size_t expected_ = 0; ///< bytes of a valid datagram + std::size_t block_ = 1; + + std::vector signals_; + std::vector rx_; + std::vector> pending_; ///< decoded, not yet framed: [signal][value] + std::vector views_; + std::size_t pending_n_ = 0; ///< datagrams in `pending_` + Clock::time_point batch_start_; + Clock::time_point last_valid_; + std::set reported_sizes_; + + std::atomic connected_{false}; + std::atomic valid_{0}; + std::atomic wrong_{0}; + mutable std::mutex msg_mu_; + std::string last_message_; + + std::uint32_t frame_counter_ = 0; + std::uint64_t frames_ = 0; + std::uint64_t reconnects_ = 0; +}; + +} // namespace udpscope::flatudp diff --git a/src/source/registry.cpp b/src/source/registry.cpp index a14184a..d67308b 100644 --- a/src/source/registry.cpp +++ b/src/source/registry.cpp @@ -51,6 +51,7 @@ void register_builtin_sources() { done = true; register_kind(make_udps_kind()); register_kind(make_e1608_kind()); + register_kind(make_flatudp_kind()); if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind()); } diff --git a/src/source/registry.hpp b/src/source/registry.hpp index 11e6643..a601930 100644 --- a/src/source/registry.hpp +++ b/src/source/registry.hpp @@ -21,10 +21,11 @@ const std::vector& kinds(); /// 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`, `e1608/e1608_kind.cpp`). +/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`, `e1608/e1608_kind.cpp`, `flatudp/flatudp_kind.cpp`). std::unique_ptr make_udps_kind(); std::unique_ptr make_probe_kind(); std::unique_ptr make_e1608_kind(); +std::unique_ptr make_flatudp_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._-]` diff --git a/src/ui.cpp b/src/ui.cpp index e8321d7..a8f00ad 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -6121,6 +6121,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) { g_timeout_add(static_cast(std::atoi(env)), flatudp_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP_GAP")) { + g_timeout_add(static_cast(std::atoi(env)), flatudp_probe_gap, &app); + } // Our own options were consumed by parse_args(); GTK would reject them, so only // the program name is handed to g_application_run(). diff --git a/src/ui_app.hpp b/src/ui_app.hpp index e68bf74..c0229ed 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1884,8 +1884,13 @@ gboolean e1608_probe_report(gpointer user); gboolean e1608_probe_reconnect(gpointer user); gboolean e1608_probe_resumed(gpointer user); /// The flat UDP (MARTe2 UDPSender) source: the signal-list codec and a hand-computed decode, then, -/// against `tools/mock_flatudp.py`, the stream, the wrong-size accounting and the silence timeout. +/// against `tools/mock_flatudp.py`, the stream, the value continuity, the wrong-size accounting and +/// the capture button (`_report`, a second later). `_gap` reads the source inside the mock's silence; +/// `_resumed` shows the stream came back. gboolean flatudp_probe(gpointer user); +gboolean flatudp_probe_report(gpointer user); +gboolean flatudp_probe_gap(gpointer user); +gboolean flatudp_probe_resumed(gpointer user); gboolean pan_probe(gpointer user); gboolean arm_probe(gpointer user); gboolean trigrenum_probe_step3(gpointer user); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 4f147f7..b74acfd 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -9,6 +9,7 @@ #include "source/probe_source.hpp" #include "source/e1608/e1608_source.hpp" #include "source/flatudp/flatudp_signals.hpp" +#include "source/flatudp/flatudp_source.hpp" #include "source/registry.hpp" #include "ui_app.hpp" @@ -1938,6 +1939,14 @@ gboolean source_kinds_probe(gpointer user) { c.set("trigger", "rising"); c.set("dir1", "o"); c.set("plot0", "1"); + } else if (id == "flatudp") { + c.set("port", "44600"); + c.set("bind", "10.0.0.8"); + c.set("multicast", "239.1.2.3"); + c.set("iface", "10.0.0.9"); + c.set("rate", "2500"); + c.set("endian", "big"); + c.set("signals", "Trigger:uint8,Ia:float32,Wave:int16[4]"); } return c; }; @@ -2102,6 +2111,31 @@ bool flatudp_codec_check() { return true; } +flatudp::FlatUdpSource* flatudp_source(App* a) { + return a->acq != nullptr ? dynamic_cast(a->acq->source()) : nullptr; +} + +std::uint64_t g_flatudp_frames_before = 0; + +/// The mock's signal k carries (i * (k + 1) + element) % 100 in packet i, so two consecutive +/// samples of a scalar signal differ by k + 1 modulo 100. Returns the number of steps that do not, +/// over a window clear of the head the producer is writing; -1 when it cannot be read. +long flatudp_breaks(App* a, std::size_t channel, long step) { + if (channel >= a->model->channels().size()) return -1; + const ChannelBuffer& ch = a->model->live().channel(channel); + const u64 n = ch.size(); + const u64 want = 400; + if (n <= want + 50) return -1; + std::vector v(static_cast(want)); + ch.raw(n - want - 50, n - 50, v.data()); + long breaks = 0; + for (std::size_t i = 1; i < v.size(); ++i) { + const long d = std::lround(double(v[i])) - std::lround(double(v[i - 1])); + if (((d % 100) + 100) % 100 != step) ++breaks; + } + return breaks; +} + } // namespace /// Phase 2, 1 s after the controls were driven: the stream, the calibration, the capture button, @@ -2247,12 +2281,110 @@ gboolean e1608_probe_reconnect(gpointer user) { return G_SOURCE_REMOVE; } -/// The flat UDP source's probe. For now only the pure codec check; it needs no flatudp source. +/// After the mock's gap: the stream's frame count moved on and the source is connected again. +gboolean flatudp_probe_resumed(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + flatudp::FlatUdpSource* src = flatudp_source(a); + const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed); + const bool up = src != nullptr && src->connected() && + a->model->stats().connected.load(std::memory_order_relaxed); + std::fprintf(stderr, "[flatudp] resumed: connected=%d frames_before=%" G_GUINT64_FORMAT + " frames_after=%" G_GUINT64_FORMAT " ok=%d\n", + up ? 1 : 0, g_flatudp_frames_before, after, + up && after > g_flatudp_frames_before ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Inside the mock's gap: 2 s of silence must have raised `Disconnected`. +gboolean flatudp_probe_gap(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + flatudp::FlatUdpSource* src = flatudp_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + const bool src_up = src->connected(); + const bool model_up = a->model->stats().connected.load(std::memory_order_relaxed); + g_flatudp_frames_before = a->model->stats().frames.load(std::memory_order_relaxed); + std::fprintf(stderr, "[flatudp] silence: connected=%d model_connected=%d ok=%d\n", src_up ? 1 : 0, + model_up ? 1 : 0, !src_up && !model_up ? 1 : 0); + std::fflush(stderr); + g_timeout_add(4000, flatudp_probe_resumed, a); + return G_SOURCE_REMOVE; +} + +/// Phase 2, 1 s after the signals were enabled: the stream, its channels, the value continuity, the +/// wrong-size accounting and the capture button. +gboolean flatudp_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + flatudp::FlatUdpSource* src = flatudp_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + const flatudp::FlatUdpSettings& s = src->settings(); + const bool up = a->model->stats().connected.load(std::memory_order_relaxed); + + const std::vector& sigs = a->model->signals(); + std::string names, expect_names; + std::size_t expect_channels = 0; // one channel per signal: a vector's elements are consecutive samples + for (const SignalInfo& si : sigs) names += (names.empty() ? "" : ",") + si.name; + for (const flatudp::Row& r : s.rows) { + expect_names += (expect_names.empty() ? "" : ",") + r.name; + ++expect_channels; + } + const double rate = sigs.empty() ? 0.0 : sigs.front().sampling_rate; + const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed); + const bool stream_ok = up && names == expect_names && std::fabs(rate - s.rate) < 1e-6 && frames > 0; + std::fprintf(stderr, + "[flatudp] stream: connected=%d signals=%zu names=%s expect=%s frames=%" G_GUINT64_FORMAT + " rate=%.3f ok=%d\n", + up ? 1 : 0, sigs.size(), names.c_str(), expect_names.c_str(), frames, rate, + stream_ok ? 1 : 0); + + const std::size_t channels = a->model->channels().size(); + std::fprintf(stderr, "[flatudp] channels: count=%zu expect=%zu ok=%d\n", channels, expect_channels, + channels == expect_channels ? 1 : 0); + + // Only the first two signals are checked, and only in an all-scalar list: a vector row makes the + // model span each frame by its elements, and the scalar channels are then padded with zeros. + if (up && s.rows.size() >= 2 && flatudp::all_scalar(s.rows)) { + const long b0 = flatudp_breaks(a, 0, 1); + const long b1 = flatudp_breaks(a, 1, 2); + std::fprintf(stderr, "[flatudp] continuity: ch0_breaks=%ld ch1_breaks=%ld ok=%d\n", b0, b1, + b0 == 0 && b1 == 0 ? 1 : 0); + } else { + std::fprintf(stderr, "[flatudp] continuity: skipped (needs a connected all-scalar list of two or more)\n"); + } + + const std::uint64_t wrong = src->wrong_size_datagrams(); + const std::string msg = src->last_message(); + std::fprintf(stderr, "[flatudp] wrong size: count=%" G_GUINT64_FORMAT " message='%s' ok=%d\n", wrong, + msg.c_str(), wrong >= 1 && msg.find("expected") != std::string::npos ? 1 : 0); + + std::fprintf(stderr, "[flatudp] 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); + std::fprintf(stderr, "[flatudp] state: valid=%" G_GUINT64_FORMAT " wrong=%" G_GUINT64_FORMAT "\n", + src->valid_datagrams(), wrong); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Phase 1: the codec check (it needs no flatudp source), then every signal enabled in one structural +/// change, and the report a second later. gboolean flatudp_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::fprintf(stderr, "[flatudp] codec: ok=%d\n", flatudp_codec_check() ? 1 : 0); + if (flatudp_source(a) == nullptr) { + std::fprintf(stderr, "[flatudp] no flatudp source (run with --source flatudp)\n"); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + std::vector all(a->model->signals().size()); + for (std::size_t i = 0; i < all.size(); ++i) all[i] = i; + if (!all.empty()) a->model->set_signals_enabled(all, true); std::fflush(stderr); + g_timeout_add(1000, flatudp_probe_report, a); return G_SOURCE_REMOVE; } diff --git a/tools/mock_flatudp.py b/tools/mock_flatudp.py new file mode 100755 index 0000000..332d78b --- /dev/null +++ b/tools/mock_flatudp.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Martino Ferrari +"""A stand-in for the MARTe2 UDPSender datasource, for the flatudp source's self-check. + +One datagram is one cycle: the signals concatenated in declaration order, no padding, in the +chosen byte order. Packet i carries, for the signal of index k (from 0) and its element e, +the value (i * (k + 1) + e) % 100, so consecutive datagrams are predictable. + + --signals name:type[count],... default Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] + --rate cycles per second + --big-endian pack big endian (default little) + --gap S,D send nothing from S to S+D seconds after the start (a sender that stops) + --bad-every S one datagram one byte too long every S seconds (the wrong-size path) + --multicast G --mcast-if A send to group G out of interface A instead of --host + --log FILE one line per event +""" +import argparse +import socket +import struct +import sys +import time + +CODES = {"uint8": "B", "int8": "b", "uint16": "H", "int16": "h", "uint32": "I", "int32": "i", + "uint64": "Q", "int64": "q", "float32": "f", "float64": "d"} + + +def parse_signals(text): + out = [] + for tok in text.split(","): + name, _, ty = tok.strip().partition(":") + count = 1 + if "[" in ty: + ty, _, n = ty.partition("[") + count = int(n.rstrip("]")) + out.append((name.strip(), CODES[ty.strip()], count)) + return out + + +def make_packer(sigs, big): + st = struct.Struct((">" if big else "<") + "".join(code * count for _, code, count in sigs)) + + def pack(i): + vals = [] + for k, (_, code, count) in enumerate(sigs): + for e in range(count): + v = (i * (k + 1) + e) % 100 + vals.append(float(v) if code in "fd" else v) + return st.pack(*vals) + + return pack, st.size + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter) + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=44488) + ap.add_argument("--rate", type=float, default=1000.0) + ap.add_argument("--signals", default="Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4]") + ap.add_argument("--big-endian", action="store_true") + ap.add_argument("--gap", default="") + ap.add_argument("--bad-every", type=float, default=0.0) + ap.add_argument("--multicast", default="") + ap.add_argument("--mcast-if", default="127.0.0.1") + ap.add_argument("--log", default="") + args = ap.parse_args() + + logf = open(args.log, "w") if args.log else None + + def log(msg): + if logf: + logf.write("%.3f %s\n" % (time.monotonic() - t0, msg)) + logf.flush() + + sigs = parse_signals(args.signals) + pack, size = make_packer(sigs, args.big_endian) + gap = tuple(float(x) for x in args.gap.split(",")) if args.gap else None + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + dest = (args.host, args.port) + if args.multicast: + dest = (args.multicast, args.port) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(args.mcast_if)) + + period = 1.0 / args.rate + t0 = time.monotonic() + log("start %s rate=%g size=%d %s" % (args.signals, args.rate, size, "big" if args.big_endian else "little")) + next_t = t0 + next_bad = t0 + args.bad_every + in_gap = False + i = 0 + try: + while True: + now = time.monotonic() + if now < next_t: + time.sleep(min(next_t - now, 0.001)) + continue + elapsed = now - t0 + if gap and gap[0] <= elapsed < gap[0] + gap[1]: + if not in_gap: + in_gap = True + log("gap begins") + next_t = now + period + time.sleep(0.01) + continue + if in_gap: + in_gap = False + log("gap ends") + if args.bad_every > 0 and now >= next_bad: + next_bad = now + args.bad_every + sock.sendto(pack(i) + b"\x00", dest) + log("bad datagram of %d bytes" % (size + 1)) + burst = 0 + while next_t <= now and burst < 64: # catch up a late timer, but bounded + sock.sendto(pack(i), dest) + i += 1 + next_t += period + burst += 1 + if next_t < now - 0.5: + next_t = now + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main())