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