Files
udpscope/docs/superpowers/plans/2026-11-02-e1608-network-scan.md

31 KiB

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):

    class Scan {
     public:
      explicit Scan(int port);
      Scan(const Scan&) = delete;
      Scan& operator=(const Scan&) = delete;
      const std::vector<DeviceInfo>& poll();      // drains non-blocking; cumulative, one entry per MAC
      std::size_t interfaces() const noexcept;    // sockets opened
    };
    std::vector<DeviceInfo> 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:

/// 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<DeviceInfo>& poll();
  /// How many interfaces a probe was sent on.
  std::size_t interfaces() const noexcept { return socks_.size(); }

 private:
  std::vector<Fd> socks_;
  std::vector<DeviceInfo> found_;
};

/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses.
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
  • Step 2: Share the reply parser

In e1608_link.cpp add includes #include <ifaddrs.h>, #include <net/if.h>, #include <thread> (keep the list alphabetical with the existing ones). Inside the existing anonymous namespace, just before its closing } // namespace (after tcp_connect), add:

/// 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<DeviceInfo>& found) {
  if (n < 29 || buf[0] != 'D') return false;
  DeviceInfo info;
  info.product_id = static_cast<std::uint16_t>(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:

    take_reply(buf, n, from, found);
  • Step 3: Implement Scan and discover_broadcast

After discover() (before the // --- Link banner) add:

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<std::uint16_t>(port));
  to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
  const std::uint8_t probe = 'D';
  std::vector<std::string> 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<socklen_t>(name.size())) != 0) {
      sockaddr_in me = *reinterpret_cast<const sockaddr_in*>(a->ifa_addr);
      me.sin_port = 0;
      if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&me), sizeof me) != 0) continue;
    }
    if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&to),
                 sizeof to) != 1)
      continue;
    done.push_back(name);
    socks_.push_back(std::move(s));
  }
  ::freeifaddrs(list);
}

const std::vector<DeviceInfo>& 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<sockaddr*>(&from), &flen);
      if (n < 0) break;  // nothing more on this socket for now
      take_reply(buf, n, from, found_);
    }
  }
  return found_;
}

std::vector<DeviceInfo> 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

    const std::vector<std::string> targets =
        host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
    const std::vector<DeviceInfo> found =
        discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500));

with

    const std::vector<std::string> targets =
        host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{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<DeviceInfo> 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:

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
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:

  GtkWidget* scan_row = nullptr;       ///< the "Scan network" expander; the results are its rows
  std::vector<GtkWidget*> results;     ///< the result rows of the current scan
  std::unique_ptr<Scan> 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(...):

  /// 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 <vector> to the includes if absent.

  • Step 2: Destroy hook stops the timer

Replace on_form_destroy with:

void on_form_destroy(GtkWidget*, gpointer user) {
  Form& f = **static_cast<FormPtr*>(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:

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<Pick*>(d); }

void on_pick_address(AdwActionRow*, gpointer user) {
  Pick& p = *static_cast<Pick*>(user);
  p.form->fill(p.ip, "");
}

void on_pick_mac(GtkButton*, gpointer user) {
  Pick& p = *static_cast<Pick*>(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<GConnectFlags>(0));
  g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac},
                        free_pick, static_cast<GConnectFlags>(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<FormPtr*>(user);
  Form& f = *fp;
  if (!f.alive || !f.scan) {
    f.timer = 0;
    return G_SOURCE_REMOVE;
  }
  const std::vector<DeviceInfo> 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<FormPtr*>(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<Scan>(static_cast<int>(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<FormPtr*>(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:

    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<GConnectFlags>(0));

Change the Connection group to start with the scan row:

    GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
                                              f->code, f->timeout});

Update the description string to:

    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
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
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=<ms> 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:

/// 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:

  if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) {
    g_timeout_add(static_cast<guint>(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):

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<GtkWidget*>& 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<App*>(user);
  if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
  E1608ScanProbe& p = *g_scan_probe;
  std::vector<GtkWidget*> 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<App*>(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<GtkWidget*> 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<App*>(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
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=<a local address> 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
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

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):

UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_PROBE_E1608_SCAN_MAC=<board 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.