From d955baf6124e3fee73d4d72c3dd2bb7ca168b461 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 01:23:51 +0100 Subject: [PATCH] flatudp: the signal-row editor form with a per-row Vector as drop-down, its probe line, README and architecture notes Multicast was verified on a loopback group (239.255.42.99 on 127.0.0.1); a real network was not exercised. Co-Authored-By: Claude Opus 4.6 --- README.md | 32 ++++- docs/claude/architecture.md | 27 ++++ docs/claude/probe-readings.md | 17 +++ docs/claude/verification.md | 4 +- src/source/flatudp/flatudp_kind.cpp | 208 +++++++++++++++++++++++++--- src/ui_probe.cpp | 117 ++++++++++++++++ 6 files changed, 385 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5cc5359..48fa69d 100644 --- a/README.md +++ b/README.md @@ -145,11 +145,41 @@ Errors (no device, a rejected connection code, a timeout, the scan socket closin `[e1608] ...` once per distinct message (an overrun is logged each time it happens); the window shows only that the source is disconnected (the status popover reads `waiting`, and the panels go insensitive), not why. +The third built-in kind is **Flat UDP**, a receiver for the MARTe2 `UDPSender` datasource: one datagram per +cycle, the signals concatenated in declaration order with no header, no padding and no timestamp, in the +sender's byte order. The datagram describes nothing, so **the signal list in the connection form is the +whole contract and is your responsibility**: one row per signal (a name, a type from `uint8` to `float64`, +and an element count), in the sender's order, including the `Trigger` byte an IndependentThread `UDPSender` +puts first. A row with a count above 1 is a vector, and a "Vector as" drop-down on that row says how to +show it: as **Channels** (the default), where `Wave:float32[16]` becomes 16 scalar channels `Wave[0]` .. +`Wave[15]` of one sample per cycle each, or as a **Waveform**, one channel that receives a block of 16 +consecutive samples per cycle (its declared rate is the cycle rate times the count). The waveform is written +`Wave:float32[16]w` in the `signals` text, and the same text is what a session file and `--source-opt +signals=` carry. A list with a waveform row may hold **only waveform rows, all of the same count** (a +scalar or a channels row beside one, or two lengths, would need zero padding or held values, so the form +refuses it and says so), and a list may make **at most 256 channels** (a scalar is one, a channels vector is +`count`, a waveform is one). The form's subtitle shows the channel total and the size the list implies +("6 channels, 13 bytes per datagram"), or the first rule the list breaks; a datagram of any other size is +dropped, counted as stale, and reported once per distinct size on stderr as `[flatudp] ...`. The form also +takes the port (44488, the sender's own default), an optional bind address, an optional multicast group with +the interface to join it on, the byte order, and the sender's **cycle rate in Hz, which is required**: it +sizes the ring and the ingest decimation and spaces the samples within a frame. Time is stamped by the +existing pipeline from arrival and that declared rate (and `--relative-time` gives a counted clock); there is +no timestamp in the stream to prefer. **Loss is never reported** -- with no counter in the datagram, a missed +cycle is indistinguishable from a slow one -- and neither is a changed sender: a list that does not match +reads as "everything dropped". Source-address filtering, detecting the list from the traffic and importing a +MARTe2 `.cfg` are not provided. A list with no waveform row is delivered in frames of about 10 ms +(`round(rate / 100)` cycles, at most 1024; a partial frame is flushed after 100 ms); a list of waveforms +delivers one frame per datagram. The source is connected on the first valid datagram and disconnected after +2 s without one. It is verified against `tools/mock_flatudp.py`, not against a MARTe2 application. Multicast +was verified on a loopback group (`239.255.42.99` joined on `127.0.0.1`, although this machine's `lo` does not +carry the `MULTICAST` flag); a real network, its switches and IGMP were not exercised. + A session file written before kinds existed has no `source_kind`, and is read as UDPS; a UDPS file is written exactly as before. A kind's capability panels (for instance a source's own controls) appear as pages of the side stack, and are insensitive rather than hidden while no source is connected. -Planned, each as its own sub-project: a flat UDP kind and ABScope. ABScope has more than one +Planned, as its own sub-project: ABScope. It has more than one timebase, so it will change the frame and the data path together rather than fit the present single-timebase frame. diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 297f01b..a093c44 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -61,6 +61,33 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. only through `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`, so the source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per retry); that line is where a user sees why a connection failed. +- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec, + the validity rules and the decoders, with no GTK and no sockets (the probe round-trips it); + `flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form, a signal-row + editor (a name, a type, a count and, while the count is above 1, a "Vector as" drop-down per row). The + datagram has no description, so a list that is wrong is invisible except as dropped datagrams: the size + check is the only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per + size. +- A vector row is shown one of two ways, chosen per row (`Row::wave`, the `w` suffix in the text). As + **channels** it is `count` scalar channels of one sample per cycle, so the list batches like an + all-scalar one. As a **waveform** it is one channel with `rows = count` and + `sampling_rate = rate * count`, one datagram per `Frame` (`num_samples = 1`). The two cannot share a + list because the model spans a frame by its largest element block and `Frame::value` is 0 past a scalar's + one value: a scalar beside a vector block would be zero-padded, so `problem()` demands that a list with a + waveform row be waveform rows of one count, and the form shows that message in the subtitle. The channel + total is capped at 256. +- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the + sample count varies): `round(rate / 100)` datagrams for a list with no waveform row, a partial block + flushed after 100 ms. `rate` is required because `sampling_rate = 0` makes the model size the ring and + the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping + path that ignores the declared cadence. +- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets + `Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the + history survives (`CONFIG` is sent once, from the first `poll()`). +- The form's row editor removes a row from inside its own button's `clicked` emission (GTK keeps the emitter + alive to the end of the emission, and the handler copies the form and the row out of its context first); + every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have + destroyed the sibling widgets the handlers reach by raw pointer. ### Data path diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index e2266a1..915f018 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1912,6 +1912,7 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign ``` [flatudp] codec: ok=1 +[flatudp] form: initial_valid=0 good_valid=1 size_ok=1 dup_valid=0 dup_msg=1 oversize_valid=0 add=1 remove=1 wave_valid=1 wave_sub=1 wave_roundtrip=1 mixed_valid=0 mixed_msg=1 length_valid=0 length_msg=1 cap_valid=0 cap_msg=1 cap_wave_valid=1 vec_shown=1 vec_drive=1 vec_reset=1 ok=1 [flatudp] datagram of 22 bytes, expected 21, check the signal list [flatudp] stream: connected=1 signals=7 names=Trigger,Ia,Cnt,Wave[0],Wave[1],Wave[2],Wave[3] expect=Trigger,Ia,Cnt,Wave[0],Wave[1],Wave[2],Wave[3] frames=368 rate=1000.000 ok=1 [flatudp] channels: count=7 expect=7 ok=1 @@ -1945,6 +1946,22 @@ same reason. rules refuse what they should (a waveform of count 1, a waveform mixed with another row or of another length, 257 channels). 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. +- `form: ... ok=1`: driven through the widgets, not the settings. `initial_valid=0` -- an empty list is not + connectable (`1` is a form that lets a source with no signals through). `good_valid=1 size_ok=1` -- a + 13-byte list is valid and its subtitle says `13 bytes` and `6 channels` (`W:int16[4]` as channels). + `dup_valid=0 dup_msg=1` -- a duplicate name is refused *and the subtitle says why* ("used twice"). + `oversize_valid=0` -- two `float64[4096]` rows (65536 bytes) exceed a datagram. `add=1` -- two clicks on + "Add signal" append `S1` and `S2`; `remove=1` -- the first row's "Remove" button takes that row out. A `0` + on `add`/`remove` with a `Gtk-CRITICAL` is a row handled after it was removed. The vector fields: + `wave_valid=1 wave_sub=1 wave_roundtrip=1` -- two waveform rows of one length are valid, the subtitle says + `2 channels`, and `signals` reads back with its `w`; `mixed_valid=0 mixed_msg=1` -- a waveform beside a + scalar is refused and the subtitle names the waveform rule; `length_valid=0 length_msg=1` -- waveforms of + two lengths are refused ("same length"); `cap_valid=0 cap_msg=1 cap_wave_valid=1` -- 300 channels are over + the 256 cap and the subtitle says `256`, while two `[200]w` waveforms (2 channels) are fine; `vec_shown=1` + -- the "Vector as" drop-down (`flatudp-vector`) is shown on a count above 1 and hidden on a scalar row; + `vec_drive=1` -- setting it to Waveform writes `Row::wave` (one waveform beside a channels row, then beside + a scalar, are both still invalid, and the text gains its `w`); `vec_reset=1` -- a row whose count returns + to 1 hides its drop-down and returns to Channels. Like `codec`, it needs no flatudp source. - `stream: ... ok=1`: the model's signals are the list's channels in order, each at its declared rate: `rate` for a scalar or a `channels` element (named `Name[k]`), `rate * count` for a waveform. `names` out of order is a decode that mixed rows; a rate that is not the expected one is a `sampling_rate` that did not reach the model diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 3aafbea..153e2ce 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -442,8 +442,8 @@ precedent, not an exception. - `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; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the + `tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both + need no source; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the float64/uint16/int32/uint32/int64 types in both byte orders), the stream's channel names (`Wave[0]`.. for a `channels` vector) and declared rates (`rate * count` for a waveform), the channel count, value continuity per row (and the offset between a vector's elements, or a waveform's block phase), the diff --git a/src/source/flatudp/flatudp_kind.cpp b/src/source/flatudp/flatudp_kind.cpp index bfa6b3e..29a7dc6 100644 --- a/src/source/flatudp/flatudp_kind.cpp +++ b/src/source/flatudp/flatudp_kind.cpp @@ -6,10 +6,14 @@ #include +#include +#include +#include #include #include #include #include +#include namespace udpscope { namespace { @@ -23,6 +27,13 @@ std::string num(double v) { return b; } +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); +} + GtkWidget* entry_row(const char* title) { GtkWidget* r = adw_entry_row_new(); adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); @@ -66,18 +77,70 @@ void mark(GtkWidget* w, bool bad) { } } -/// The form's widgets, shared by every signal closure and every `ConfigForm` closure. -struct Form { - GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *signals; +/// One signal's editing widgets; `row` is the `AdwEntryRow` that holds the others. `vec` ("Vector as": +/// Channels or Waveform) is shown only while `count` is above 1. +struct SigRow { + GtkWidget *row, *type, *count, *vec; +}; + +/// The form's widgets, shared by every signal closure and every `ConfigForm` closure. The raw +/// widget pointers are valid while the chooser keeps the rows. +struct Form : std::enable_shared_from_this
{ + GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *group; + std::vector sigs; 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. + std::vector rows_now() const { + std::vector rows; + for (const SigRow& s : sigs) { + Row r; + r.name = trim(text_of(s.row)); + r.type = static_cast(std::min(gtk_drop_down_get_selected(GTK_DROP_DOWN(s.type)), 9)); + r.count = static_cast(gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(s.count))); + r.wave = r.count > 1 && gtk_drop_down_get_selected(GTK_DROP_DOWN(s.vec)) == 1; + rows.push_back(r); + } + return rows; + } + + /// The first `S` that no row uses. + std::string next_name() const { + const std::vector rows = rows_now(); + for (int n = 1;; ++n) { + const std::string name = "S" + std::to_string(n); + bool used = false; + for (const Row& r : rows) used = used || r.name == name; + if (!used) return name; + } + } + + /// Bad addresses are marked with the `error` style; the group's subtitle carries the signal list's + /// own state, the first rule it breaks or the size it implies. 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))); + // "Vector as" belongs to a vector row only; a row back at one element returns to Channels. + for (const SigRow& s : sigs) { + const bool vec = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(s.count)) > 1; + gtk_widget_set_visible(s.vec, vec); + if (!vec && gtk_drop_down_get_selected(GTK_DROP_DOWN(s.vec)) != 0) + gtk_drop_down_set_selected(GTK_DROP_DOWN(s.vec), 0); + } + const std::vector rows = rows_now(); + const std::string p = flatudp::problem(rows); + std::string sub = p; + if (p.empty()) { + sub = std::to_string(channel_count(rows)) + (channel_count(rows) == 1 ? " channel, " : " channels, ") + + std::to_string(datagram_size(rows)) + " bytes per datagram"; + } + adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(group), sub.c_str()); + } + + void fire() { + if (!updating && changed && *changed) (*changed)(); } SourceConfig read() const { @@ -88,10 +151,13 @@ struct Form { 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)); + c.set("signals", format(rows_now())); return c; } + void add_row(const Row& r); + void remove_row(GtkWidget* row); + void write(const SourceConfig& cfg) { const FlatUdpSettings s = FlatUdpSettings::from(cfg); updating = true; @@ -101,7 +167,12 @@ struct Form { 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()); + while (!sigs.empty()) { + GtkWidget* row = sigs.back().row; + sigs.pop_back(); + adw_expander_row_remove(ADW_EXPANDER_ROW(group), row); + } + for (const Row& r : s.rows) add_row(r); updating = false; refresh(); } @@ -111,21 +182,53 @@ struct Form { using FormPtr = std::shared_ptr; +/// A remove button's context: which form, which row. +struct RowCtx { + FormPtr form; + GtkWidget* row; +}; + 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)(); + f.fire(); } 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)(); + f.fire(); +} + +void on_spin(GtkSpinButton*, gpointer user) { + Form& f = **static_cast(user); + if (!f.alive) return; + f.refresh(); + f.fire(); +} + +void on_add(GtkButton*, gpointer user) { + FormPtr f = *static_cast(user); + if (!f->alive) return; + f->add_row(Row{f->next_name(), Type::F32, 1}); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE); + f->refresh(); + f->fire(); +} + +void on_remove(GtkButton*, gpointer user) { + // Copy first: removing the row finalizes this button, and with it this context. + RowCtx* c = static_cast(user); + FormPtr f = c->form; + GtkWidget* row = c->row; + if (!f->alive) return; + f->remove_row(row); } void free_form(gpointer d, GClosure*) { delete static_cast(d); } +void free_ctx(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; } @@ -145,6 +248,70 @@ void hook_entry(GtkWidget* w, const FormPtr& f) { static_cast(0)); } +void Form::add_row(const Row& r) { + FormPtr self = shared_from_this(); + GtkWidget* row = adw_entry_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), "Name"); + gtk_editable_set_text(GTK_EDITABLE(row), r.name.c_str()); + + const char* names[11]; + for (std::size_t i = 0; i < 10; ++i) names[i] = type_name(static_cast(i)); + names[10] = nullptr; + GtkWidget* type = gtk_drop_down_new_from_strings(names); + gtk_drop_down_set_selected(GTK_DROP_DOWN(type), static_cast(r.type)); + gtk_widget_set_valign(type, GTK_ALIGN_CENTER); + gtk_widget_set_name(type, "flatudp-type"); + + // The spin clamps to 1..4096, so a hand-edited session with a count outside it is repaired here. + GtkWidget* count = gtk_spin_button_new_with_range(1, kMaxCount, 1); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(count), r.count); + gtk_widget_set_valign(count, GTK_ALIGN_CENTER); + gtk_widget_set_tooltip_text(count, "Elements: 1 is a scalar signal, more make a vector signal"); + gtk_widget_set_name(count, "flatudp-count"); + + static const char* const kVec[] = {"Channels", "Waveform", nullptr}; + GtkWidget* vec = gtk_drop_down_new_from_strings(kVec); + gtk_drop_down_set_selected(GTK_DROP_DOWN(vec), r.wave && r.count > 1 ? 1 : 0); + gtk_widget_set_valign(vec, GTK_ALIGN_CENTER); + gtk_widget_set_visible(vec, r.count > 1); + gtk_widget_set_tooltip_text(vec, + "Vector as: Channels makes one channel per element, one sample per cycle; " + "Waveform makes one channel of all the elements per cycle"); + gtk_widget_set_name(vec, "flatudp-vector"); + + GtkWidget* rm = gtk_button_new_with_label("Remove"); + gtk_widget_set_valign(rm, GTK_ALIGN_CENTER); + gtk_widget_set_name(rm, "flatudp-remove"); + + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), type); + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), count); + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), vec); + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), rm); + + hook_entry(row, self); + hook_notify(type, "notify::selected", self); + hook_notify(vec, "notify::selected", self); + g_signal_connect_data(count, "value-changed", G_CALLBACK(on_spin), new FormPtr(self), free_form, + static_cast(0)); + g_signal_connect_data(rm, "clicked", G_CALLBACK(on_remove), new RowCtx{self, row}, free_ctx, + static_cast(0)); + + adw_expander_row_add_row(ADW_EXPANDER_ROW(group), row); + sigs.push_back(SigRow{row, type, count, vec}); +} + +void Form::remove_row(GtkWidget* row) { + for (auto it = sigs.begin(); it != sigs.end(); ++it) { + if (it->row == row) { + sigs.erase(it); + adw_expander_row_remove(ADW_EXPANDER_ROW(group), row); + break; + } + } + refresh(); + fire(); +} + class FlatUdpKind final : public SourceKind { public: std::string id() const override { return "flatudp"; } @@ -169,27 +336,34 @@ class FlatUdpKind final : public SourceKind { 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}); + f->group = adw_expander_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->group), "Signals"); adw_expander_row_set_expanded(ADW_EXPANDER_ROW(net), TRUE); - adw_expander_row_set_expanded(ADW_EXPANDER_ROW(sig), TRUE); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), 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); + GtkWidget* add = gtk_button_new_with_label("Add signal"); + gtk_widget_set_valign(add, GTK_ALIGN_CENTER); + gtk_widget_set_name(add, "flatudp-add"); + adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->group), add); + g_signal_connect_data(add, "clicked", G_CALLBACK(on_add), new FormPtr(f), free_form, + static_cast(0)); + + for (GtkWidget* w : {net, timing, f->group}) hook_destroy(w, f); + for (GtkWidget* w : {f->bind, f->multicast, f->iface}) 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.rows = {net, timing, f->group}; 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; + "description, and a datagram of any other size is dropped. A vector signal is either " + "one channel per element or, as a waveform, one channel of all its elements"; + out.focus = f->rate; out.write = [f](const SourceConfig& c) { f->write(c); }; out.read = [f]() { return f->read(); }; out.valid = [f]() { return f->valid(); }; diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 2c24f19..1f04995 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -2213,6 +2213,122 @@ long flatudp_breaks(App* a, std::size_t channel, long step) { return breaks; } +/// Every widget named `name` at or under `root`, in tree order (a signal row's widgets repeat). +void find_all_named(GtkWidget* root, const char* name, std::vector& out) { + if (root == nullptr) return; + if (std::strcmp(gtk_widget_get_name(root), name) == 0) out.push_back(root); + for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + find_all_named(c, name, out); + } +} + +/// The connect form's validity rules and its signal-row editor, driven through the widgets: the +/// empty form is not connectable; a good list is, and its subtitle names the channel total and the +/// implied size; a duplicate name and an over-size list are not; "Add signal" appends `S1`, `S2`; a +/// row's "Remove" button takes that row out. Then the vector rows: a waveform list is valid and +/// round-trips with its `w`; a waveform beside a scalar (or of another length) is not, and the +/// subtitle says so; the 256-channel cap counts a waveform as one; the "Vector as" drop-down is +/// shown for a count above 1 only, writes `Row::wave`, and returns to Channels when the count does. +void flatudp_form_probe() { + SourceKind* k = find_kind("flatudp"); + if (k == nullptr) { + std::fprintf(stderr, "[flatudp] form: no flatudp kind\n"); + return; + } + ConfigForm f = k->make_config_form(); + const bool initial = f.valid(); + const auto with = [&](const char* signals) { + SourceConfig c; + c.set("rate", "1000"); + c.set("signals", signals); + f.write(c); + return f.valid(); + }; + const auto subtitle = [&]() { + const char* t = adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(f.rows[2])); + return std::string(t != nullptr ? t : ""); + }; + const auto has = [](const std::string& text, const char* part) { + return text.find(part) != std::string::npos; + }; + const bool good = with("A:uint8,B:float32,W:int16[4]"); + const std::string good_sub = subtitle(); + const bool size_ok = has(good_sub, "13 bytes") && has(good_sub, "6 channels"); + const bool dup = with("A:uint8,A:float32"); + const bool dup_ok = has(subtitle(), "twice"); + const bool big = with("A:float64[4096],B:float64[4096]"); + with("A:uint8,B:float32"); + + GtkWidget* add = find_named(f.rows[2], "flatudp-add"); + if (add != nullptr) { + g_signal_emit_by_name(add, "clicked"); + g_signal_emit_by_name(add, "clicked"); + } + const bool added = f.read().get("signals") == "A:uint8,B:float32,S1:float32,S2:float32"; + GtkWidget* rm = find_named(f.rows[2], "flatudp-remove"); // the first row's + if (rm != nullptr) g_signal_emit_by_name(rm, "clicked"); + const bool removed = f.read().get("signals") == "B:float32,S1:float32,S2:float32"; + + // Waveform rows. + const bool wave_valid = with("W1:int16[4]w,W2:float32[4]w"); + const bool wave_sub = has(subtitle(), "2 channels"); + const bool wave_rt = f.read().get("signals") == "W1:int16[4]w,W2:float32[4]w"; + const bool mixed = with("A:uint8,W:int16[4]w"); + const bool mixed_msg = has(subtitle(), "waveform"); + const bool length = with("W1:int16[4]w,W2:int16[8]w"); + const bool length_msg = has(subtitle(), "same length"); + const bool cap = with("A:float32[200],B:float32[100]"); + const bool cap_msg = has(subtitle(), "256"); + const bool cap_wave = with("A:float32[200]w,B:float32[200]w"); + + // The "Vector as" drop-down, driven. + with("A:uint8,V1:int16[4],V2:int16[4]"); + std::vector vec; + find_all_named(f.rows[2], "flatudp-vector", vec); + const bool vec_found = vec.size() == 3; + const bool vec_shown = vec_found && !gtk_widget_get_visible(vec[0]) && + gtk_widget_get_visible(vec[1]) && gtk_widget_get_visible(vec[2]); + bool vec_drive = false; + bool vec_reset = false; + if (vec_found) { + gtk_drop_down_set_selected(GTK_DROP_DOWN(vec[1]), 1); + const bool half_valid = f.valid(); // one waveform beside a scalar and a channels row + const std::string half = f.read().get("signals"); + gtk_drop_down_set_selected(GTK_DROP_DOWN(vec[2]), 1); + const bool scalar_left = f.valid(); // the scalar A still breaks the all-waveform rule + vec_drive = !half_valid && half == "A:uint8,V1:int16[4]w,V2:int16[4]" && !scalar_left && + f.read().get("signals") == "A:uint8,V1:int16[4]w,V2:int16[4]w"; + // Bring V1 back to one element: its drop-down hides and returns to Channels. + std::vector counts; + find_all_named(f.rows[2], "flatudp-count", counts); + if (counts.size() == 3) gtk_spin_button_set_value(GTK_SPIN_BUTTON(counts[1]), 1); + vec_reset = counts.size() == 3 && !gtk_widget_get_visible(vec[1]) && + gtk_drop_down_get_selected(GTK_DROP_DOWN(vec[1])) == 0 && + f.read().get("signals") == "A:uint8,V1:int16,V2:int16[4]w"; + } + + const bool ok = !initial && good && size_ok && !dup && dup_ok && !big && add != nullptr && added && + rm != nullptr && removed && wave_valid && wave_sub && wave_rt && !mixed && + mixed_msg && !length && length_msg && !cap && cap_msg && cap_wave && vec_shown && + vec_drive && vec_reset; + std::fprintf(stderr, + "[flatudp] form: initial_valid=%d good_valid=%d size_ok=%d dup_valid=%d dup_msg=%d " + "oversize_valid=%d add=%d remove=%d wave_valid=%d wave_sub=%d wave_roundtrip=%d " + "mixed_valid=%d mixed_msg=%d length_valid=%d length_msg=%d cap_valid=%d cap_msg=%d " + "cap_wave_valid=%d vec_shown=%d vec_drive=%d vec_reset=%d ok=%d\n", + initial ? 1 : 0, good ? 1 : 0, size_ok ? 1 : 0, dup ? 1 : 0, dup_ok ? 1 : 0, + big ? 1 : 0, added ? 1 : 0, removed ? 1 : 0, wave_valid ? 1 : 0, wave_sub ? 1 : 0, + wave_rt ? 1 : 0, mixed ? 1 : 0, mixed_msg ? 1 : 0, length ? 1 : 0, length_msg ? 1 : 0, + cap ? 1 : 0, cap_msg ? 1 : 0, cap_wave ? 1 : 0, vec_shown ? 1 : 0, vec_drive ? 1 : 0, + vec_reset ? 1 : 0, ok ? 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); + } +} + } // namespace /// Phase 2, 1 s after the controls were driven: the stream, the calibration, the capture button, @@ -2585,6 +2701,7 @@ 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); + flatudp_form_probe(); if (flatudp_source(a) == nullptr) { std::fprintf(stderr, "[flatudp] no flatudp source (run with --source flatudp)\n"); std::fflush(stderr);