From dbf145094ac9859d066bc9f5308ea617bf3244ba Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 10:20:16 +0100 Subject: [PATCH] e1608: probe and docs for the network scan Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +- docs/claude/architecture.md | 4 +- docs/claude/build-and-run.md | 3 +- docs/claude/probe-readings.md | 15 +++- docs/claude/verification.md | 7 ++ src/ui.cpp | 3 + src/ui_app.hpp | 6 ++ src/ui_probe.cpp | 148 ++++++++++++++++++++++++++++++++++ 8 files changed, 189 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a5af09c..5b194bf 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,13 @@ only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-che The second built-in kind is the **MCC E-1608** Ethernet DAQ (8 single-ended or 4 differential 16-bit inputs, two analog outputs, eight digital lines and a 32-bit counter). Its connection form takes an address, or a MAC address -to look up (optionally with the subnet to sweep), and groups the acquisition settings (scan rate, external clock, +to look up (optionally with the subnet to sweep). A **Scan network** row at the top of the form probes every +network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a +click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not +saved: only the filled field is. A MAC with no subnet is now looked up on every interface, not only the default +route's, because a single limited broadcast leaves through the default route alone and missed a board on another +interface (found on a real board on 2026-11-02, where the board was on `net0` and the default route was +`wlan0`). The form also groups the acquisition settings (scan rate, external clock, trigger, restart after an overrun, factory calibration, the digital poll rate), a range per analog input, and the direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out** diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index a093c44..9b21c8d 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -44,7 +44,9 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. has neither key, so a legacy session is read as UDPS and nothing existing moves. - **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link` (discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands, - factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; + factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is + `Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main + thread, the source's lookup goes through `discover_broadcast`); `e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire never reaches the UI files. - The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index b525c21..0f79a33 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -122,7 +122,8 @@ is exported when the option is absent, so GTK's own choice stands. drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1 alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`, `plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is -required; a MAC with no `address` is looked up by broadcast, or by a sweep of `subnet` (`a.b.c`). +required; a MAC with no `address` is looked up by a broadcast sent on every interface (`Scan`), or by a sweep of +`subnet` (`a.b.c`). The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S` diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 525bbeb..8585e82 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1859,11 +1859,12 @@ The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no [e1608] scan socket closed by the board [e1608] reconnect: connects=2 connected=1 ok=1 [e1608] resumed: frames_before=612 frames_after=662 ok=1 +[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1 ``` `UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and -`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the last two) run as `--source e1608` against `tools/mock_e1608.py` -with the options in `verification.md`. The line between `decode` and `reconnect` is the source's own stderr message +`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the next two) run as `--source e1608` against `tools/mock_e1608.py` +with the options in `verification.md`; `UDPSCOPE_PROBE_E1608_SCAN=1000` (the `scan` line) is a run of its own. The line between `decode` and `reconnect` is the source's own stderr message about the drop, not a probe line (its text is the error the source met, once per distinct message). `connected=0` throughout, with `actions: skipped (not connected)`, is the no-board run; it is a reading and not a failure, and it also prints the one `[e1608] handshake with ...: Connection refused` line, `stream: ... signals=0 expect=1 ... ok=0`, @@ -1894,6 +1895,16 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart 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). +- `scan: found=1 ... ok=1`: the form's **Scan network** row against the mock. `found` is the result rows after + the first scan; `0` with the second line `did not answer` is an environment with no up, non-loopback + broadcast interface that reaches the mock (or a refused broadcast), **not a pass**. `ip` is the listed address + of the expected board and `mac` the MAC the probe looked for. +- `status='1 board found'`: the row's subtitle at the end of the first scan (`N boards found` with more + than one). `No board found` with a mock running is the probe's sockets not reaching it. +- `fill_addr=0`: a click on a result did not set only the address (the MAC or subnet kept a stale value), or it + told the dialog something other than once. `fill_mac=0`: the same for **Use MAC**. +- `rescan_replaced=0`: the results of the first scan were kept or doubled by the second, so `start_scan` did not + remove them. ## `[flatudp]` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 45ff23f..26b3f79 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -441,6 +441,13 @@ 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_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt + address=127.0.0.1` against `tools/mock_e1608.py` on its default port 54211 (the form scans that port) -- builds + the connection form, presses **Scan network**, expects the mock to be listed, clicks the result (address + filled, MAC and subnet cleared), presses **Use MAC** (MAC filled, the others cleared), scans again and checks + the results were replaced. `UDPSCOPE_PROBE_E1608_SCAN_MAC` names another board (a real one) instead of the + mock's `00:80:2f:00:16:08`. One `[e1608] scan:` line. It needs a non-loopback up interface with a broadcast + address; when the board did not answer a second line says so, and the run proves nothing about the scan. - `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 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 diff --git a/src/ui.cpp b/src/ui.cpp index 347f373..fe5c28d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -6031,6 +6031,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) { g_timeout_add(static_cast(std::atoi(env)), e1608_probe_reconnect, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) { + g_timeout_add(static_cast(std::atoi(env)), e1608_scan_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) { g_timeout_add(static_cast(std::atoi(env)), flatudp_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 6090e9a..95852f4 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1873,6 +1873,12 @@ gboolean e1608_probe(gpointer user); gboolean e1608_probe_report(gpointer user); gboolean e1608_probe_reconnect(gpointer user); gboolean e1608_probe_resumed(gpointer user); +/// The E-1608 form's "Scan network" row: presses Scan against the mock, then (phases `_fill`, +/// `_rescan`) clicks a result to fill the address, "Use MAC" to fill the MAC, scans again and checks +/// the results were replaced, not added to. +gboolean e1608_scan_probe(gpointer user); +gboolean e1608_scan_probe_fill(gpointer user); +gboolean e1608_scan_probe_rescan(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 value continuity, the wrong-size accounting and /// the capture button (`_report`, a second later). `_gap` starts a 100 ms poll that waits for the diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index e9ac7a7..bb334b2 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -2458,6 +2458,154 @@ gboolean e1608_probe_reconnect(gpointer user) { return G_SOURCE_REMOVE; } +namespace { + +/// The scan probe's state, alive from phase 1 to phase 3. +struct E1608ScanProbe { + ConfigForm form; + int changes = 0; ///< how many times the form told the dialog it changed + std::string want_mac; ///< the board the probe expects (the mock's by default) + std::string status; ///< the scan row's subtitle, as read at the end of the first scan + std::string ip; ///< the expected board's address, as listed + std::size_t first = 0; ///< result rows after the first scan + bool listed = false; ///< the expected board was among them + bool fill_addr = false; + bool fill_mac = false; +}; + +E1608ScanProbe* g_scan_probe = nullptr; + +void collect_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)) + collect_named(c, name, out); +} + +std::string scan_status(E1608ScanProbe& p) { + GtkWidget* row = find_named(p.form.rows[0], "e1608-scan-row"); + const char* t = row != nullptr ? adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(row)) : nullptr; + return t != nullptr ? t : ""; +} + +/// A form holding stale values in all three naming fields, so a fill is seen to clear the others. +void scan_probe_stale(E1608ScanProbe& p) { + SourceConfig c; + c.set("address", "1.2.3.4"); + c.set("mac", "aa:bb:cc:dd:ee:ff"); + c.set("subnet", "10.0.0"); + p.form.write(c); + p.changes = 0; +} + +void scan_probe_click(E1608ScanProbe& p) { + GtkWidget* button = find_named(p.form.rows[0], "e1608-scan"); + if (button != nullptr) g_signal_emit_by_name(button, "clicked"); +} + +void scan_probe_cleanup() { + if (g_scan_probe == nullptr) return; + for (GtkWidget* row : g_scan_probe->form.rows) { + g_object_ref_sink(row); + g_object_unref(row); + } + delete g_scan_probe; + g_scan_probe = nullptr; +} + +} // namespace + +/// Phase 3, 2 s after the second Scan: the results were replaced (same count), and the one line. +gboolean e1608_scan_probe_rescan(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE; + E1608ScanProbe& p = *g_scan_probe; + std::vector rows; + collect_named(p.form.rows[0], "e1608-result", rows); + const bool replaced = rows.size() == p.first; + const std::string status = scan_status(p); + const bool status_ok = status.find("found") != std::string::npos && status.rfind("No", 0) != 0; + const bool ok = p.listed && p.fill_addr && p.fill_mac && replaced && status_ok; + std::fprintf(stderr, + "[e1608] scan: found=%zu ip=%s mac=%s status='%s' fill_addr=%d fill_mac=%d " + "rescan_replaced=%d ok=%d\n", + p.first, p.ip.c_str(), p.want_mac.c_str(), p.status.c_str(), p.fill_addr ? 1 : 0, + p.fill_mac ? 1 : 0, replaced ? 1 : 0, ok ? 1 : 0); + if (!p.listed) + std::fprintf(stderr, + "[e1608] scan: %s did not answer; no up broadcast interface reaches it, or the " + "broadcast was refused (this run proves nothing about the scan)\n", + p.want_mac.c_str()); + std::fflush(stderr); + scan_probe_cleanup(); + return G_SOURCE_REMOVE; +} + +/// Phase 2, 2 s after the first Scan: the expected board is listed, a result click fills the address +/// only, "Use MAC" fills the MAC only (each telling the dialog exactly once), then Scan again. +gboolean e1608_scan_probe_fill(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE; + E1608ScanProbe& p = *g_scan_probe; + p.status = scan_status(p); + std::vector rows; + collect_named(p.form.rows[0], "e1608-result", rows); + p.first = rows.size(); + GtkWidget* mine = nullptr; + for (GtkWidget* r : rows) { + const char* sub = adw_action_row_get_subtitle(ADW_ACTION_ROW(r)); + if (sub != nullptr && p.want_mac == sub) mine = r; + } + p.listed = mine != nullptr; + if (mine != nullptr) { + const char* title = adw_preferences_row_get_title(ADW_PREFERENCES_ROW(mine)); + p.ip = title != nullptr ? title : ""; + + scan_probe_stale(p); + g_signal_emit_by_name(mine, "activated"); + const SourceConfig by_addr = p.form.read(); + p.fill_addr = by_addr.get("address") == p.ip && by_addr.get("mac").empty() && + by_addr.get("subnet").empty() && p.changes == 1; + + scan_probe_stale(p); + GtkWidget* use = find_named(mine, "e1608-use-mac"); + if (use != nullptr) g_signal_emit_by_name(use, "clicked"); + const SourceConfig by_mac = p.form.read(); + p.fill_mac = by_mac.get("mac") == p.want_mac && by_mac.get("address").empty() && + by_mac.get("subnet").empty() && p.changes == 1; + } + scan_probe_click(p); + g_timeout_add(2000, e1608_scan_probe_rescan, a); + return G_SOURCE_REMOVE; +} + +/// Phase 1: the form, Scan pressed (the row must read `Scanning...` at once), phase 2 in 2 s. +gboolean e1608_scan_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + SourceKind* k = find_kind("e1608"); + if (k == nullptr) { + std::fprintf(stderr, "[e1608] scan: no e1608 kind\n"); + return G_SOURCE_REMOVE; + } + g_scan_probe = new E1608ScanProbe; + E1608ScanProbe& p = *g_scan_probe; + p.form = k->make_config_form(); + const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN_MAC"); + p.want_mac = env != nullptr ? env : "00:80:2f:00:16:08"; + p.form.on_change([]() { + if (g_scan_probe != nullptr) ++g_scan_probe->changes; + }); + scan_probe_click(p); + const std::string now = scan_status(p); + if (now != "Scanning...") + std::fprintf(stderr, "[e1608] scan: status right after Scan reads '%s', expected 'Scanning...'\n", + now.c_str()); + g_timeout_add(2000, e1608_scan_probe_fill, a); + return G_SOURCE_REMOVE; +} + /// The mock's channels, as the model must list them: a scalar `Name`, the elements of a `channels` /// vector `Name[k]`, a waveform `Name` at `rate * count`. struct FlatudpExpect {