From 64e0bc0f2e6e46f9372c12b39e7f29e44b3b5979 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Fri, 9 Oct 2026 16:00:43 -0500 Subject: [PATCH] setup: match a USB pod to its mDNS entry by public key; nudge outdated firmware - A pod on Ethernet and Wi-Fi reports its wired address over USB while mDNS can answer on Wi-Fi. A registered pod was matched by its cloud device id, but an unregistered one (the case benchpod setup is for) has none, so discover listed it twice and setup stopped with 'found 2 BenchPods'. The USB probe now also reads the pod's identity (read-only) and correlate matches its public key against the mDNS TXT id. Found and verified live on benchpod-baea06. - setup step 1 prints the update command when the pod's firmware is older than the latest release (it does not flash on its own). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++ cmd/benchpod-cli/commands_discover.go | 14 +++++---- cmd/benchpod-cli/commands_discover_test.go | 15 ++++++++++ cmd/benchpod-cli/setup.go | 12 ++++++++ cmd/benchpod-cli/setup_test.go | 34 ++++++++++++++++++++++ internal/serialconsole/probe.go | 15 +++++++++- 6 files changed, 88 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b11b556..2b1573e 100644 --- a/README.md +++ b/README.md @@ -606,6 +606,10 @@ $ benchpod setup --la-voltage 3.3V Default connection set to network/TCP 192.168.1.221:8080 (saved to ~/.config/benchpod-cli/config.json). ``` +When the pod runs firmware older than the latest release, step 1 also prints the update +command (`benchpod flash-self --enter-dfu`, then replug USB-C); setup does not flash on its own. + + | Flag | Default | Purpose | |--------------------|---------|-------------------------------------------------------------------------| | `--la-voltage` | (ask) | Board I/O voltage to set: `1.8V` or `3.3V`. Without it, a voltage already set is kept and an unset one is asked for. | diff --git a/cmd/benchpod-cli/commands_discover.go b/cmd/benchpod-cli/commands_discover.go index 0e0cf14..924ed71 100644 --- a/cmd/benchpod-cli/commands_discover.go +++ b/cmd/benchpod-cli/commands_discover.go @@ -192,10 +192,12 @@ func checkPod(p *discoveredPod) { } // correlate marks each network pod that is the same physical device as one of -// the pods found over USB, matched on the address the pod reports for itself or, -// for a registered pod, on its cloud device id. The id catches a pod on Ethernet -// and Wi-Fi at once, whose USB console reports one address while mDNS answers on -// the other. Without this a single pod plugged in and on the LAN reads as two. +// the pods found over USB, matched on the address the pod reports for itself, on its +// public key (USB `identity` vs the mDNS TXT id) or, for a registered pod, on its +// cloud device id. The key and the id catch a pod on Ethernet and Wi-Fi at once, +// whose USB console reports one address while mDNS answers on the other; the key +// also works before the pod is registered. Without this a single pod plugged in and +// on the LAN reads as two. func correlate(netPods []discoveredPod, serialPods []serialconsole.SerialPod) { for i := range netPods { host, _, err := net.SplitHostPort(netPods[i].addr) @@ -203,8 +205,10 @@ func correlate(netPods []discoveredPod, serialPods []serialconsole.SerialPod) { host = netPods[i].addr } id := strings.TrimSpace(netPods[i].cloud.DeviceID) + key := strings.TrimSpace(netPods[i].id) for _, sp := range serialPods { - if (sp.Addressed() && sp.IP == host) || (id != "" && strings.TrimSpace(sp.DeviceID) == id) { + if (sp.Addressed() && sp.IP == host) || (id != "" && strings.TrimSpace(sp.DeviceID) == id) || + (key != "" && strings.TrimSpace(sp.PublicKey) == key) { netPods[i].sameAs = sp.Device break } diff --git a/cmd/benchpod-cli/commands_discover_test.go b/cmd/benchpod-cli/commands_discover_test.go index 0f66f7f..c7ed688 100644 --- a/cmd/benchpod-cli/commands_discover_test.go +++ b/cmd/benchpod-cli/commands_discover_test.go @@ -39,6 +39,21 @@ func TestCorrelateMatchesADualHomedPodByDeviceID(t *testing.T) { } } +// A pod straight out of the box on Ethernet and Wi-Fi has no device id yet; its public key +// (USB `identity`, mDNS TXT id) is what makes it one pod rather than two. +func TestCorrelateMatchesAnUnregisteredDualHomedPodByPublicKey(t *testing.T) { + netPods := []discoveredPod{{addr: "192.168.1.221:8080", id: "uuoGf1v6n9yeBZZVkey", cloud: cloudState{known: true}}} + correlate(netPods, []serialconsole.SerialPod{{Device: "/dev/ttyACM0", IP: "192.168.1.220", PublicKey: "uuoGf1v6n9yeBZZVkey"}}) + if netPods[0].sameAs != "/dev/ttyACM0" { + t.Fatalf("got %q", netPods[0].sameAs) + } + other := []discoveredPod{{addr: "192.168.1.221:8080", id: "someone-else", cloud: cloudState{known: true}}} + correlate(other, []serialconsole.SerialPod{{Device: "/dev/ttyACM0", IP: "192.168.1.220", PublicKey: "uuoGf1v6n9yeBZZVkey"}}) + if other[0].sameAs != "" { + t.Fatalf("different keys were matched: %q", other[0].sameAs) + } +} + func TestCorrelateIgnoresUnaddressedSerialPod(t *testing.T) { // A pod with no lease reports 0.0.0.0; it must never match a network entry, // which would otherwise collapse two distinct pods into one. diff --git a/cmd/benchpod-cli/setup.go b/cmd/benchpod-cli/setup.go index 24372c5..64b3928 100644 --- a/cmd/benchpod-cli/setup.go +++ b/cmd/benchpod-cli/setup.go @@ -111,6 +111,8 @@ type setupEnv interface { accountDevices() map[string]string register(addr string) error save(target string) error + // latestFirmware is the latest firmware release tag, "" when it could not be read. + latestFirmware() string } // errNeedsAnswer is returned under --yes when a step needs an answer that no flag gave. @@ -210,6 +212,14 @@ func runSetup(env setupEnv, ui *setupUI, o setupOpts) error { } p := pods[0] fmt.Fprintf(out, " Found %s.\n", describeSetupPod(p)) + if latest := env.latestFirmware(); latest != "" && p.firmware != "" && versionLess(p.firmware, latest) { + // A nudge, not a step: flashing over USB DFU needs the pod replugged afterwards, so the + // user runs it when it suits them. + fmt.Fprintf(out, " Firmware %s is out of date: %s is the latest. Update it with the pod on USB:\n"+ + " benchpod flash-self --enter-dfu\n"+ + " (unplug and replug USB-C when it finishes), or later from the web app's BenchPod page.\n", + strings.TrimPrefix(p.firmware, "v"), strings.TrimPrefix(latest, "v")) + } // 2. Network. step(2, "Network") @@ -653,3 +663,5 @@ func (e *realSetupEnv) saveLaMV(deviceID string, mv int) (bool, error) { } func (e *realSetupEnv) save(target string) error { return runSetConnection(e.g, target) } + +func (e *realSetupEnv) latestFirmware() string { return latestFirmwareRelease() } diff --git a/cmd/benchpod-cli/setup_test.go b/cmd/benchpod-cli/setup_test.go index bbaf4a4..d3a8d7f 100644 --- a/cmd/benchpod-cli/setup_test.go +++ b/cmd/benchpod-cli/setup_test.go @@ -24,6 +24,7 @@ type fakeSetupEnv struct { afterReg cloudState // cloud state after register profileMV int // la_mv in the stored wiring profile, 0 = none saveErr error // what saveLaMV fails with + latest string // latest firmware release, "" = unknown calls []string wifiSSID string @@ -89,6 +90,8 @@ func (f *fakeSetupEnv) save(target string) error { return nil } +func (f *fakeSetupEnv) latestFirmware() string { return f.latest } + func (f *fakeSetupEnv) did(call string) bool { for _, c := range f.calls { if c == call { @@ -386,3 +389,34 @@ func TestSetupSavesTheVoltageAfterRegistering(t *testing.T) { t.Fatalf("calls %v profile %d\n%s", env.calls, env.profileMV, out) } } + +func TestSetupNudgesAnOutdatedFirmware(t *testing.T) { + newEnv := func(fw, latest string) *fakeSetupEnv { + return &fakeSetupEnv{ + pods: []setupPod{{device: "/dev/cu.usbmodem1", addr: "192.168.1.221:8080", firmware: fw, cloud: registeredMine}}, + laMV: 3300, + cloud: registeredMine, + who: "you@example.com", + account: map[string]string{"dev-1": "benchpod-baea06"}, + latest: latest, + } + } + out, err := runSetupWith(t, newEnv("v3.6.0", "v3.8.0"), "", setupOpts{laVoltageMV: 3300, yes: true}) + if err != nil { + t.Fatalf("setup: %v\n%s", err, out) + } + for _, want := range []string{"Firmware 3.6.0 is out of date: 3.8.0 is the latest", "benchpod flash-self --enter-dfu"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q\n%s", want, out) + } + } + for _, env := range []*fakeSetupEnv{newEnv("v3.8.0", "v3.8.0"), newEnv("v3.6.0", "")} { + out, err := runSetupWith(t, env, "", setupOpts{laVoltageMV: 3300, yes: true}) + if err != nil { + t.Fatalf("setup: %v\n%s", err, out) + } + if strings.Contains(out, "out of date") { + t.Errorf("nudged without cause\n%s", out) + } + } +} diff --git a/internal/serialconsole/probe.go b/internal/serialconsole/probe.go index 880e806..199b046 100644 --- a/internal/serialconsole/probe.go +++ b/internal/serialconsole/probe.go @@ -32,6 +32,12 @@ type SerialPod struct { Registered bool CloudState string // "connected", "connecting", "backoff", ... when registered DeviceID string // the provisioned device id, when registered + + // PublicKey is the pod's Ed25519 identity (base64url), the same key the pod advertises as + // the mDNS TXT "id". It matches a USB pod to its network entry when the two report + // different addresses (USB shows the wired IP, mDNS may answer on Wi-Fi) and the pod is not + // registered yet, so there is no device id to match on. "" on firmware without `identity`. + PublicKey string } // Addressed reports whether the pod holds a usable IP address (it has a DHCP @@ -71,12 +77,19 @@ func ProbeSerial(preferred string, probeTimeout time.Duration) (pods []SerialPod ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) raw, _ := c.Status(ctx) cancel() - _ = c.Close() if !strings.Contains(strings.ToLower(raw), benchpodMarker) { + _ = c.Close() skipped = append(skipped, name) continue } pod := parseSerialPod(name, raw) + // Read-only: `identity` prints the pod's public key and changes nothing. + ctx, cancel = context.WithTimeout(context.Background(), probeTimeout) + if id, idErr := c.Identity(ctx); idErr == nil { + pod.PublicKey = id.PublicKey + } + cancel() + _ = c.Close() key := podIdentity(pod) if seen[key] { continue