From 900ed649d08be411f4a9fbd7e101094af10513d7 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:18:40 -0500 Subject: [PATCH 1/5] usb: la voltage, flash, wiring lookups and blob installs over the USB network Phase 2 of USB networking, ahead of trimming the firmware console: with a USB connection, the commands that work both ways use the pod's USB network adapter when it is up and the console otherwise. la voltage, flash (the DAP probe over TCP), the wiring profile lookup, and install-blobs (also the blob steps of set-wifi and flash-self), which now sends blobs with blob_status + ota_* through the USB network. Policies, the cloud CA/proxy, Wi-Fi and identity stay on the console. HW (macOS, baea06): la voltage --connection usb answers over 10.237.8.85; install-blobs --only gw1 --force through the USB network: 102 KB in 2.3 s, pod signature check ok. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- cmd/benchpod-cli/blobs.go | 19 ++-- cmd/benchpod-cli/blobs_target.go | 160 ++++++++++++++++++++++++++++ cmd/benchpod-cli/commands_serial.go | 10 +- cmd/benchpod-cli/flash.go | 2 + cmd/benchpod-cli/flash_self.go | 21 +++- cmd/benchpod-cli/lavoltage.go | 6 +- cmd/benchpod-cli/podclient.go | 5 +- cmd/benchpod-cli/spiflash.go | 2 +- cmd/benchpod-cli/status.go | 26 ++--- cmd/benchpod-cli/usbnet.go | 14 +++ 11 files changed, 242 insertions(+), 28 deletions(-) create mode 100644 cmd/benchpod-cli/blobs_target.go diff --git a/README.md b/README.md index 91ef4e5..cbbf68f 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Which command works over which connection: | Connection | Commands | |---------------------|----------| | Network address | Every pod command except the USB-only ones below. | -| USB | `status`, `la voltage`, `flash` (SWD), `lan-policy`, `sig-policy`, `cloud ca`, `cloud proxy` over the console; with firmware 3.9.0+ and no Wi-Fi configured, every other network command too, over the pod's USB network adapter. | +| USB | With firmware 3.9.0+ and no Wi-Fi configured, every network command over the pod's USB network adapter, including `status`, `la voltage`, `flash` (SWD) and `install-blobs`, which fall back to the console when the adapter is not up. `lan-policy`, `sig-policy`, `cloud ca` and `cloud proxy` always use the console (physical presence). | | `embeddedci:` | `status`, `ping`, `la voltage`, `la pullup`, `la status`, `la step`, `generate`, `lan-policy`, `sig-policy`, `cloud ca show`, `cloud ca clear`, `cloud proxy` (show, set, clear), `deregister`. | | USB only | `set-wifi`, `show-network`, `clear-wifi`, `identity`, `install-blobs`, `bootsel`, `dfu`, `usb-bridge`, `usb-reattach`; `flash-self` uses USB DFU. These always use the USB console: a saved address or `embeddedci:` default falls through to USB auto-detection, a device path still selects the port, and an explicit `--connection embeddedci:` is refused. | @@ -939,7 +939,8 @@ fit the pod's flash, the download's checksum does not match, or dfu-util fails. ### `benchpod install-blobs` -Install the gateware and ESP32-C3 images the pod's firmware goes with (USB). By +Install the gateware and ESP32-C3 images the pod's firmware goes with (USB: over the +pod's USB network adapter when it is up, firmware 3.9.0+, else its console). By default they come from the release that matches the firmware the pod runs. Each blob's signature is reported; for now a missing or failing one never stops it. diff --git a/cmd/benchpod-cli/blobs.go b/cmd/benchpod-cli/blobs.go index 1e83be7..506228a 100644 --- a/cmd/benchpod-cli/blobs.go +++ b/cmd/benchpod-cli/blobs.go @@ -137,7 +137,7 @@ func blobsToInstall(m blobsManifest, slots []serialconsole.BlobSlot, force bool) // installBlobs brings the pod's blob slots in line with the manifest at src, over the USB // console. only, when non-empty, limits it to those slot names. -func installBlobs(ctx context.Context, console *serialconsole.Console, src blobSource, force bool, only []string) error { +func installBlobs(ctx context.Context, console blobTarget, src blobSource, force bool, only []string) error { slots, err := console.Blobs(ctx) if err != nil { return err @@ -170,7 +170,7 @@ func installBlobs(ctx context.Context, console *serialconsole.Console, src blobS return err } manifest := checkBlobSignature(ctx, src, e, data) // report only - fmt.Fprintf(os.Stderr, "blobs: installing %s (%d KB) over USB...\n", e.Name, (e.Size+1023)/1024) + fmt.Fprintf(os.Stderr, "blobs: installing %s (%d KB) through %s...\n", e.Name, (e.Size+1023)/1024, console.Via()) last := -1 start := time.Now() rep, err := console.UploadSigned(ctx, e.Name, data, e.Version, manifest, func(done, total int) { @@ -211,7 +211,8 @@ func newInstallBlobsCmd(g *globalFlags) *cobra.Command { Short: "Install the gateware and ESP32-C3 images the pod's firmware goes with (over USB)", Long: "The pod keeps its iCE40 gateware images and its ESP32-C3 Wi-Fi image in the\n" + "W25Q flash next to the FPGA, not in its firmware. This sends the ones it lacks\n" + - "over the USB console. By default they come from the GitHub release that matches\n" + + "over USB: the pod's USB network adapter when it is up (firmware 3.9.0+), else its\n" + + "USB console. By default they come from the GitHub release that matches\n" + "the firmware the pod runs; --dir takes them from a local build (stm32h563/build/blobs).\n\n" + "Each blob's signature (blob-*.bin.sig) is checked and reported, and handed to\n" + "firmware that checks it too; for now a missing or failing one never stops an install.", @@ -227,20 +228,26 @@ func newInstallBlobsCmd(g *globalFlags) *cobra.Command { } defer cancel() defer console.Close() + // The pod's USB network when it is up (firmware 3.9.0+), else its console. + target, closeTarget, err := blobTargetFor(ctx, console, path) + if err != nil { + return err + } + defer closeTarget() src := blobSource{base: dir} if strings.TrimSpace(dir) == "" { tag := release if strings.TrimSpace(tag) == "" { - tag = podFirmwareRelease(ctx, console) + tag = target.Release(ctx) } src = releaseBlobSource(tag) } - fmt.Fprintf(os.Stderr, "blobs: pod on %s, blobs from %s\n", path, src) + fmt.Fprintf(os.Stderr, "blobs: pod on %s (through %s), blobs from %s\n", path, target.Via(), src) var sel []string if strings.TrimSpace(only) != "" { sel = strings.Split(only, ",") } - return installBlobs(ctx, console, src, force, sel) + return installBlobs(ctx, target, src, force, sel) }, } cmd.Flags().StringVar(&dir, "dir", "", "take the blobs from this directory (holding blobs-manifest.json)") diff --git a/cmd/benchpod-cli/blobs_target.go b/cmd/benchpod-cli/blobs_target.go new file mode 100644 index 0000000..b98240e --- /dev/null +++ b/cmd/benchpod-cli/blobs_target.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" +) + +// blobTarget is where install-blobs reads the pod's W25Q slots and sends blobs: the pod's network +// API (over its USB network adapter, firmware 3.9.0+: blob_status and ota_* with a target) or its +// USB console (blobs and upload-*, older firmware). +type blobTarget interface { + Blobs(ctx context.Context) ([]serialconsole.BlobSlot, error) + UploadSigned(ctx context.Context, target string, data []byte, version int, manifest []byte, + progress func(done, total int)) (serialconsole.SigReport, error) + // Release is the tag of the firmware the pod runs ("v3.9.0"), "" if unknown. + Release(ctx context.Context) string + // Via names the way in for messages: "/dev/cu.usbmodem1", "10.237.8.85:8080". + Via() string +} + +// consoleBlobs is the USB console. +type consoleBlobs struct { + *serialconsole.Console + path string +} + +func (c consoleBlobs) Release(ctx context.Context) string { return podFirmwareRelease(ctx, c.Console) } +func (c consoleBlobs) Via() string { return c.path } + +// lanBlobChunk is the raw bytes per ota_data line (as the LAN OTA in hwe2e sends). +const lanBlobChunk = 768 + +// lanBlobs is the pod's JSON API on one connection. +type lanBlobs struct { + lan lanSession + addr string +} + +func (l lanBlobs) Via() string { return l.addr } + +func (l lanBlobs) Release(ctx context.Context) string { + var st struct { + Version string `json:"version"` + } + if l.lan.cmd(ctx, map[string]any{"cmd": "status"}, &st) != nil || st.Version == "" { + return "" + } + if strings.HasPrefix(st.Version, "v") { + return st.Version + } + return "v" + st.Version +} + +func (l lanBlobs) Blobs(ctx context.Context) ([]serialconsole.BlobSlot, error) { + var rep struct { + Blobs []struct { + Name string `json:"name"` + State string `json:"state"` + Size int `json:"size"` + Version int `json:"version"` + SHA256 string `json:"sha256"` + Present bool `json:"present"` + } `json:"blobs"` + } + if err := l.lan.cmd(ctx, map[string]any{"cmd": "blob_status"}, &rep); err != nil { + if pe, ok := tcpclient.AsPodError(err); ok && strings.Contains(pe.Reason, "unknown cmd") { + return nil, serialconsole.ErrNoBlobSlots + } + return nil, err + } + slots := make([]serialconsole.BlobSlot, 0, len(rep.Blobs)) + for _, b := range rep.Blobs { + sha := b.SHA256 + if !b.Present { + sha = "" + } + slots = append(slots, serialconsole.BlobSlot{Name: b.Name, State: b.State, Size: b.Size, Version: b.Version, SHA256: sha}) + } + return slots, nil +} + +// otaSession is the pod's ota_* reply. +type otaSession struct { + State string `json:"state"` + Error string `json:"error"` + Sig string `json:"sig"` + SigKey string `json:"sig_key"` +} + +func (l lanBlobs) UploadSigned(ctx context.Context, target string, data []byte, version int, manifest []byte, + progress func(done, total int)) (serialconsole.SigReport, error) { + var rep serialconsole.SigReport + if len(data) == 0 { + return rep, fmt.Errorf("upload %s: empty image", target) + } + sum := sha256.Sum256(data) + begin := map[string]any{"cmd": "ota_begin", "size": len(data), "sha256": hex.EncodeToString(sum[:]), + "target": target, "version": version} + if len(manifest) == serialconsole.ManifestLen { + begin["sig"] = base64.RawURLEncoding.EncodeToString(manifest) + rep.Supported, rep.Sent = true, true + } + var st otaSession + if err := l.lan.cmd(ctx, begin, &st); err != nil { + return rep, fmt.Errorf("upload %s: begin: %w", target, err) + } + rep.Result, rep.KeyID = st.Sig, st.SigKey + abort := func() { + if !l.lan.s.Broken() { + _ = l.lan.cmd(ctx, map[string]any{"cmd": "ota_abort"}, nil) + } + } + for off := 0; off < len(data); off += lanBlobChunk { + end := min(off+lanBlobChunk, len(data)) + if err := l.lan.cmd(ctx, map[string]any{"cmd": "ota_data", "offset": off, + "data": base64.RawURLEncoding.EncodeToString(data[off:end])}, nil); err != nil { + abort() + return rep, fmt.Errorf("upload %s at %d: %w", target, off, err) + } + if progress != nil { + progress(end, len(data)) + } + } + if err := l.lan.cmd(ctx, map[string]any{"cmd": "ota_end"}, &st); err != nil { + abort() + return rep, fmt.Errorf("upload %s: end: %w", target, err) + } + if st.State != "verified" { + abort() + return rep, fmt.Errorf("upload %s: verify: %s", target, valueOr(st.Error, st.State)) + } + if err := l.lan.cmd(ctx, map[string]any{"cmd": "ota_commit"}, &st); err != nil { + return rep, fmt.Errorf("upload %s: commit: %w", target, err) + } + if st.State == "error" { + return rep, fmt.Errorf("upload %s: commit: %s", target, valueOr(st.Error, "failed")) + } + return rep, nil +} + +// blobTargetFor picks the pod's USB network over its console when it is up (the console's +// status names the address); the caller closes the returned closer. +func blobTargetFor(ctx context.Context, console *serialconsole.Console, path string) (blobTarget, func(), error) { + if u, err := console.USBNetwork(ctx); err == nil && u.On && u.Link { + addr := u.PodIP + ":8080" + if dialUSBPod(addr) == nil { + if lan, err := openLAN(ctx, addr); err == nil { + return lanBlobs{lan: lan, addr: addr}, func() { lan.s.Close() }, nil + } + } + } + return consoleBlobs{Console: console, path: path}, func() {}, nil +} diff --git a/cmd/benchpod-cli/commands_serial.go b/cmd/benchpod-cli/commands_serial.go index b50f73e..b61ba74 100644 --- a/cmd/benchpod-cli/commands_serial.go +++ b/cmd/benchpod-cli/commands_serial.go @@ -113,13 +113,19 @@ func runSetWifi(g *globalFlags, ssid, pw string, skipBlobs bool) (string, error) func ensureEspBlob(console *serialconsole.Console) error { ctx, cancel := context.WithTimeout(context.Background(), blobsInstallTimeout) defer cancel() - slots, err := console.Blobs(ctx) + // Before Wi-Fi is set the pod's USB network is still on (firmware 3.9.0+): use it when up. + target, closeTarget, err := blobTargetFor(ctx, console, "the USB console") + if err != nil { + return err + } + defer closeTarget() + slots, err := target.Blobs(ctx) missing, err := espBlobMissing(slots, err) if err != nil || !missing { return err } fmt.Fprintln(os.Stderr, "set-wifi: the pod has no ESP32-C3 image for Wi-Fi yet; installing it over USB first") - return installBlobs(ctx, console, releaseBlobSource(podFirmwareRelease(ctx, console)), false, nil) + return installBlobs(ctx, target, releaseBlobSource(target.Release(ctx)), false, nil) } // espBlobMissing reads the pod's blob listing for set-wifi: whether the ESP32-C3 image needs diff --git a/cmd/benchpod-cli/flash.go b/cmd/benchpod-cli/flash.go index b5b3d70..89aba7f 100644 --- a/cmd/benchpod-cli/flash.go +++ b/cmd/benchpod-cli/flash.go @@ -144,6 +144,7 @@ func runFlash(g *globalFlags, f *flashFlags) error { if err != nil { return err } + spec = g.preferUSBNetwork(spec, "flash") fromBuild := f.fromBuild() if fromBuild && strings.TrimSpace(f.file) != "" { return fmt.Errorf("pass either --file or an uploaded build (--build, --repo, --ref, --artifact), not both") @@ -352,6 +353,7 @@ func openDAP(ctx context.Context, g *globalFlags, swclk, swdio int, powerEfuse i if err != nil { return nil, false, err } + spec = g.preferUSBNetwork(spec, "flash") // the probe over the USB network is much faster if spec.IsNetwork() { client := &tcpclient.Client{Addr: spec.Addr} // Ask the pod whether it has the dedicated reset pin BEFORE arming the diff --git a/cmd/benchpod-cli/flash_self.go b/cmd/benchpod-cli/flash_self.go index 8609759..d62ba85 100644 --- a/cmd/benchpod-cli/flash_self.go +++ b/cmd/benchpod-cli/flash_self.go @@ -476,6 +476,9 @@ func checkImageFitsPod(ctx context.Context, dfuPath, file string) error { return nil } +// skipUSBNetForBlobs keeps installBlobsAfterFlash on the console (tests). +var skipUSBNetForBlobs = false + // Seams for tests: how long installBlobsAfterFlash waits for the flashed pod's USB console, // how often it tries, and how it opens it. var ( @@ -509,14 +512,28 @@ func installBlobsAfterFlash(g *globalFlags, src blobSource) error { defer console.Close() ctx, cancel := context.WithTimeout(context.Background(), blobsInstallTimeout) defer cancel() - if _, err := console.Blobs(ctx); err != nil { + target := blobTarget(consoleBlobs{Console: console, path: "the USB console"}) + if u, err := console.USBNetwork(ctx); err == nil && u.On && !skipUSBNetForBlobs { + // The freshly booted pod's USB network may need a few seconds on the computer's side. + for i := 0; i < 10; i++ { + if t, closeT, err := blobTargetFor(ctx, console, "the USB console"); err == nil { + if _, ok := t.(lanBlobs); ok { + target = t + defer closeT() + break + } + } + time.Sleep(time.Second) + } + } + if _, err := target.Blobs(ctx); err != nil { if errors.Is(err, serialconsole.ErrNoBlobSlots) { fmt.Fprintln(os.Stderr, "flash-self: this firmware keeps its blobs built in; nothing to install") return nil } return fmt.Errorf("flash-self: the firmware is flashed, but reading the pod's blob slots failed, so its blobs are not installed (retry with `benchpod install-blobs`): %w", err) } - if err := installBlobs(ctx, console, src, false, nil); err != nil { + if err := installBlobs(ctx, target, src, false, nil); err != nil { return fmt.Errorf("flash-self: the firmware is flashed, but installing its blobs failed (retry with `benchpod install-blobs`): %w", err) } return nil diff --git a/cmd/benchpod-cli/lavoltage.go b/cmd/benchpod-cli/lavoltage.go index b351742..b1ce7bb 100644 --- a/cmd/benchpod-cli/lavoltage.go +++ b/cmd/benchpod-cli/lavoltage.go @@ -118,7 +118,10 @@ func runLAVoltage(g *globalFlags, mv int, out, warn io.Writer) error { // cannot tell). It never prompts or fails. A variable so tests can replace it. var accountDeviceForConnection = func(g *globalFlags) string { spec, err := g.resolveTarget() - if err != nil || spec.IsSerial() { + if err != nil { + return "" + } + if spec = g.preferUSBNetwork(spec, "la voltage"); spec.IsSerial() { return "" } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -158,6 +161,7 @@ func laVoltageDo(g *globalFlags, mv int) (string, laVoltageReply, error) { if err != nil { return "", laVoltageReply{}, err } + spec = g.preferUSBNetwork(spec, what) var rep laVoltageReply if spec.IsSerial() { console, path, ctx, cancel, err := openLAVoltageConsole(g, spec.Device, g.effectiveTimeout(15*time.Second)) diff --git a/cmd/benchpod-cli/podclient.go b/cmd/benchpod-cli/podclient.go index 78b205d..0eec714 100644 --- a/cmd/benchpod-cli/podclient.go +++ b/cmd/benchpod-cli/podclient.go @@ -163,7 +163,10 @@ const wiringLookupTimeout = 6 * time.Second // fails (then it writes one warning line to warn). A variable so tests can replace it. var loadPodWiring = func(g *globalFlags, warn io.Writer) *podWiring { spec, err := g.resolveTarget() - if err != nil || spec.IsSerial() { + if err != nil { + return nil + } + if spec = g.preferUSBNetwork(spec, "wiring"); spec.IsSerial() { return nil } ctx, cancel := context.WithTimeout(context.Background(), wiringLookupTimeout) diff --git a/cmd/benchpod-cli/spiflash.go b/cmd/benchpod-cli/spiflash.go index 3a13397..f1a7436 100644 --- a/cmd/benchpod-cli/spiflash.go +++ b/cmd/benchpod-cli/spiflash.go @@ -71,7 +71,7 @@ func (f *spiFlags) fillFromWiring(g *globalFlags) { log.Printf("spi-flash: pins from the flags") return } - if spec, err := g.resolveTarget(); err != nil || !spec.IsNetwork() { + if spec, err := g.resolveTarget(); err != nil || !g.preferUSBNetwork(spec, "spi-flash").IsNetwork() { return // the connection error comes from the command itself } w := loadPodWiring(g, os.Stderr) diff --git a/cmd/benchpod-cli/status.go b/cmd/benchpod-cli/status.go index 86bc701..8792e7e 100644 --- a/cmd/benchpod-cli/status.go +++ b/cmd/benchpod-cli/status.go @@ -100,19 +100,19 @@ func runStatusSerial(g *globalFlags, device string) error { // podStatus is the part of the firmware's `status` reply the summary shows. type podStatus struct { - Version string `json:"version"` - Board string `json:"board"` - BoardRev string `json:"board_rev"` - Net string `json:"net"` - IP string `json:"ip"` - RSSI *int `json:"rssi_dbm"` - Wifi string `json:"wifi"` - Cloud string `json:"cloud"` - LAVccioMV int `json:"la_vccio_mv"` - Gateware int `json:"gateware"` - GatewareEmbedded int `json:"gateware_embedded"` - SafeMode bool `json:"safe_mode"` - SafeReason string `json:"safe_reason"` + Version string `json:"version"` + Board string `json:"board"` + BoardRev string `json:"board_rev"` + Net string `json:"net"` + IP string `json:"ip"` + RSSI *int `json:"rssi_dbm"` + Wifi string `json:"wifi"` + Cloud string `json:"cloud"` + LAVccioMV int `json:"la_vccio_mv"` + Gateware int `json:"gateware"` + GatewareEmbedded int `json:"gateware_embedded"` + SafeMode bool `json:"safe_mode"` + SafeReason string `json:"safe_reason"` USB *podUSB `json:"usb"` // firmware 3.9.0+ Lease *struct { Held bool `json:"held"` diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go index ad34236..1153c23 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -69,6 +69,20 @@ func (g *globalFlags) usbNetAddr(device string) (string, error) { return addr, nil } +// preferUSBNetwork turns a USB spec into the pod's address on its USB network adapter when that +// is up, for a command that works over both; any other spec, or a USB pod without a working USB +// network (older firmware, Wi-Fi configured, adapter not up), is returned unchanged and the command +// uses the console. +func (g *globalFlags) preferUSBNetwork(spec ConnSpec, cmd string) ConnSpec { + if !spec.IsSerial() { + return spec + } + if net, err := g.networkTarget(cmd); err == nil && net.IsNetwork() { + return net + } + return spec +} + // networkTarget is resolveTarget for a command that needs the pod's network API: a USB connection // becomes the pod's address on its USB network adapter, or the standard "needs the network" // error with the reason that is not possible. From 7299d8841910cd39ba66a8935b19ace679cfb0a4 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:39:45 -0500 Subject: [PATCH 2/5] usb: clear errors when a 3.9.0 console lacks la-voltage or power With the USB network unusable (Wi-Fi configured, adapter not up) the console fallback met a firmware that moved la-voltage and power to the JSON API, and la voltage said to update the firmware. Say what happened and why the USB network did not work instead. Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/flash.go | 5 +++++ cmd/benchpod-cli/lavoltage.go | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/cmd/benchpod-cli/flash.go b/cmd/benchpod-cli/flash.go index 89aba7f..e93c438 100644 --- a/cmd/benchpod-cli/flash.go +++ b/cmd/benchpod-cli/flash.go @@ -399,6 +399,11 @@ func openDAP(ctx context.Context, g *globalFlags, swclk, swdio int, powerEfuse i if powerEfuse != 0 { if err := console.TargetPower(ctx, powerEfuse, true); err != nil { console.Close() + if errors.Is(err, serialconsole.ErrUnknownCommand) { + return nil, false, fmt.Errorf("flash: this pod's console has no power command (firmware 3.9.0+ switches "+ + "target power over the network API) and its USB network is not usable; drop --target-power, "+ + "or connect over the network") + } return nil, false, fmt.Errorf("flash: enable target-power eFuse %d over %s: %w", powerEfuse, path, err) } fmt.Fprintf(os.Stderr, "flash: target-power eFuse %d on\n", powerEfuse) diff --git a/cmd/benchpod-cli/lavoltage.go b/cmd/benchpod-cli/lavoltage.go index b1ce7bb..ec2a9e9 100644 --- a/cmd/benchpod-cli/lavoltage.go +++ b/cmd/benchpod-cli/lavoltage.go @@ -171,6 +171,13 @@ func laVoltageDo(g *globalFlags, mv int) (string, laVoltageReply, error) { defer cancel() defer console.Close() rep.MV, err = console.LAVoltage(ctx, mv) + if errors.Is(err, serialconsole.ErrUnknownCommand) { + // Firmware 3.9.0+ moved la-voltage from the console to the JSON API; preferUSBNetwork + // already found the USB network unusable, so say why. + _, nerr := g.networkTarget(what) + return "", rep, fmt.Errorf("%s: this pod's console has no la-voltage (firmware 3.9.0+ sets it over the "+ + "network API) and its USB network is not usable: %v", what, nerr) + } if err != nil { return "", rep, laVoltageError(what, err) } From 3ebe63367963827043033b2609c497ae4a32ce2e Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:39:55 -0500 Subject: [PATCH 3/5] flash: import serialconsole (build fix for 7299d88) Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/flash.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/benchpod-cli/flash.go b/cmd/benchpod-cli/flash.go index e93c438..989ce1c 100644 --- a/cmd/benchpod-cli/flash.go +++ b/cmd/benchpod-cli/flash.go @@ -11,6 +11,7 @@ import ( "time" "github.com/embeddedci-com/benchpod-cli/internal/openocd" + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" "github.com/embeddedci-com/benchpod-cli/internal/serverapi" "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" "github.com/spf13/cobra" From 09ebe590488b395b1e8485261e0c09cd9aa06611 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:40:25 -0500 Subject: [PATCH 4/5] la voltage: keep the update-firmware message for consoles without a USB network A console that answers unknown command to la-voltage is either too old to have it or 3.9.0+ (which moved it to the JSON API); only the latter reports a USB network. Also gofmt flash.go. Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/flash.go | 4 ++-- cmd/benchpod-cli/lavoltage.go | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cmd/benchpod-cli/flash.go b/cmd/benchpod-cli/flash.go index 989ce1c..99e078d 100644 --- a/cmd/benchpod-cli/flash.go +++ b/cmd/benchpod-cli/flash.go @@ -401,8 +401,8 @@ func openDAP(ctx context.Context, g *globalFlags, swclk, swdio int, powerEfuse i if err := console.TargetPower(ctx, powerEfuse, true); err != nil { console.Close() if errors.Is(err, serialconsole.ErrUnknownCommand) { - return nil, false, fmt.Errorf("flash: this pod's console has no power command (firmware 3.9.0+ switches "+ - "target power over the network API) and its USB network is not usable; drop --target-power, "+ + return nil, false, fmt.Errorf("flash: this pod's console has no power command (firmware 3.9.0+ switches " + + "target power over the network API) and its USB network is not usable; drop --target-power, " + "or connect over the network") } return nil, false, fmt.Errorf("flash: enable target-power eFuse %d over %s: %w", powerEfuse, path, err) diff --git a/cmd/benchpod-cli/lavoltage.go b/cmd/benchpod-cli/lavoltage.go index ec2a9e9..85e3b3c 100644 --- a/cmd/benchpod-cli/lavoltage.go +++ b/cmd/benchpod-cli/lavoltage.go @@ -171,7 +171,7 @@ func laVoltageDo(g *globalFlags, mv int) (string, laVoltageReply, error) { defer cancel() defer console.Close() rep.MV, err = console.LAVoltage(ctx, mv) - if errors.Is(err, serialconsole.ErrUnknownCommand) { + if errors.Is(err, serialconsole.ErrUnknownCommand) && consoleHasUSBNet(ctx, console) { // Firmware 3.9.0+ moved la-voltage from the console to the JSON API; preferUSBNetwork // already found the USB network unusable, so say why. _, nerr := g.networkTarget(what) @@ -226,6 +226,19 @@ func laVoltageError(what string, err error) error { return fmt.Errorf("%s: %w", what, err) } +// consoleHasUSBNet: the pod's console reports a USB network adapter (firmware 3.9.0+), which +// tells a console that moved a command to the JSON API from one too old to have it. +func consoleHasUSBNet(ctx context.Context, c laVoltageConsole) bool { + u, ok := c.(interface { + USBNetwork(context.Context) (serialconsole.USBNet, error) + }) + if !ok { + return false + } + _, err := u.USBNetwork(ctx) + return err == nil +} + // formatMV is 3300 -> "3.3 V". func formatMV(mv int) string { return strings.TrimSuffix(strings.TrimRight(fmt.Sprintf("%.3f", float64(mv)/1000), "0"), ".") + " V" From 62a0eaa86daee435f56948aeca6d0b6cc089b5e6 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:45:48 -0500 Subject: [PATCH 5/5] usb: recognize the 3.9.0 console's moved reply; tests across firmware generations Firmware 3.9.0 (benchpod-firmware#48) answers la-voltage, power and the other commands it moved to the JSON API with a moved-to-the-network-API line instead of unknown command. The console client reports it as ErrMovedToNetwork, which la voltage and flash turn into the reason the USB network was not usable. Tests: la-voltage and power against an older and a 3.9.0 console, the la voltage message for each, and install-blobs staying on the console for firmware before 3.9.0 and for a pod with Wi-Fi set. Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/blobs_target_test.go | 63 ++++++++++++++++++++++++ cmd/benchpod-cli/flash.go | 2 +- cmd/benchpod-cli/lavoltage.go | 3 +- cmd/benchpod-cli/lavoltage_test.go | 6 +++ internal/serialconsole/fake.go | 7 +++ internal/serialconsole/lavoltage.go | 3 ++ internal/serialconsole/lavoltage_test.go | 15 ++++++ internal/serialconsole/power_test.go | 12 +++++ internal/serialconsole/serialconsole.go | 2 + internal/serialconsole/usbnet.go | 7 +++ 10 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 cmd/benchpod-cli/blobs_target_test.go create mode 100644 internal/serialconsole/fake.go diff --git a/cmd/benchpod-cli/blobs_target_test.go b/cmd/benchpod-cli/blobs_target_test.go new file mode 100644 index 0000000..26f36a4 --- /dev/null +++ b/cmd/benchpod-cli/blobs_target_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" +) + +// textConsole answers `status` with the given lines and a prompt, like the firmware's console. +type textConsole struct { + status string + out bytes.Buffer +} + +func (c *textConsole) Read(p []byte) (int, error) { + if c.out.Len() == 0 { + time.Sleep(5 * time.Millisecond) + return 0, nil + } + return c.out.Read(p) +} + +func (c *textConsole) Write(p []byte) (int, error) { + line := strings.TrimSpace(string(bytes.ReplaceAll(p, []byte{0x08}, nil))) + if line != "" { + c.out.WriteString(line + "\r\n") + if line == "status" { + c.out.WriteString(c.status) + } + c.out.WriteString("> ") + } + return len(p), nil +} + +func (c *textConsole) Close() error { return nil } + +var _ io.ReadWriteCloser = (*textConsole)(nil) + +// New CLI + firmware without a usable USB network: install-blobs keeps today's console path. +func TestBlobTargetFallsBackToTheConsole(t *testing.T) { + cases := map[string]string{ + "firmware before 3.9.0": " device : benchpod\r\n ip : 192.168.1.220\r\n", + "Wi-Fi configured": " device : benchpod\r\n usb : net off (Wi-Fi configured) pod 10.237.8.85 host 10.237.8.86 link down rx 0 (drop 0) tx 0 (full 0)\r\n", + } + for name, status := range cases { + c := serialconsole.NewConsole(&textConsole{status: status}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + target, closeT, err := blobTargetFor(ctx, c, "/dev/cu.usbmodem1") + cancel() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + closeT() + if _, ok := target.(consoleBlobs); !ok || target.Via() != "/dev/cu.usbmodem1" { + t.Fatalf("%s: target %T via %s, want the console", name, target, target.Via()) + } + } +} diff --git a/cmd/benchpod-cli/flash.go b/cmd/benchpod-cli/flash.go index 99e078d..60804ff 100644 --- a/cmd/benchpod-cli/flash.go +++ b/cmd/benchpod-cli/flash.go @@ -400,7 +400,7 @@ func openDAP(ctx context.Context, g *globalFlags, swclk, swdio int, powerEfuse i if powerEfuse != 0 { if err := console.TargetPower(ctx, powerEfuse, true); err != nil { console.Close() - if errors.Is(err, serialconsole.ErrUnknownCommand) { + if errors.Is(err, serialconsole.ErrMovedToNetwork) || errors.Is(err, serialconsole.ErrUnknownCommand) { return nil, false, fmt.Errorf("flash: this pod's console has no power command (firmware 3.9.0+ switches " + "target power over the network API) and its USB network is not usable; drop --target-power, " + "or connect over the network") diff --git a/cmd/benchpod-cli/lavoltage.go b/cmd/benchpod-cli/lavoltage.go index 85e3b3c..9a8dffd 100644 --- a/cmd/benchpod-cli/lavoltage.go +++ b/cmd/benchpod-cli/lavoltage.go @@ -171,7 +171,8 @@ func laVoltageDo(g *globalFlags, mv int) (string, laVoltageReply, error) { defer cancel() defer console.Close() rep.MV, err = console.LAVoltage(ctx, mv) - if errors.Is(err, serialconsole.ErrUnknownCommand) && consoleHasUSBNet(ctx, console) { + if errors.Is(err, serialconsole.ErrMovedToNetwork) || + (errors.Is(err, serialconsole.ErrUnknownCommand) && consoleHasUSBNet(ctx, console)) { // Firmware 3.9.0+ moved la-voltage from the console to the JSON API; preferUSBNetwork // already found the USB network unusable, so say why. _, nerr := g.networkTarget(what) diff --git a/cmd/benchpod-cli/lavoltage_test.go b/cmd/benchpod-cli/lavoltage_test.go index fce6caf..fbee24b 100644 --- a/cmd/benchpod-cli/lavoltage_test.go +++ b/cmd/benchpod-cli/lavoltage_test.go @@ -156,6 +156,12 @@ func TestLAVoltageUSB(t *testing.T) { if err := runLAVoltage(&globalFlags{connection: "usb"}, 0, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || err.Error() != laVoltageMissing { t.Fatalf("old firmware: %v", err) } + // A 3.9.0 console moved la-voltage to the network API, and the USB network was not usable. + fc.err = serialconsole.ErrMovedToNetwork + if err := runLAVoltage(&globalFlags{connection: "usb"}, 0, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || + !strings.Contains(err.Error(), "network API") || !strings.Contains(err.Error(), "USB network is not usable") { + t.Fatalf("3.9.0 console without a usable USB network: %v", err) + } } func TestLAVoltageArgIsAUsageError(t *testing.T) { diff --git a/internal/serialconsole/fake.go b/internal/serialconsole/fake.go new file mode 100644 index 0000000..7228855 --- /dev/null +++ b/internal/serialconsole/fake.go @@ -0,0 +1,7 @@ +package serialconsole + +import "io" + +// NewConsole wraps an already-open port (or a test fake answering like the firmware) as a +// Console. The CLI opens real ports with Open/OpenBenchpod; this is for tests outside the package. +func NewConsole(rw io.ReadWriteCloser) *Console { return newConsole(rw) } diff --git a/internal/serialconsole/lavoltage.go b/internal/serialconsole/lavoltage.go index eee7bd3..8578c8e 100644 --- a/internal/serialconsole/lavoltage.go +++ b/internal/serialconsole/lavoltage.go @@ -52,6 +52,9 @@ func parseLAVoltageReply(raw string) (int, error) { s = strings.ReplaceAll(s, "\r", "\n") for _, ln := range strings.Split(s, "\n") { ln = strings.TrimSpace(strings.TrimLeft(ln, "> \t\x08")) + if strings.Contains(ln, movedLine) { + return 0, ErrMovedToNetwork + } if strings.Contains(ln, "unknown command") { return 0, ErrUnknownCommand } diff --git a/internal/serialconsole/lavoltage_test.go b/internal/serialconsole/lavoltage_test.go index 8aa8d62..c2feb2f 100644 --- a/internal/serialconsole/lavoltage_test.go +++ b/internal/serialconsole/lavoltage_test.go @@ -16,6 +16,7 @@ type laVoltagePod struct { v2 bool // a v2 board: no 1.8 V inUse string // pins in use: a change is refused old bool + moved bool // firmware 3.9.0+: la-voltage moved to the JSON API writes []string } @@ -27,6 +28,8 @@ func (p *laVoltagePod) console() *Console { switch { case p.old: fmt.Fprintf(out, " unknown command '%s' (try 'help')\r\n", f[0]) + case p.moved: + fmt.Fprintf(out, "%s: moved to the network API (JSON la_voltage) in firmware 3.9.0; update the benchpod CLI to 0.6.0 or later (or embeddedci to 2.12.0), which uses it over USB\r\n", f[0]) case len(f) == 1 && p.mv == 0: out.WriteString(" LA VCCIO = UNSET (st=0)\r\n") case len(f) == 1: @@ -78,3 +81,15 @@ func TestLAVoltageRefusals(t *testing.T) { t.Fatalf("old firmware: %v", err) } } + +// Firmware 3.9.0 answers la-voltage with the moved line; firmware before it runs the command. +func TestLAVoltageAcrossFirmwareGenerations(t *testing.T) { + _, err := (&laVoltagePod{moved: true}).console().LAVoltage(testContext(t), 3300) + if !errors.Is(err, ErrMovedToNetwork) { + t.Fatalf("3.9.0 console: err = %v, want ErrMovedToNetwork", err) + } + got, err := (&laVoltagePod{}).console().LAVoltage(testContext(t), 3300) + if err != nil || got != 3300 { + t.Fatalf("older firmware: %d, %v", got, err) + } +} diff --git a/internal/serialconsole/power_test.go b/internal/serialconsole/power_test.go index ae7110d..a0a7a6d 100644 --- a/internal/serialconsole/power_test.go +++ b/internal/serialconsole/power_test.go @@ -13,6 +13,7 @@ import ( // powerPod answers `power` like the firmware's console (console.c). type powerPod struct { old bool + moved bool // firmware 3.9.0+: power moved to the JSON API on [3]bool writes []string } @@ -23,6 +24,8 @@ func (p *powerPod) console() *Console { out.WriteString(line + "\r\n[net] dhcp renew\r\n") f := strings.Fields(line) switch { + case p.moved: + fmt.Fprintf(out, "%s: moved to the network API (JSON target_power) in firmware 3.9.0; update the benchpod CLI to 0.6.0 or later (or embeddedci to 2.12.0), which uses it over USB\r\n", f[0]) case p.old || f[0] != "power" || len(f) < 3: fmt.Fprintf(out, " unknown command '%s' (try 'help')\r\n", f[0]) case f[1] != "1" && f[1] != "2": @@ -66,3 +69,12 @@ func TestTargetPowerRefusals(t *testing.T) { t.Fatalf("old firmware: %v", err) } } + +func TestTargetPowerAcrossFirmwareGenerations(t *testing.T) { + if err := (&powerPod{moved: true}).console().TargetPower(testContext(t), 1, true); !errors.Is(err, ErrMovedToNetwork) { + t.Fatalf("3.9.0 console: err = %v, want ErrMovedToNetwork", err) + } + if err := (&powerPod{}).console().TargetPower(testContext(t), 1, true); err != nil { + t.Fatalf("older firmware: %v", err) + } +} diff --git a/internal/serialconsole/serialconsole.go b/internal/serialconsole/serialconsole.go index 357a75c..1ef0f64 100644 --- a/internal/serialconsole/serialconsole.go +++ b/internal/serialconsole/serialconsole.go @@ -557,6 +557,8 @@ func parsePowerReply(raw string, efuse int, want string) error { for _, ln := range strings.Split(s, "\n") { ln = strings.TrimSpace(strings.TrimLeft(ln, "> \t\x08")) switch { + case strings.Contains(ln, movedLine): + return ErrMovedToNetwork case strings.Contains(ln, "unknown command"): return ErrUnknownCommand case ln == ok: diff --git a/internal/serialconsole/usbnet.go b/internal/serialconsole/usbnet.go index 122c595..835b9b8 100644 --- a/internal/serialconsole/usbnet.go +++ b/internal/serialconsole/usbnet.go @@ -22,6 +22,13 @@ type USBNet struct { HostIP string } +// ErrMovedToNetwork is a console command firmware 3.9.0 moved to the JSON API: the console answers +// ": moved to the network API (JSON ) ..." instead of running it. +var ErrMovedToNetwork = errors.New("firmware 3.9.0 moved this command from the USB console to the network API") + +// movedLine is the marker of that answer. +const movedLine = "moved to the network API" + // ErrNoUSBNet means the pod's console reports no USB network adapter: firmware older than 3.9.0. var ErrNoUSBNet = errors.New("the pod's firmware has no USB network adapter (needs firmware 3.9.0 or later)")