diff --git a/README.md b/README.md index a5af09c..cea447c 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,12 @@ 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 looked up by a broadcast sent on every up interface: a +single limited broadcast leaves through the default route only, and on 2026-11-02 it missed a real board on +`net0` while 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** @@ -127,12 +132,17 @@ Acquisition is hardware-paced only (the scan rate or an external clock), the cha or below 250 kS/s, and the form refuses more. With the command-line options, a run that names no `aiN` key gets `ai0=BIP10V`; naming any `aiN` key drops that default, so `ai1=BIP5V` alone is channel 1 alone. -**This kind has not been run against a board.** It is verified against `tools/mock_e1608.py`, a protocol mock written -from two independent implementations of the wire protocol. The following rest on a single reference or the two -disagree, and are the first thing to check when a board is attached (the mock's header lists the same items): -the counter reset command (0x31, from one reference only); the pacer value (the source sends the *aggregate* +**This kind has been run against a real board once, on 2026-11-02, with one channel.** The board was at +169.254.88.75 on `net0`, `ai0=BIP10V` single-ended with the input grounded, `scan_rate=1000`, for 7 s: the connection +and handshake succeeded, the source configured one channel and streamed it at the declared 1000 S/s (about 1250 +samples in the first 1.25 s, no gaps, no overflows, no `[e1608]` errors), and the readings sat within a few LSB of +0 V (about 0.9 to 1.8 mV on a +-10 V range). Nothing else has been run on hardware; everything else is verified only +against `tools/mock_e1608.py`, a protocol mock written from two independent implementations of the wire protocol. +The following rest on a single reference or the two disagree, and are the first thing to check with more of the board +exercised (the mock's header lists the same items): +the counter reset command (0x31, from one reference only; not run on the board); the pacer value (the source sends the *aggregate* rate, `round(80e6 / (scan_rate * channels) - 1)`, as one reference does; the other sends the per-channel -frequency); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only); +frequency; the two differ only with two or more channels, so the one-channel run cannot settle it); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only); whether the handshake may answer status 2 ("code ignored"; one reference accepts it, the other refuses it, and the source accepts it); the first frame id; and the maximum frame size. Two further things are worth a look on a board even though the mock's header does not flag them: the calibration memory layout and 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..9cbff1a 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -122,12 +122,13 @@ 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` -switches, `--code N`); there is no real board in the loop, and the mock's header lists the items whose -layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message. +switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see +the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message. ```sh python3 tools/mock_e1608.py --port 54711 & 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..e840516 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -441,6 +441,16 @@ 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`. Start `tools/mock_e1608.py` first on its default port 54211 (the form scans that port; do + not pass `--port`) and kill it afterwards. The value is the delay of phase 1; phase 2 follows 2 s after it and + phase 3 (the line) 2 s after that, so phase 3 runs 4 s after phase 1 and the delay must stay below about + 2500 ms for the line to print before the exit at 6500 ms. It 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/docs/superpowers/plans/2026-11-02-e1608-network-scan.md b/docs/superpowers/plans/2026-11-02-e1608-network-scan.md new file mode 100644 index 0000000..8362d02 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-e1608-network-scan.md @@ -0,0 +1,681 @@ +# E-1608 Network Scan Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The E-1608 connection form gets a "Scan network" row that finds boards on every local interface and fills the address or the MAC from a click; the MAC lookup in `E1608Source` stops depending on the default route. + +**Architecture:** A non-blocking `Scan` class in `e1608_link` opens one UDP socket per up IPv4 broadcast interface (bound to the device, or to its address), sends `D` on each and drains replies on `poll()`. The form drives it with a 50 ms GLib timer on the GTK main thread (the project has exactly two threads, so no worker). `discover_broadcast()` is `Scan` plus a blocking wait and replaces the limited-broadcast `discover()` call in the source's MAC lookup. + +**Tech Stack:** C++20, GTK4/libadwaita (`AdwExpanderRow`, `AdwActionRow`), POSIX sockets (`getifaddrs`, `SO_BINDTODEVICE`), `tools/mock_e1608.py`, the in-app headless probe under `xvfb-run`. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md` (approved 2026-11-02). +- Exactly two threads: no worker thread for the scan; the form polls with `g_timeout_add*`. +- Every source file carries the SPDX MIT header (no new source files in this plan). +- Text buttons only (no theme icons) on the new controls. +- A `subnet` that is given still uses `sweep_targets` + `discover` unchanged. +- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty result. +- Scan results are never persisted: the session reads and writes only the filled fields. +- Baselines: a clean build has exactly 3 warnings (an incremental build shows the 2 `ui_probe.cpp` MathNode ones); `gtk warnings: 0`; no `Gtk-CRITICAL`. +- GTK traps (`docs/claude/invariants-gtk-and-chrome.md`): handler signatures are notify `(GObject*, GParamSpec*, gpointer)`, clicked `(GtkButton*, gpointer)`, changed `(GtkEditable*, gpointer)`; handlers behind `Form::alive`; probes use `g_object_ref_sink` + `g_object_unref` on never-parented rows. +- Probe code must avoid `x({...}).empty() || y({...}).empty()` (GCC `-Wdangling-pointer`). +- Verification runs are short and serial; kill every mock afterwards. Do not push or merge. + +## File Structure + +- Modify `src/source/e1608/e1608_link.hpp` / `.cpp`: `class Scan`, `discover_broadcast()`, a shared reply parser used by `discover()` too. +- Modify `src/source/e1608/e1608_source.cpp`: the MAC lookup with an empty subnet uses `discover_broadcast()`. +- Modify `src/source/e1608/e1608_kind.cpp`: the scan row, result rows, fills, timer. +- Modify `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp`: the `UDPSCOPE_PROBE_E1608_SCAN` probe. +- Modify docs: `README.md`, `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`. + +One design note the spec leaves open: `AdwExpanderRow` can only append rows, so "results right after the scan row" is done by making the scan row itself an `AdwExpanderRow` ("Scan network", a text "Scan" suffix button) at the top of the Connection expander, with the results as its child rows. It reads the same: results sit directly under the scan row. + +--- + +### Task 1: The `Scan` class, `discover_broadcast()`, and the MAC lookup + +**Files:** +- Modify: `src/source/e1608/e1608_link.hpp` (after `discover`, line ~77) +- Modify: `src/source/e1608/e1608_link.cpp` (includes; anonymous namespace; `discover`; new definitions) +- Modify: `src/source/e1608/e1608_source.cpp:270-293` + +**Interfaces:** +- Produces (used by Task 2): + ```cpp + class Scan { + public: + explicit Scan(int port); + Scan(const Scan&) = delete; + Scan& operator=(const Scan&) = delete; + const std::vector& poll(); // drains non-blocking; cumulative, one entry per MAC + std::size_t interfaces() const noexcept; // sockets opened + }; + std::vector discover_broadcast(int port, int wait_ms); + ``` +- Consumes: existing `Fd`, `DeviceInfo`, `kProductId`, `Clock`, `ms_left`. + +- [ ] **Step 1: Declare the API** + +In `e1608_link.hpp`, after the `discover` declaration add: + +```cpp +/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default +/// route only, so on a host with several interfaces it can miss a board on another one; this opens +/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or +/// to the interface address when binding to the device is refused), sends `D` to +/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the +/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan. +class Scan { + public: + explicit Scan(int port); + Scan(const Scan&) = delete; + Scan& operator=(const Scan&) = delete; + /// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC. + const std::vector& poll(); + /// How many interfaces a probe was sent on. + std::size_t interfaces() const noexcept { return socks_.size(); } + + private: + std::vector socks_; + std::vector found_; +}; + +/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses. +std::vector discover_broadcast(int port, int wait_ms); +``` + +- [ ] **Step 2: Share the reply parser** + +In `e1608_link.cpp` add includes `#include `, `#include `, `#include ` (keep the list alphabetical with the existing ones). Inside the existing anonymous namespace, just before its closing `} // namespace` (after `tcp_connect`), add: + +```cpp +/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board +/// that `found` did not already hold (one entry per MAC). +bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from, + std::vector& found) { + if (n < 29 || buf[0] != 'D') return false; + DeviceInfo info; + info.product_id = static_cast(buf[7] | (buf[8] << 8)); + if (info.product_id != kProductId) return false; + std::copy(buf + 1, buf + 7, info.mac.begin()); + for (const DeviceInfo& d : found) { + if (d.mac == info.mac) return false; + } + char ip[INET_ADDRSTRLEN] = {}; + ::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip); + info.ip = ip; + found.push_back(info); + return true; +} +``` + +In `discover()`, replace the block from `if (n < 29 || buf[0] != 'D') continue;` through `found.push_back(info);` (the lines that build `info`, test the MAC for a duplicate and push) with the single call: + +```cpp + take_reply(buf, n, from, found); +``` + +- [ ] **Step 3: Implement `Scan` and `discover_broadcast`** + +After `discover()` (before the `// --- Link` banner) add: + +```cpp +Scan::Scan(int port) { + ifaddrs* list = nullptr; + if (::getifaddrs(&list) != 0) return; + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons(static_cast(port)); + to.sin_addr.s_addr = htonl(INADDR_BROADCAST); + const std::uint8_t probe = 'D'; + std::vector done; // an interface with several addresses needs one probe, not several + for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) { + if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue; + const unsigned fl = a->ifa_flags; + if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue; + const std::string name = a->ifa_name; + if (std::find(done.begin(), done.end(), name) != done.end()) continue; + Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0)); + if (!s.valid()) continue; + const int one = 1; + ::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one); + if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(), + static_cast(name.size())) != 0) { + sockaddr_in me = *reinterpret_cast(a->ifa_addr); + me.sin_port = 0; + if (::bind(s.get(), reinterpret_cast(&me), sizeof me) != 0) continue; + } + if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&to), + sizeof to) != 1) + continue; + done.push_back(name); + socks_.push_back(std::move(s)); + } + ::freeifaddrs(list); +} + +const std::vector& Scan::poll() { + for (const Fd& s : socks_) { + for (;;) { + std::uint8_t buf[128]; + sockaddr_in from{}; + socklen_t flen = sizeof from; + const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT, + reinterpret_cast(&from), &flen); + if (n < 0) break; // nothing more on this socket for now + take_reply(buf, n, from, found_); + } + } + return found_; +} + +std::vector discover_broadcast(int port, int wait_ms) { + Scan scan(port); + const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms); + while (ms_left(deadline) > 0) { + scan.poll(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return scan.poll(); +} +``` + +- [ ] **Step 4: Use it in the source's lookup** + +In `e1608_source.cpp` `connect_and_start()`, replace + +```cpp + const std::vector targets = + host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const std::vector found = + discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500)); +``` + +with + +```cpp + const std::vector targets = + host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const int wait_ms = std::min(cfg_.timeout_ms, 1500); + // No address and no subnet: the limited broadcast, sent on every interface rather than only + // the default route's. + const bool broadcast = + host.empty() && targets.size() == 1 && targets[0] == "255.255.255.255"; + const std::vector found = + broadcast ? discover_broadcast(cfg_.port, wait_ms) : discover(targets, cfg_.port, wait_ms); +``` + +- [ ] **Step 5: Build** + +Run: `cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done` +Expected: no `error:`; only the 2 baseline `ui_probe.cpp` MathNode warnings (incremental) -- nothing new from `e1608_link.cpp` or `e1608_source.cpp`. + +- [ ] **Step 6: Verify the MAC lookup end to end against the mock** + +The mock answers a broadcast `D` on UDP 54211. A run with only a `mac` option (no address, no subnet) must find it and stream: + +```sh +python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 & +MOCK=$! +UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a ./build/udpscope --source e1608 \ + --source-opt mac=00:80:2f:00:16:08 --source-opt scan_rate=1000 2>&1 | grep -E "^\[e1608\] (stream|state)" +kill $MOCK +``` + +Expected: `[e1608] stream: connected=1 ... ok=1` and `state: connects=1 message=''`. +If it reads `connected=0` with `no E-1608 with MAC ... answered`: the sandbox did not loop the broadcast back to the mock. Check with `ip -4 -o addr` that an up non-loopback interface with a broadcast address exists. If one does and it still fails, report that to the user as a finding; do not paper over it. + +- [ ] **Step 7: Commit** + +```bash +git add src/source/e1608/e1608_link.hpp src/source/e1608/e1608_link.cpp src/source/e1608/e1608_source.cpp +git commit -m "e1608: discover boards on every interface, not only the default route's" +``` + +--- + +### Task 2: The form's "Scan network" row, results and fills + +**Files:** +- Modify: `src/source/e1608/e1608_kind.cpp` + +**Interfaces:** +- Consumes: `Scan`, `DeviceInfo`, `format_mac` (Task 1 / existing, via `e1608_source.hpp`'s includes of `e1608_link.hpp`; add the include if the build says otherwise). +- Produces (used by Task 3's probe), by widget name: the scan row `AdwExpanderRow` named `e1608-scan-row`; its suffix `GtkButton` named `e1608-scan`; each result an `AdwActionRow` named `e1608-result` (title IP, subtitle MAC, `activated` fills the address) holding a suffix button named `e1608-use-mac`. Subtitle texts: `Scanning...`, `1 board found`, `N boards found`, `No board found`. + +- [ ] **Step 1: Add the state and `fill()` to `Form`** + +In `struct Form`, after `bool alive = true;...` add: + +```cpp + GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows + std::vector results; ///< the result rows of the current scan + std::unique_ptr scan; ///< non-null while a scan is running + guint timer = 0; ///< the GLib source polling `scan`, 0 when none + int scan_ms = 0; ///< time spent in the running scan +``` + +and, after `write(...)`: + +```cpp + /// A result click: puts one way of naming the board in and the other two fields out, under the + /// `updating` guard so the dialog is told once. + void fill(const std::string& addr, const std::string& mac_text) { + if (!alive) return; + updating = true; + gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str()); + gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str()); + gtk_editable_set_text(GTK_EDITABLE(subnet), ""); + updating = false; + if (changed && *changed) (*changed)(); + } + + void stop_scan() { + if (timer != 0) g_source_remove(timer); + timer = 0; + scan.reset(); + } +``` + +Add `#include ` to the includes if absent. + +- [ ] **Step 2: Destroy hook stops the timer** + +Replace `on_form_destroy` with: + +```cpp +void on_form_destroy(GtkWidget*, gpointer user) { + Form& f = **static_cast(user); + f.alive = false; + f.stop_scan(); // the poll must not outlive the rows it writes to +} +``` + +- [ ] **Step 3: The scan functions** + +After `hook_entry(...)` and before `class E1608Kind`, add: + +```cpp +constexpr int kScanMs = 1500; ///< how long a scan listens +constexpr int kTickMs = 50; ///< how often it drains the sockets + +/// A result row's two ways to name the board, with the form they fill. +struct Pick { + FormPtr form; + std::string ip, mac; +}; + +void free_pick(gpointer d, GClosure*) { delete static_cast(d); } + +void on_pick_address(AdwActionRow*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill(p.ip, ""); +} + +void on_pick_mac(GtkButton*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill("", p.mac); +} + +void add_result(const FormPtr& f, const DeviceInfo& d) { + const std::string mac = format_mac(d.mac); + GtkWidget* row = adw_action_row_new(); + gtk_widget_set_name(row, "e1608-result"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str()); + adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str()); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); + GtkWidget* use_mac = gtk_button_new_with_label("Use MAC"); + gtk_widget_set_name(use_mac, "e1608-use-mac"); + gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER); + adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac); + g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address), + new Pick{f, d.ip, mac}, free_pick, static_cast(0)); + g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac}, + free_pick, static_cast(0)); + adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE); + f->results.push_back(row); +} + +void set_scan_status(Form& f, const std::string& text) { + adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str()); +} + +/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is +/// settled and the scan (its sockets) is released. +gboolean on_scan_tick(gpointer user) { + const FormPtr& fp = *static_cast(user); + Form& f = *fp; + if (!f.alive || !f.scan) { + f.timer = 0; + return G_SOURCE_REMOVE; + } + const std::vector found = f.scan->poll(); + while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]); + f.scan_ms += kTickMs; + if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE; + if (found.empty()) + set_scan_status(f, "No board found"); + else if (found.size() == 1) + set_scan_status(f, "1 board found"); + else + set_scan_status(f, std::to_string(found.size()) + " boards found"); + f.scan.reset(); + f.timer = 0; + return G_SOURCE_REMOVE; +} + +void free_form_ptr(gpointer d) { delete static_cast(d); } + +/// A new scan on the Port field's value: the previous results go, a running scan is replaced. +void start_scan(const FormPtr& fp) { + Form& f = *fp; + if (!f.alive) return; + f.stop_scan(); + for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r); + f.results.clear(); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE); + f.scan = std::make_unique(static_cast(value_of(f.port))); + f.scan_ms = 0; + set_scan_status(f, "Scanning..."); + f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp), + free_form_ptr); +} + +void on_scan_clicked(GtkButton*, gpointer user) { + start_scan(*static_cast(user)); +} +``` + +(`f.stop_scan()` removes the timer; `g_source_remove` fires `free_form_ptr`, which only drops one `FormPtr` copy, never the last. `on_scan_tick` never calls `g_source_remove` on itself: it zeroes `timer` and returns `G_SOURCE_REMOVE`.) + +- [ ] **Step 4: Build the row in `make_config_form()`** + +After the `f->ao_init[1] = ...` line, add: + +```cpp + f->scan_row = adw_expander_row_new(); + gtk_widget_set_name(f->scan_row, "e1608-scan-row"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network"); + GtkWidget* scan_button = gtk_button_new_with_label("Scan"); + gtk_widget_set_name(scan_button, "e1608-scan"); + gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER); + adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button); + g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f), + free_form, static_cast(0)); +``` + +Change the Connection group to start with the scan row: + +```cpp + GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port, + f->code, f->timeout}); +``` + +Update the description string to: + +```cpp + out.description = + "Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), " + "or scan the network for boards"; +``` + +- [ ] **Step 5: Build, then check the existing form round trip and warnings** + +```sh +cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done +UDPSCOPE_PROBE_SOURCE_KINDS=1000 UDPSCOPE_EXIT_AFTER_MS=2500 xvfb-run -a ./build/udpscope \ + --source probe --source-opt rate=500 2>&1 | grep -E "form e1608|gtk warnings|Gtk-CRITICAL" +``` + +Expected: no `error:`, only the 2 baseline warnings; `[source-kinds] form e1608: round_trip=1 valid=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`. + +- [ ] **Step 6: Commit** + +```bash +git add src/source/e1608/e1608_kind.cpp +git commit -m "e1608: a Scan network row in the connection form, with click-to-fill results" +``` + +--- + +### Task 3: The scan probe, docs, and final verification + +**Files:** +- Modify: `src/ui_probe.cpp` (after `e1608_probe_reconnect`, ~line 2459) +- Modify: `src/ui_app.hpp:1874-1875` +- Modify: `src/ui.cpp:6031-6033` +- Modify: `README.md` (E-1608 paragraph, lines ~118-146), `docs/claude/build-and-run.md:118-130`, `docs/claude/verification.md` (after the `UDPSCOPE_PROBE_E1608_RECONNECT` bullet, ~line 443), `docs/claude/probe-readings.md` (`## [e1608]` section, ~line 1848-1890), `docs/claude/architecture.md:45-48` + +**Interfaces:** +- Consumes: the widget names from Task 2; `find_named()`, `find_kind()` in `ui_probe.cpp`. +- Produces: env var `UDPSCOPE_PROBE_E1608_SCAN=` and optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` (default `00:80:2f:00:16:08`, the mock's); one stderr line `[e1608] scan: ...`. + +- [ ] **Step 1: Declare and register the probe** + +In `ui_app.hpp`, after `gboolean e1608_probe_resumed(gpointer user);` add: + +```cpp +/// 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); +``` + +In `ui.cpp`, after the `UDPSCOPE_PROBE_E1608_RECONNECT` block add: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) { + g_timeout_add(static_cast(std::atoi(env)), e1608_scan_probe, &app); + } +``` + +- [ ] **Step 2: Write the probe** + +In `ui_probe.cpp`, after `e1608_probe_reconnect`, add (this is outside the file's anonymous namespace, like its neighbours; the helpers it uses, `find_named` and `find_kind`, are visible): + +```cpp +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; +} +``` + +Notes for the implementer: `SourceConfig::get` returns `std::string` (the existing probe calls `f.read().get("signals") == "..."`); `find_kind`, `SourceConfig` and `ConfigForm` are already used in this file. Do not write `x({...}).empty() || y({...}).empty()`. + +- [ ] **Step 3: Build and run against the mock** + +```sh +cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done +python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 & +MOCK=$! +UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_EXIT_AFTER_MS=6500 xvfb-run -a ./build/udpscope \ + --source e1608 --source-opt address=127.0.0.1 --source-opt scan_rate=1000 2>&1 \ + | grep -E "^\[e1608\] scan|gtk warnings|Gtk-CRITICAL" +kill $MOCK +``` + +Expected: `[e1608] scan: found=1 ip= mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`. `found=2` or more is fine if a real board is on the network (status then reads `N boards found`; the line still needs `ok=1`). +If `ok=0` and the second line says the board did not answer, check Task 1 Step 6's loopback note, and tell the user honestly; do not mark this step passed. +If a flag other than `listed` reads 0, debug that flag (the fill reads the form back; `changes` must be exactly 1 per fill). + +- [ ] **Step 4: Clean-build warning count** + +```sh +rm -rf /tmp/scan-clean && cmake -S . -B /tmp/scan-clean >/dev/null && cmake --build /tmp/scan-clean -j8 2>&1 | grep -c "warning:"; rm -rf /tmp/scan-clean +``` + +Expected: `3`. + +- [ ] **Step 5: Docs** + +- `README.md`, E-1608 paragraph (line ~119): after "...(optionally with the subnet to sweep)," add the scan: the sentence "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." Also add one sentence that 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 2026-11-02, where the board was on `net0` and the default route was `wlan0`). +- `docs/claude/build-and-run.md` line ~125: change "a MAC with no `address` is looked up by broadcast" to "...looked up by a broadcast sent on every interface (`Scan`), or by a sweep of `subnet`". +- `docs/claude/verification.md`: after the `UDPSCOPE_PROBE_E1608_RECONNECT=8000` bullet add a bullet for `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt address=127.0.0.1` against the mock on its default port 54211 (the form scans that port), optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` for a real board; one `[e1608] scan:` line; note it needs a non-loopback up interface with a broadcast address, and that the line says so when the board did not answer. +- `docs/claude/probe-readings.md`, `## [e1608]`: add the sample line `[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` to the block, and one bullet per field: `found` (result rows after the first scan; 0 with the "did not answer" second line is an environment without a reachable broadcast interface, not a pass), `status` (the row subtitle; "No board found" with a mock running is the probe sockets not reaching it), `fill_addr=0` (a result click did not set only the address, or told the dialog other than once), `fill_mac=0` (same for Use MAC), `rescan_replaced=0` (the results of the first scan were kept or doubled: `start_scan` did not remove them). +- `docs/claude/architecture.md` line ~45: in the E-1608 three-layers bullet, add that 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 through `discover_broadcast`). + +- [ ] **Step 6: Commit** + +```bash +git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp README.md docs/claude +git commit -m "e1608: probe and docs for the network scan" +``` + +- [ ] **Step 7: Manual run on the real board (only if it is attached)** + +Check `ip -4 -o addr` for the board's interface and `ping`-free: simply run the probe without the mock, naming the board's MAC (the user knows it; the MAC printed by the earlier hardware run was on `net0`, address 169.254.88.75): + +```sh +UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_PROBE_E1608_SCAN_MAC= UDPSCOPE_EXIT_AFTER_MS=6500 \ + xvfb-run -a ./build/udpscope --source e1608 --source-opt address=127.0.0.1 2>&1 | grep -E "^\[e1608\] scan" +``` + +If it finds the board (`listed`, `ok=1`), add one sentence with the date to the README's E-1608 paragraph ("the network scan was checked against a real board on ") and commit `docs: record the scan check on a real board`. If the board is not attached or the MAC is unknown, say so; claim nothing. diff --git a/src/source/e1608/e1608_kind.cpp b/src/source/e1608/e1608_kind.cpp index 2f89931..9ba2d16 100644 --- a/src/source/e1608/e1608_kind.cpp +++ b/src/source/e1608/e1608_kind.cpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace udpscope { namespace { @@ -82,6 +83,11 @@ struct Form { ChangeCb changed = std::make_shared>(); bool updating = false; bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing + GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows + std::vector results; ///< the result rows of the current scan + std::unique_ptr scan; ///< non-null while a scan is running + guint timer = 0; ///< the GLib source polling `scan`, 0 when none + int scan_ms = 0; ///< time spent in the running scan bool diff() const { return sel_of(mode) == 1; } int enabled_ai() const { @@ -164,6 +170,24 @@ struct Form { refresh(); } + /// A result click: puts one way of naming the board in and the other two fields out, under the + /// `updating` guard so the dialog is told once. + void fill(const std::string& addr, const std::string& mac_text) { + if (!alive) return; + updating = true; + gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str()); + gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str()); + gtk_editable_set_text(GTK_EDITABLE(subnet), ""); + updating = false; + if (changed && *changed) (*changed)(); + } + + void stop_scan() { + if (timer != 0) g_source_remove(timer); + timer = 0; + scan.reset(); + } + bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); } }; @@ -185,7 +209,11 @@ void on_edited(GtkEditable*, gpointer user) { 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 on_form_destroy(GtkWidget*, gpointer user) { + Form& f = **static_cast(user); + f.alive = false; + f.stop_scan(); // the poll must not outlive the rows it writes to +} void hook_destroy(GtkWidget* w, const FormPtr& f) { g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form, @@ -202,6 +230,96 @@ void hook_entry(GtkWidget* w, const FormPtr& f) { static_cast(0)); } +constexpr int kScanMs = 1500; ///< how long a scan listens +constexpr int kTickMs = 50; ///< how often it drains the sockets + +/// A result row's two ways to name the board: what each fills into the form (the other is cleared). +struct Pick { + FormPtr form; + std::string value; +}; + +void free_pick(gpointer d, GClosure*) { delete static_cast(d); } + +void on_pick_address(AdwActionRow*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill(p.value, ""); +} + +void on_pick_mac(GtkButton*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill("", p.value); +} + +void add_result(const FormPtr& f, const DeviceInfo& d) { + const std::string mac = format_mac(d.mac); + GtkWidget* row = adw_action_row_new(); + gtk_widget_set_name(row, "e1608-result"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str()); + adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str()); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); + GtkWidget* use_mac = gtk_button_new_with_label("Use MAC"); + gtk_widget_set_name(use_mac, "e1608-use-mac"); + gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER); + adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac); + g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address), + new Pick{f, d.ip}, free_pick, static_cast(0)); + g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, mac}, + free_pick, static_cast(0)); + adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE); + f->results.push_back(row); +} + +void set_scan_status(Form& f, const std::string& text) { + adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str()); +} + +/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is +/// settled and the scan (its sockets) is released. +gboolean on_scan_tick(gpointer user) { + const FormPtr& fp = *static_cast(user); + Form& f = *fp; + if (!f.alive || !f.scan) { + f.timer = 0; + return G_SOURCE_REMOVE; + } + const auto& found = f.scan->poll(); // valid until f.scan.reset() below, after its last use + while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]); + f.scan_ms += kTickMs; + if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE; + if (found.empty()) + set_scan_status(f, "No board found"); + else if (found.size() == 1) + set_scan_status(f, "1 board found"); + else + set_scan_status(f, std::to_string(found.size()) + " boards found"); + f.scan.reset(); + f.timer = 0; + return G_SOURCE_REMOVE; +} + +void free_form_ptr(gpointer d) { delete static_cast(d); } + +/// A new scan on the Port field's value: the previous results go, a running scan is replaced. +void start_scan(const FormPtr& fp) { + Form& f = *fp; + if (!f.alive) return; + f.stop_scan(); + for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r); + f.results.clear(); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE); + f.scan = std::make_unique(static_cast(value_of(f.port))); + f.scan_ms = 0; + set_scan_status(f, "Scanning..."); + f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp), + free_form_ptr); +} + +void on_scan_clicked(GtkButton*, gpointer user) { + start_scan(*static_cast(user)); +} + class E1608Kind final : public SourceKind { public: std::string id() const override { return "e1608"; } @@ -253,8 +371,18 @@ class E1608Kind final : public SourceKind { f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3); f->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3); - GtkWidget* conn = expander("Connection", {f->address, f->mac, f->subnet, f->port, f->code, - f->timeout}); + f->scan_row = adw_expander_row_new(); + gtk_widget_set_name(f->scan_row, "e1608-scan-row"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network"); + GtkWidget* scan_button = gtk_button_new_with_label("Scan"); + gtk_widget_set_name(scan_button, "e1608-scan"); + gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER); + adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button); + g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f), + free_form, static_cast(0)); + + GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port, + f->code, f->timeout}); GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart, f->calibrate, f->poll_rate}); GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3], @@ -283,7 +411,8 @@ class E1608Kind final : public SourceKind { ConfigForm out; out.rows = {conn, acq, ai, dio}; out.description = - "Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1)"; + "Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), " + "or scan the network for boards"; out.focus = f->address; out.write = [f](const SourceConfig& c) { f->write(c); }; out.read = [f]() { return f->read(); }; diff --git a/src/source/e1608/e1608_link.cpp b/src/source/e1608/e1608_link.cpp index 566d0a0..e2a4371 100644 --- a/src/source/e1608/e1608_link.cpp +++ b/src/source/e1608/e1608_link.cpp @@ -9,11 +9,14 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include namespace udpscope::e1608 { @@ -120,6 +123,25 @@ Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) { return fd; } +/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board +/// that `found` did not already hold (one entry per MAC). +bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from, + std::vector& found) { + if (n < 29 || buf[0] != 'D') return false; + DeviceInfo info; + info.product_id = static_cast(buf[7] | (buf[8] << 8)); + if (info.product_id != kProductId) return false; + std::copy(buf + 1, buf + 7, info.mac.begin()); + for (const DeviceInfo& d : found) { + if (d.mac == info.mac) return false; + } + char ip[INET_ADDRSTRLEN] = {}; + ::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip); + info.ip = ip; + found.push_back(info); + return true; +} + } // namespace // --- addresses ------------------------------------------------------------------------------ @@ -222,22 +244,73 @@ std::vector discover(const std::vector& targets, int po if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; break; } - if (n < 29 || buf[0] != 'D') continue; - DeviceInfo info; - info.product_id = static_cast(buf[7] | (buf[8] << 8)); - if (info.product_id != kProductId) continue; - std::copy(buf + 1, buf + 7, info.mac.begin()); - bool dup = false; - for (const DeviceInfo& d : found) dup = dup || d.mac == info.mac; - if (dup) continue; - char ip[INET_ADDRSTRLEN] = {}; - ::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip); - info.ip = ip; - found.push_back(info); + take_reply(buf, n, from, found); } return found; } +Scan::Scan(int port) { + ifaddrs* list = nullptr; + if (::getifaddrs(&list) != 0) return; + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons(static_cast(port)); + to.sin_addr.s_addr = htonl(INADDR_BROADCAST); + const std::uint8_t probe = 'D'; + std::vector done; // an interface with several addresses needs one probe, not several + for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) { + if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue; + const unsigned fl = a->ifa_flags; + if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue; + const std::string name = a->ifa_name; + if (std::find(done.begin(), done.end(), name) != done.end()) continue; + Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0)); + if (!s.valid()) continue; + const int one = 1; + ::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one); + if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(), + static_cast(name.size())) != 0) { + sockaddr_in me = *reinterpret_cast(a->ifa_addr); + me.sin_port = 0; + if (::bind(s.get(), reinterpret_cast(&me), sizeof me) != 0) continue; + } + if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&to), + sizeof to) != 1) + continue; + done.push_back(name); + socks_.push_back(std::move(s)); + } + ::freeifaddrs(list); +} + +const std::vector& Scan::poll() { + for (const Fd& s : socks_) { + for (;;) { + std::uint8_t buf[128]; + sockaddr_in from{}; + socklen_t flen = sizeof from; + const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT, + reinterpret_cast(&from), &flen); + if (n < 0) { + if (errno == EINTR) continue; + break; // nothing more on this socket for now + } + take_reply(buf, n, from, found_); + } + } + return found_; +} + +std::vector discover_broadcast(int port, int wait_ms) { + Scan scan(port); + const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms); + while (ms_left(deadline) > 0) { + scan.poll(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return scan.poll(); +} + // --- Link ----------------------------------------------------------------------------------- void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) { diff --git a/src/source/e1608/e1608_link.hpp b/src/source/e1608/e1608_link.hpp index 7c0a98d..7516fb0 100644 --- a/src/source/e1608/e1608_link.hpp +++ b/src/source/e1608/e1608_link.hpp @@ -76,6 +76,30 @@ std::vector sweep_targets(const std::string& subnet); /// `kProductId`) and one entry per MAC. std::vector discover(const std::vector& targets, int port, int wait_ms); +/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default +/// route only, so on a host with several interfaces it can miss a board on another one; this opens +/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or +/// to the interface address when binding to the device is refused), sends `D` to +/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the +/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan. +class Scan { + public: + explicit Scan(int port); + Scan(const Scan&) = delete; + Scan& operator=(const Scan&) = delete; + /// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC. + const std::vector& poll(); + /// How many interfaces a probe was sent on. + std::size_t interfaces() const noexcept { return socks_.size(); } + + private: + std::vector socks_; + std::vector found_; +}; + +/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses. +std::vector discover_broadcast(int port, int wait_ms); + /// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`). /// Not thread safe; the source's producer thread owns it. class Link { diff --git a/src/source/e1608/e1608_source.cpp b/src/source/e1608/e1608_source.cpp index d7d8d7f..7e85e95 100644 --- a/src/source/e1608/e1608_source.cpp +++ b/src/source/e1608/e1608_source.cpp @@ -272,10 +272,15 @@ void E1608Source::connect_and_start() { Mac want{}; const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want); if (host.empty() || have_mac) { - const std::vector targets = - host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const int wait_ms = std::min(cfg_.timeout_ms, 1500); + // No address and no subnet: the limited broadcast, sent on every interface rather than only + // the default route's. + const bool broadcast = host.empty() && cfg_.subnet.empty(); const std::vector found = - discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500)); + broadcast ? discover_broadcast(cfg_.port, wait_ms) + : discover(host.empty() ? sweep_targets(cfg_.subnet) + : std::vector{host}, + cfg_.port, wait_ms); if (host.empty()) { for (const DeviceInfo& d : found) { if (d.mac == want) host = d.ip; 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 {