From d76ae18f7bc2bfa5f29dd326060b8ed3250e2540 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 20:40:43 -0500 Subject: [PATCH 01/29] usb: network commands over the pod's USB network adapter; usb-bridge, usb-reattach Firmware 3.9.0 makes the pod's USB device also a network adapter with the full network API on a fixed 10.237.x.y address. A command that needs the network and gets a USB connection (usb or a device path) now reads that address from the console's status, checks the pod answers there and uses it; when it cannot (old firmware, Wi-Fi configured, adapter not up) the error says why. The console stays for the commands it runs. The serial auto-detect ranks the new VID 1209 (1209:0001) with 2E8A and 0483. usb-bridge runs an HTTP CONNECT proxy on the computer's end of the USB link (serving only the pod, tunnelling only to port 443), sets it as the pod's cloud proxy and clears it on Ctrl-C. usb-reattach re-enumerates the pod on USB (macOS ignores a new adapter under a locked screen). README updated. Co-Authored-By: Claude Opus 5.5 --- README.md | 53 +++- cmd/benchpod-cli/commands_deregister.go | 2 +- cmd/benchpod-cli/commands_register.go | 2 +- cmd/benchpod-cli/commands_serial.go | 3 +- cmd/benchpod-cli/connection.go | 6 +- cmd/benchpod-cli/helpers.go | 2 +- cmd/benchpod-cli/podclient.go | 7 +- cmd/benchpod-cli/podclient_test.go | 6 + cmd/benchpod-cli/root.go | 14 +- cmd/benchpod-cli/usbbridge.go | 275 +++++++++++++++++++ cmd/benchpod-cli/usbbridge_test.go | 135 +++++++++ cmd/benchpod-cli/usbnet.go | 81 ++++++ cmd/benchpod-cli/usbnet_test.go | 71 +++++ internal/serialconsole/serialconsole.go | 13 +- internal/serialconsole/serialconsole_test.go | 2 +- internal/serialconsole/usbnet.go | 80 ++++++ internal/serialconsole/usbnet_test.go | 30 ++ 17 files changed, 753 insertions(+), 29 deletions(-) create mode 100644 cmd/benchpod-cli/usbbridge.go create mode 100644 cmd/benchpod-cli/usbbridge_test.go create mode 100644 cmd/benchpod-cli/usbnet.go create mode 100644 cmd/benchpod-cli/usbnet_test.go create mode 100644 internal/serialconsole/usbnet.go create mode 100644 internal/serialconsole/usbnet_test.go diff --git a/README.md b/README.md index e525155..df267e6 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ reach the pod; the transport is inferred from its value: | `--connection` value | Transport | |----------------------------------|--------------------------------------------------------| | `192.168.1.5[:8080]` | The pod's network TCP/JSON API (Ethernet or Wi-Fi). | -| `/dev/tty...`, `COM3` | USB console, explicit device path. | -| `usb` | USB console, auto-detected by probing the ports. | +| `/dev/tty...`, `COM3` | USB, explicit console device path. | +| `usb` | USB, the console auto-detected by probing the ports. | | `embeddedci:` | Through embeddedci.com to the pod with that name on your account (see below). | | *(omitted)* | the default saved by `benchpod discover --save`, `benchpod setup` or `benchpod set-connection`. | @@ -115,14 +115,25 @@ The firmware itself is unauthenticated; `benchpod login` is independent of the device path and authenticates with `embeddedci-server` (device-login flow) for cloud features. Direct firmware commands on the network or USB do not send tokens. +**USB networking (firmware 3.9.0+).** The pod's USB device is also a network +adapter that carries the full network API on a fixed address (`10.237.x.y`, its own +point-to-point link: the computer keeps its default route and DNS). With a USB +connection (`usb` or a device path), a command that needs the network uses that +adapter, so the whole CLI works over one USB cable; the console is still used for +the commands it runs (below) and as the fallback. USB networking is off while Wi-Fi +is configured on the pod (`set-wifi` turns it off, `clear-wifi` back on). If a +command says the adapter is not up, unlock the screen (macOS ignores a new adapter +while it is locked) and run `benchpod usb-reattach`. `benchpod usb-bridge` carries +the pod's cloud link over the same cable. + 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`. | +| 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. | | `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`; `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. | +| 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. | A command that cannot use the connection it is given says so and names what to use instead. @@ -241,6 +252,11 @@ benchpod set-wifi benchpod show-network # the pod's IP (wired lease included), SSID, state, RSSI benchpod clear-wifi +# One USB cable (firmware 3.9.0+, no Wi-Fi configured): +benchpod capture --connection usb ... # network commands use the pod's USB network adapter +benchpod usb-bridge # the pod's cloud link through this computer (Ctrl-C stops it) +benchpod usb-reattach # re-enumerate on USB (macOS: after unlocking the screen) + # Firmware: benchpod flash ... # flash a TARGET/DUT wired to the pod (SWD via OpenOCD CMSIS-DAP; --build: an uploaded build) benchpod spi-flash ... # read/erase/program an SPI NOR flash on four LA pins (network) @@ -928,7 +944,7 @@ Reboot a (retired) RP2350 pod into its UF2 bootloader (USB). ### `benchpod set-wifi` -Save Wi-Fi credentials and join (USB). Installs the ESP32-C3 image first when the +Save Wi-Fi credentials and join (USB). With firmware 3.9.0+ this turns the pod's USB networking off (Wi-Fi and USB networking are one or the other; `clear-wifi` turns it back on). Installs the ESP32-C3 image first when the pod lacks it; if the pod's image slots cannot be read, it stops and says so (`--skip-blobs` sets Wi-Fi without the check). @@ -946,7 +962,32 @@ The pod's IP (the wired lease first) plus the stored Wi-Fi SSID, state and RSSI ### `benchpod clear-wifi` -Erase the stored Wi-Fi credentials (USB). Wi-Fi goes down at once, no reboot needed. Firmware after 3.7.0 also erases the copy older firmware left on the ESP32-C3 Wi-Fi chip, which takes a few seconds more. No flags. +Erase the stored Wi-Fi credentials (USB). Wi-Fi goes down at once, no reboot needed, and USB networking (firmware 3.9.0+) comes back on. Firmware after 3.7.0 also erases the copy older firmware left on the ESP32-C3 Wi-Fi chip, which takes a few seconds more. No flags. + +### `benchpod usb-bridge` + +Carry the pod's cloud link over USB through this computer, until Ctrl-C (USB). The +pod's USB network adapter has no router, so the pod cannot reach embeddedci.com over +it by itself. The bridge runs an HTTP CONNECT proxy on this computer's end of the +USB link and sets it as the pod's cloud proxy; the pod then connects to +embeddedci.com through this computer's internet connection, with no Ethernet or +Wi-Fi. When it stops it clears the pod's proxy again. The proxy serves only the pod, +and only tunnels to port 443. It refuses to replace a proxy the pod already has +(`benchpod cloud proxy clear --connection usb` removes it). Needs firmware 3.9.0 or +later and no Wi-Fi configured on the pod. + +| Flag | Default | Purpose | +|----------------|---------|------------------------------------------------------| +| `--port` | `3128` | TCP port of the proxy on this computer's USB address. | +| `--keep-proxy` | off | Leave the pod's cloud proxy set when the bridge stops. | + +### `benchpod usb-reattach` + +Make the pod drop off the USB bus for half a second and connect again (USB). The +computer then sets up the console and the USB network adapter anew, as after +replugging the cable. macOS ignores a new network adapter while the screen is +locked: run this after unlocking when the pod was plugged in or rebooted meanwhile. +Needs firmware 3.9.0 or later. No flags. ### `benchpod login` diff --git a/cmd/benchpod-cli/commands_deregister.go b/cmd/benchpod-cli/commands_deregister.go index 3fb2d93..eece83a 100644 --- a/cmd/benchpod-cli/commands_deregister.go +++ b/cmd/benchpod-cli/commands_deregister.go @@ -70,7 +70,7 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st var client *tcpclient.Client if usePod { - spec, err := g.resolveTarget() + spec, err := g.networkTarget("deregister") if err != nil { return err } diff --git a/cmd/benchpod-cli/commands_register.go b/cmd/benchpod-cli/commands_register.go index 6b5e776..c2393ef 100644 --- a/cmd/benchpod-cli/commands_register.go +++ b/cmd/benchpod-cli/commands_register.go @@ -112,7 +112,7 @@ func runRegister(g *globalFlags, opts registerOptions) error { host = podHostFor(host, opts.podHost) // Verify the server cert by default when using TLS; --insecure-skip-verify opts out for bring-up. verify := tls && !opts.insecureSkipVerify - spec, err := g.resolveTarget() + spec, err := g.networkTarget("register") if err != nil { return err } diff --git a/cmd/benchpod-cli/commands_serial.go b/cmd/benchpod-cli/commands_serial.go index e3f4efc..b50f73e 100644 --- a/cmd/benchpod-cli/commands_serial.go +++ b/cmd/benchpod-cli/commands_serial.go @@ -210,7 +210,8 @@ func newClearWifiCmd(g *globalFlags) *cobra.Command { if err := console.WifiClear(ctx); err != nil { return fmt.Errorf("clear-wifi: %w", err) } - fmt.Fprintln(os.Stderr, "Erased the stored Wi-Fi credentials. Wi-Fi is off; no reboot needed.") + fmt.Fprintln(os.Stderr, "Erased the stored Wi-Fi credentials. Wi-Fi is off; no reboot needed. "+ + "USB networking is on again (firmware 3.9.0+).") return nil }, } diff --git a/cmd/benchpod-cli/connection.go b/cmd/benchpod-cli/connection.go index 8515ee3..4fe6e69 100644 --- a/cmd/benchpod-cli/connection.go +++ b/cmd/benchpod-cli/connection.go @@ -132,9 +132,9 @@ func (c ConnSpec) RequireNetwork(cmd string) error { if c.IsCloud() { return cloudRefusal(cmd, c, false) } - return fmt.Errorf("%s needs the pod on the network; it is not available over a USB connection. "+ - "Run `benchpod discover` to find the pod's address, then pass --connection
. "+ - "If it has no address yet, plug in Ethernet or run `benchpod set-wifi --ssid `", cmd) + return fmt.Errorf("%s needs the pod's network API, which a USB connection reaches only through the pod's USB "+ + "network adapter (firmware 3.9.0 or later, with no Wi-Fi configured). "+ + "Or run `benchpod discover` to find the pod's network address, then pass --connection
", cmd) } // cloudRefusal is the one message for a command that cannot go through embeddedci.com: it streams diff --git a/cmd/benchpod-cli/helpers.go b/cmd/benchpod-cli/helpers.go index 5e59f3f..9b7709d 100644 --- a/cmd/benchpod-cli/helpers.go +++ b/cmd/benchpod-cli/helpers.go @@ -109,7 +109,7 @@ func (g *globalFlags) usbDevice(cmd string) (string, error) { // handling. It is for the commands that need the pod's own port: chunked replies and raw streams. // Single-reply commands use podClient, which also goes through embeddedci.com. func (g *globalFlags) networkClient(cmdName string, def time.Duration) (context.Context, context.CancelFunc, *tcpclient.Client, error) { - spec, err := g.resolveTarget() + spec, err := g.networkTarget(cmdName) if err != nil { return nil, func() {}, nil, err } diff --git a/cmd/benchpod-cli/podclient.go b/cmd/benchpod-cli/podclient.go index 4e0761d..78b205d 100644 --- a/cmd/benchpod-cli/podclient.go +++ b/cmd/benchpod-cli/podclient.go @@ -45,15 +45,12 @@ type podTarget struct { // podClient resolves the connection for a single-reply JSON command: the pod's network address, // or embeddedci: through embeddedci.com. A USB connection gets the standard "needs the -// network" error. It returns a deadline context (overridable by --timeout) with signal handling. +// network" error unless the pod's USB network adapter is up. It returns a deadline context (overridable by --timeout) with signal handling. func (g *globalFlags) podClient(cmdName string, def time.Duration) (context.Context, context.CancelFunc, *podTarget, error) { - spec, err := g.resolveTarget() + spec, err := g.networkTarget(cmdName) if err != nil { return nil, func() {}, nil, err } - if spec.IsSerial() { - return nil, func() {}, nil, spec.RequireNetwork(cmdName) - } ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(def)) cancel = withSignalCleanup(cancel, installSignalHandler(ctx, cancel)) t, err := openPodTarget(ctx, spec) diff --git a/cmd/benchpod-cli/podclient_test.go b/cmd/benchpod-cli/podclient_test.go index 639209c..4935a9a 100644 --- a/cmd/benchpod-cli/podclient_test.go +++ b/cmd/benchpod-cli/podclient_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "encoding/json" + "errors" "fmt" "io" "net" @@ -16,6 +17,7 @@ import ( "testing" "github.com/embeddedci-com/benchpod-cli/internal/benchpodconfig" + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" ) // TestMain keeps every test off the real embeddedci.com session and config: the CLI's files go @@ -29,6 +31,10 @@ func TestMain(m *testing.M) { for _, k := range []string{"BENCHPOD_API_KEY", "BENCHPOD_API_BASE", "BENCHPOD_CONNECTION", "BENCHPOD_CONFIG_FILE"} { os.Unsetenv(k) } + // Never probe this machine's USB ports for a pod's USB network adapter. + usbNetwork = func(*globalFlags, string) (serialconsole.USBNet, error) { + return serialconsole.USBNet{}, errors.New("no pod on USB (tests)") + } code := m.Run() os.RemoveAll(dir) os.Exit(code) diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index 93771a9..b8906c6 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -48,17 +48,20 @@ func newRootCmd() *cobra.Command { "the board I/O voltage, registers it with embeddedci.com and saves the connection.\n\n" + "--connection says where and how to reach the pod; the transport follows from\n" + "its value. An address (192.168.1.5[:8080]) uses the pod's network (TCP/JSON) API;\n" + - "a device path (/dev/tty..., COM3) or `usb` uses the pod's USB console;\n" + + "a device path (/dev/tty..., COM3) or `usb` uses the pod's USB console, and its\n" + + "USB network adapter for the network commands (firmware 3.9.0+, no Wi-Fi set);\n" + "embeddedci: goes through embeddedci.com to the pod with that name on your\n" + "account (signed in with `benchpod login`, or BENCHPOD_API_KEY). Omit it to use the\n" + "default saved by `benchpod discover --save` or `benchpod set-connection`.\n\n" + - "Over the network: every pod command but the USB-only ones below. Over USB:\n" + - "status, la voltage, flash (SWD), lan-policy, sig-policy, cloud ca and cloud proxy.\n" + + "Over the network: every pod command but the USB-only ones below. Over the USB\n" + + "console: status, la voltage, flash (SWD), lan-policy, sig-policy, cloud ca and\n" + + "cloud proxy; the rest over the pod's USB network adapter when it is up.\n" + "Through embeddedci.com: status, ping, la voltage, la pullup, la status, la step,\n" + "generate, lan-policy, sig-policy, cloud ca show/clear, cloud proxy and deregister.\n" + "Always over the USB console: set-wifi, show-network, clear-wifi, identity,\n" + - "install-blobs, bootsel and dfu; flash-self reflashes the pod over USB DFU. A\n" + - "command that cannot use the connection says so and names what to use instead.\n\n" + + "install-blobs, bootsel, dfu, usb-bridge and usb-reattach; flash-self reflashes\n" + + "the pod over USB DFU. A command that cannot use the connection says so and\n" + + "names what to use instead.\n\n" + "flash and spi-flash over the network take the pins you leave out from the pod's\n" + "wiring profile on embeddedci.com when you are signed in and the pod is on your\n" + "account.\n\n" + @@ -97,6 +100,7 @@ func newRootCmd() *cobra.Command { {"setup", "Setup:", []*cobra.Command{ newSetupCmd(g), newDiscoverCmd(g), newSetConnectionCmd(g), newSetWifiCmd(g), newShowNetworkCmd(g), newClearWifiCmd(g), + newUSBBridgeCmd(g), newUSBReattachCmd(g), }}, {"pod", "The pod:", []*cobra.Command{ newStatusCmd(g), newPingCmd(g), newLACmd(g), newIdentityCmd(g), diff --git a/cmd/benchpod-cli/usbbridge.go b/cmd/benchpod-cli/usbbridge.go new file mode 100644 index 0000000..2380771 --- /dev/null +++ b/cmd/benchpod-cli/usbbridge.go @@ -0,0 +1,275 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "strconv" + "sync" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" + "github.com/spf13/cobra" +) + +// usb-bridge: the pod's cloud link through this computer. The pod's USB network adapter has no +// router (the computer keeps its own default route), so the pod cannot reach embeddedci.com over +// it by itself. The bridge runs an HTTP CONNECT proxy on the computer's end of the USB link and +// sets it as the pod's cloud proxy (the same setting as `benchpod cloud proxy set`); the pod's +// firmware then starts its cloud client on the USB link alone. + +const ( + usbBridgeDefaultPort = 3128 + usbBridgeDialTimeout = 15 * time.Second + // The pod only ever asks for its cloud server over TLS. + usbBridgeAllowedPort = 443 +) + +func newUSBBridgeCmd(g *globalFlags) *cobra.Command { + var port int + var keep bool + cmd := &cobra.Command{ + Use: "usb-bridge", + Short: "Carry the pod's cloud link over USB through this computer (runs until Ctrl-C)", + Long: "Carry the pod's cloud link over its USB network adapter: run an HTTP CONNECT proxy on\n" + + "this computer's end of the USB link and set it as the pod's cloud proxy, so the pod\n" + + "reaches embeddedci.com through this computer's internet connection. It runs until\n" + + "Ctrl-C, then clears the pod's proxy again (--keep-proxy leaves it set).\n\n" + + "Needs firmware 3.9.0 or later and no Wi-Fi configured on the pod. The proxy accepts\n" + + "only the pod, and only tunnels to port 443. It refuses to replace a proxy the pod\n" + + "already has (`benchpod cloud proxy clear` removes it).", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runUSBBridge(g, port, keep) + }, + } + cmd.Flags().IntVar(&port, "port", usbBridgeDefaultPort, "TCP port of the proxy on this computer's USB address") + cmd.Flags().BoolVar(&keep, "keep-proxy", false, "leave the pod's cloud proxy set when the bridge stops") + return cmd +} + +func runUSBBridge(g *globalFlags, port int, keep bool) error { + if port <= 0 || port > 65535 { + return fmt.Errorf("usb-bridge: --port %d is out of range", port) + } + device, err := g.usbDevice("usb-bridge") + if err != nil { + return err + } + console, path, ctx, cancel, err := g.openSerialConsole(device, 15*time.Second) + if err != nil { + return err + } + u, err := console.USBNetwork(ctx) + if err == nil { + err = usbBridgeReady(u) + } + if err != nil { + console.Close() + cancel() + return fmt.Errorf("usb-bridge: %w", err) + } + proxyAddr := net.JoinHostPort(u.HostIP, strconv.Itoa(port)) + cur, err := console.CloudProxy(ctx) + if err != nil { + console.Close() + cancel() + return fmt.Errorf("usb-bridge: read the pod's cloud proxy: %w", err) + } + if cur.Addr != "" && cur.Addr != proxyAddr { + console.Close() + cancel() + return fmt.Errorf("usb-bridge: the pod already has a cloud proxy (%s); the bridge would replace it. "+ + "Remove it first with `benchpod cloud proxy clear --connection usb`", cur.Addr) + } + + ln, err := net.Listen("tcp", proxyAddr) + if err != nil { + console.Close() + cancel() + return fmt.Errorf("usb-bridge: listen on %s, this computer's end of the USB link: %w "+ + "(if the adapter has no address yet, run `benchpod usb-reattach`)", proxyAddr, err) + } + if _, err := console.CloudProxySet(ctx, proxyAddr, "", ""); err != nil { + ln.Close() + console.Close() + cancel() + return fmt.Errorf("usb-bridge: set the pod's cloud proxy: %w", err) + } + // Free the console for other commands while the bridge runs. + console.Close() + cancel() + fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s. Ctrl-C stops it.\n", u.PodIP, proxyAddr) + + runCtx, stop := context.WithCancel(context.Background()) + defer stop() + defer installSignalHandler(runCtx, stop)() + go func() { <-runCtx.Done(); ln.Close() }() + p := &connectProxy{allowFrom: u.PodIP, allowPort: usbBridgeAllowedPort, logf: log.Printf} + p.serve(ln) + p.wait() + + if keep { + fmt.Fprintf(os.Stderr, "usb-bridge: stopped; the pod's cloud proxy stays %s (--keep-proxy)\n", proxyAddr) + return nil + } + console, _, ctx, cancel, err = g.openSerialConsole(path, 15*time.Second) + if err != nil { + return fmt.Errorf("usb-bridge: stopped, but could not clear the pod's cloud proxy (%w); "+ + "run `benchpod cloud proxy clear --connection usb`", err) + } + defer cancel() + defer console.Close() + if _, err := console.CloudProxyClear(ctx); err != nil { + return fmt.Errorf("usb-bridge: stopped, but clearing the pod's cloud proxy failed: %w", err) + } + fmt.Fprintln(os.Stderr, "usb-bridge: stopped; cleared the pod's cloud proxy") + return nil +} + +// usbBridgeReady says why the USB link cannot carry the bridge, or nil. +func usbBridgeReady(u serialconsole.USBNet) error { + if !u.On { + return errors.New("USB networking is off because Wi-Fi is configured on the pod; " + + "the pod reaches the cloud over Wi-Fi then (`benchpod clear-wifi` turns USB networking back on)") + } + if !u.Link { + return fmt.Errorf("the pod's USB network adapter (%s) is not up on this computer; "+ + "macOS ignores a new adapter while the screen is locked: unlock and run `benchpod usb-reattach`", u.PodIP) + } + return nil +} + +// connectProxy is a minimal HTTP CONNECT proxy for one client address and one destination port. +type connectProxy struct { + allowFrom string // the only client IP served + allowPort int // the only destination port tunnelled + logf func(format string, args ...any) + dial func(network, addr string, timeout time.Duration) (net.Conn, error) // nil: net.DialTimeout + wg sync.WaitGroup +} + +// serve accepts until ln is closed. +func (p *connectProxy) serve(ln net.Listener) { + for { + c, err := ln.Accept() + if err != nil { + return + } + p.wg.Add(1) + go func() { + defer p.wg.Done() + p.handle(c) + }() + } +} + +// wait blocks until the open tunnels have ended, or 2 s. +func (p *connectProxy) wait() { + done := make(chan struct{}) + go func() { p.wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + } +} + +func (p *connectProxy) handle(c net.Conn) { + defer c.Close() + host, _, _ := net.SplitHostPort(c.RemoteAddr().String()) + if host != p.allowFrom { + p.logf("usb-bridge: refused a connection from %s (only the pod, %s)", host, p.allowFrom) + return + } + _ = c.SetReadDeadline(time.Now().Add(30 * time.Second)) + br := bufio.NewReader(c) + req, err := http.ReadRequest(br) + if err != nil { + return + } + if req.Method != http.MethodConnect { + reply(c, http.StatusMethodNotAllowed) + return + } + target := req.RequestURI + _, tport, err := net.SplitHostPort(target) + if err != nil || tport != strconv.Itoa(p.allowPort) { + p.logf("usb-bridge: refused CONNECT %s (only port %d)", target, p.allowPort) + reply(c, http.StatusForbidden) + return + } + dial := p.dial + if dial == nil { + dial = net.DialTimeout + } + up, err := dial("tcp", target, usbBridgeDialTimeout) + if err != nil { + p.logf("usb-bridge: CONNECT %s: %v", target, err) + reply(c, http.StatusBadGateway) + return + } + defer up.Close() + _ = c.SetReadDeadline(time.Time{}) + if _, err := io.WriteString(c, "HTTP/1.1 200 Connection established\r\n\r\n"); err != nil { + return + } + p.logf("usb-bridge: tunnel to %s open", target) + start := time.Now() + var toUp, toPod int64 + done := make(chan struct{}) + go func() { + // What the pod sent after the request line (normally nothing yet) goes first. + toUp, _ = io.Copy(up, br) + if cw, ok := up.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + close(done) + }() + toPod, _ = io.Copy(c, up) + if cw, ok := c.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + <-done + p.logf("usb-bridge: tunnel to %s closed after %s (%d B up, %d B down)", + target, time.Since(start).Round(time.Second), toUp, toPod) +} + +func reply(c net.Conn, status int) { + fmt.Fprintf(c, "HTTP/1.1 %d %s\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", status, http.StatusText(status)) +} + +func newUSBReattachCmd(g *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "usb-reattach", + Short: "Make the pod reconnect on USB, so this computer sets up its USB network adapter again", + Long: "Make the pod drop off the USB bus for half a second and connect again (USB console).\n" + + "This computer then enumerates the console and the USB network adapter anew, as after\n" + + "replugging the cable. macOS ignores a new network adapter while the screen is locked,\n" + + "so run this after unlocking when the pod was plugged in or rebooted meanwhile.\n" + + "Needs firmware 3.9.0 or later.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + device, err := g.usbDevice("usb-reattach") + if err != nil { + return err + } + console, _, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(10*time.Second)) + if err != nil { + return err + } + defer cancel() + defer console.Close() + if err := console.USBReattach(ctx); err != nil { + return fmt.Errorf("usb-reattach: %w", err) + } + fmt.Fprintln(os.Stderr, "The pod reconnected on USB; its network adapter is back in a few seconds.") + return nil + }, + } +} diff --git a/cmd/benchpod-cli/usbbridge_test.go b/cmd/benchpod-cli/usbbridge_test.go new file mode 100644 index 0000000..a010bd6 --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_test.go @@ -0,0 +1,135 @@ +package main + +import ( + "bufio" + "io" + "net" + "strconv" + "strings" + "testing" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" +) + +// startProxy runs a connectProxy on a loopback listener that serves 127.0.0.1 and tunnels to +// allowPort. It returns the proxy's address. +func startProxy(t *testing.T, allowFrom string, allowPort int) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := &connectProxy{allowFrom: allowFrom, allowPort: allowPort, logf: t.Logf} + go p.serve(ln) + t.Cleanup(func() { ln.Close(); p.wait() }) + return ln.Addr().String() +} + +// echoServer answers every line with "echo: ". +func echoServer(t *testing.T) (string, int) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + sc := bufio.NewScanner(c) + for sc.Scan() { + io.WriteString(c, "echo: "+sc.Text()+"\n") + } + }() + } + }() + return ln.Addr().String(), ln.Addr().(*net.TCPAddr).Port +} + +func connect(t *testing.T, proxy, target string) (net.Conn, *bufio.Reader, string) { + t.Helper() + c, err := net.DialTimeout("tcp", proxy, 2*time.Second) + if err != nil { + t.Fatal(err) + } + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + // The way the pod's firmware asks (altcp_bp_proxy.c), with the first bytes of the + // tunnel in the same write. + io.WriteString(c, "CONNECT "+target+" HTTP/1.1\r\nHost: "+target+"\r\n\r\n") + br := bufio.NewReader(c) + status, _ := br.ReadString('\n') + for { + l, err := br.ReadString('\n') + if err != nil || l == "\r\n" { + break + } + } + return c, br, status +} + +func TestConnectProxyTunnels(t *testing.T) { + target, port := echoServer(t) + proxy := startProxy(t, "127.0.0.1", port) + c, br, status := connect(t, proxy, target) + defer c.Close() + if !strings.Contains(status, " 200 ") { + t.Fatalf("status %q, want 200", status) + } + io.WriteString(c, "hello\n") + if got, _ := br.ReadString('\n'); got != "echo: hello\n" { + t.Fatalf("tunnel answered %q", got) + } +} + +func TestConnectProxyRefusesOtherPorts(t *testing.T) { + target, port := echoServer(t) + proxy := startProxy(t, "127.0.0.1", port+1) + c, _, status := connect(t, proxy, target) + defer c.Close() + if !strings.Contains(status, " 403 ") { + t.Fatalf("status %q, want 403 for a port other than %d", status, port+1) + } +} + +func TestConnectProxyServesOnlyThePod(t *testing.T) { + target, port := echoServer(t) + proxy := startProxy(t, "10.237.8.85", port) + c, _, status := connect(t, proxy, target) + defer c.Close() + if status != "" { + t.Fatalf("a client other than the pod got %q, want the connection closed", status) + } +} + +func TestConnectProxyRefusesPlainRequests(t *testing.T) { + proxy := startProxy(t, "127.0.0.1", 443) + c, err := net.Dial("tcp", proxy) + if err != nil { + t.Fatal(err) + } + defer c.Close() + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + io.WriteString(c, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n") + status, _ := bufio.NewReader(c).ReadString('\n') + if !strings.Contains(status, " "+strconv.Itoa(405)+" ") { + t.Fatalf("status %q, want 405", status) + } +} + +func TestUSBBridgeReady(t *testing.T) { + if err := usbBridgeReady(serialconsole.USBNet{On: true, Link: true}); err != nil { + t.Fatal(err) + } + if err := usbBridgeReady(serialconsole.USBNet{}); err == nil || !strings.Contains(err.Error(), "clear-wifi") { + t.Fatalf("net off: %v", err) + } + if err := usbBridgeReady(serialconsole.USBNet{On: true}); err == nil || !strings.Contains(err.Error(), "usb-reattach") { + t.Fatalf("link down: %v", err) + } +} diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go new file mode 100644 index 0000000..3ed3e67 --- /dev/null +++ b/cmd/benchpod-cli/usbnet.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" +) + +// The pod's USB network adapter (firmware 3.9.0+): the USB device that carries the console is +// also a network adapter, with the pod's full network API on its fixed address. A command that +// needs the network and is given a USB connection uses it; the console stays for the commands +// that only the console runs and as the fallback. + +// usbNetProbeTimeout bounds the TCP check of the pod's address on the USB link. +const usbNetProbeTimeout = 2 * time.Second + +// usbNetwork reads the USB network adapter's state from the pod's console. A variable so tests +// can replace it. +var usbNetwork = func(g *globalFlags, device string) (serialconsole.USBNet, error) { + console, _, err := g.openBenchpodSerial(device, 2*time.Second) + if err != nil { + return serialconsole.USBNet{}, err + } + defer console.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return console.USBNetwork(ctx) +} + +// dialUSBPod checks the pod answers on its USB address. A variable so tests can replace it. +var dialUSBPod = func(addr string) error { + c, err := net.DialTimeout("tcp", addr, usbNetProbeTimeout) + if err != nil { + return err + } + return c.Close() +} + +// usbNetAddr returns the pod's API address on its USB network adapter (podIP:8080), or why there +// is none, phrased to follow "over USB, ". +func (g *globalFlags) usbNetAddr(device string) (string, error) { + u, err := usbNetwork(g, device) + if err != nil { + if errors.Is(err, serialconsole.ErrNoUSBNet) { + return "", errors.New("the pod's firmware has no USB network adapter (it needs 3.9.0 or later: `benchpod flash-self`)") + } + return "", fmt.Errorf("the pod's console did not report its USB network: %w", err) + } + if !u.On { + return "", errors.New("USB networking is off because Wi-Fi is configured on the pod (`benchpod clear-wifi` turns it back on)") + } + addr := net.JoinHostPort(u.PodIP, "8080") + if !u.Link { + return "", fmt.Errorf("the pod's USB network adapter (%s) is not up on this computer; "+ + "macOS ignores a new adapter while the screen is locked: unlock and run `benchpod usb-reattach`", u.PodIP) + } + if err := dialUSBPod(addr); err != nil { + return "", fmt.Errorf("the pod did not answer on its USB network address %s (%v); this computer should have %s on that adapter, "+ + "run `benchpod usb-reattach` if it does not", addr, err, u.HostIP) + } + return addr, nil +} + +// 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. +func (g *globalFlags) networkTarget(cmd string) (ConnSpec, error) { + spec, err := g.resolveTarget() + if err != nil || !spec.IsSerial() { + return spec, err + } + addr, uerr := g.usbNetAddr(spec.Device) + if uerr != nil { + return spec, fmt.Errorf("%w. Here: %v", spec.RequireNetwork(cmd), uerr) + } + return ConnSpec{Kind: connNetwork, Addr: addr}, nil +} diff --git a/cmd/benchpod-cli/usbnet_test.go b/cmd/benchpod-cli/usbnet_test.go new file mode 100644 index 0000000..6bc1ccf --- /dev/null +++ b/cmd/benchpod-cli/usbnet_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" +) + +func withUSBNet(t *testing.T, u serialconsole.USBNet, uerr, dialErr error) *[]string { + t.Helper() + var dialed []string + oldNet, oldDial := usbNetwork, dialUSBPod + usbNetwork = func(*globalFlags, string) (serialconsole.USBNet, error) { return u, uerr } + dialUSBPod = func(addr string) error { dialed = append(dialed, addr); return dialErr } + t.Cleanup(func() { usbNetwork, dialUSBPod = oldNet, oldDial }) + return &dialed +} + +func TestNetworkTargetUsesTheUSBNetworkAdapter(t *testing.T) { + dialed := withUSBNet(t, serialconsole.USBNet{On: true, Link: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86"}, nil, nil) + for _, conn := range []string{"usb", "/dev/cu.usbmodem1"} { + g := &globalFlags{connection: conn} + spec, err := g.networkTarget("capture") + if err != nil { + t.Fatalf("%s: %v", conn, err) + } + if !spec.IsNetwork() || spec.Addr != "10.237.8.85:8080" { + t.Fatalf("%s: spec = %+v, want the network transport on 10.237.8.85:8080", conn, spec) + } + } + if len(*dialed) != 2 { + t.Fatalf("dialed %v, want the pod checked once per resolve", *dialed) + } +} + +func TestNetworkTargetLeavesOtherConnectionsAlone(t *testing.T) { + withUSBNet(t, serialconsole.USBNet{}, errors.New("must not be asked"), nil) + for conn, network := range map[string]bool{"192.168.1.5": true, "embeddedci:bench-1": false} { + g := &globalFlags{connection: conn} + spec, err := g.networkTarget("capture") + if err != nil || spec.IsNetwork() != network { + t.Fatalf("%s: spec %+v err %v", conn, spec, err) + } + } +} + +func TestNetworkTargetSaysWhyUSBNetworkIsUnavailable(t *testing.T) { + cases := []struct { + name string + u serialconsole.USBNet + uerr error + dialErr error + want string + }{ + {"old firmware", serialconsole.USBNet{}, serialconsole.ErrNoUSBNet, nil, "3.9.0"}, + {"wifi configured", serialconsole.USBNet{PodIP: "10.237.8.85", HostIP: "10.237.8.86"}, nil, nil, "clear-wifi"}, + {"link down", serialconsole.USBNet{On: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86"}, nil, nil, "usb-reattach"}, + {"no answer", serialconsole.USBNet{On: true, Link: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86"}, nil, errors.New("timeout"), "10.237.8.86"}, + {"no console", serialconsole.USBNet{}, errors.New("no pod found"), nil, "no pod found"}, + } + for _, c := range cases { + withUSBNet(t, c.u, c.uerr, c.dialErr) + g := &globalFlags{connection: "usb"} + _, err := g.networkTarget("capture") + if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "benchpod discover") { + t.Errorf("%s: err = %v, want it to mention %q and the discover way out", c.name, err, c.want) + } + } +} diff --git a/internal/serialconsole/serialconsole.go b/internal/serialconsole/serialconsole.go index aa262ef..b5e76a8 100644 --- a/internal/serialconsole/serialconsole.go +++ b/internal/serialconsole/serialconsole.go @@ -32,14 +32,17 @@ import ( // under, matched case-insensitively against enumerator VID strings: // // 2E8A — Raspberry Pi, the RP2350 pod's native USB port. -// 0483 — STMicroelectronics, the STM32H563 pod (ST's stock CDC VID/PID -// 0483:5740, so the host binds its in-box CDC driver). +// 0483 — STMicroelectronics, the STM32H563 pod up to firmware 3.8 (ST's +// stock CDC VID/PID 0483:5740, so the host binds its in-box CDC driver). +// 1209 — pid.codes, the STM32H563 pod from firmware 3.9.0 (1209:0001): a +// composite device with the console and a USB network adapter. // -// Neither ID is exclusive to a bench pod — 2E8A is shared with the CMSIS-DAP -// probe's CDC and 0483:5740 with every ST Virtual COM Port on the bench — so +// None of the IDs is exclusive to a bench pod — 2E8A is shared with the CMSIS-DAP +// probe's CDC, 0483:5740 with every ST Virtual COM Port on the bench and 1209 +// with every pid.codes device — so // these only ORDER the ports to probe (see portPrio). Identification is always // by probing for benchpodMarker, never by VID alone. -var benchPodVIDs = []string{"2E8A", "0483"} +var benchPodVIDs = []string{"2E8A", "0483", "1209"} // isBenchPodVID reports whether vid is one of benchPodVIDs. func isBenchPodVID(vid string) bool { diff --git a/internal/serialconsole/serialconsole_test.go b/internal/serialconsole/serialconsole_test.go index e0afb61..6a237d3 100644 --- a/internal/serialconsole/serialconsole_test.go +++ b/internal/serialconsole/serialconsole_test.go @@ -901,7 +901,7 @@ func TestParseSerialPodWithoutLease(t *testing.T) { } func TestIsBenchPodVIDCoversBothPodGenerations(t *testing.T) { - for _, vid := range []string{"2E8A", "2e8a", "0483", "0483"} { + for _, vid := range []string{"2E8A", "2e8a", "0483", "1209"} { if !isBenchPodVID(vid) { t.Fatalf("VID %s should be recognised as a bench-pod console", vid) } diff --git a/internal/serialconsole/usbnet.go b/internal/serialconsole/usbnet.go new file mode 100644 index 0000000..122c595 --- /dev/null +++ b/internal/serialconsole/usbnet.go @@ -0,0 +1,80 @@ +package serialconsole + +import ( + "context" + "errors" + "fmt" + "net" + "strings" +) + +// USBNet is the pod's USB network adapter as its console `status` reports it (firmware 3.9.0+): +// +// usb : net on pod 10.237.8.85 host 10.237.8.86 link up rx 2946 (drop 0) tx 5759 (full 2357) +// +// The link is a point-to-point /30: the pod's API is on PodIP:8080, and the pod's DHCP server +// gives the computer HostIP. On is false while Wi-Fi credentials are stored on the pod (USB +// networking and Wi-Fi are one or the other); Link is true once the computer has the adapter up. +type USBNet struct { + On bool + Link bool + PodIP string + HostIP string +} + +// 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)") + +// ParseUSBNet reads the `usb` line of the console's `status` output. It returns ErrNoUSBNet when +// there is none. +func ParseUSBNet(status string) (USBNet, error) { + for _, ln := range strings.Split(strings.ReplaceAll(status, "\r", "\n"), "\n") { + k, v, ok := strings.Cut(ln, ":") + if !ok || strings.TrimSpace(k) != "usb" { + continue + } + u := USBNet{On: true} // the line without a net field (pre-release builds): on + f := strings.Fields(v) + for i := 0; i+1 < len(f); i++ { + switch f[i] { + case "net": + u.On = f[i+1] == "on" + case "pod": + u.PodIP = f[i+1] + case "host": + u.HostIP = f[i+1] + case "link": + u.Link = f[i+1] == "up" + } + } + if net.ParseIP(u.PodIP).To4() == nil || net.ParseIP(u.HostIP).To4() == nil { + return USBNet{}, fmt.Errorf("unexpected usb status line %q", strings.TrimSpace(ln)) + } + return u, nil + } + return USBNet{}, ErrNoUSBNet +} + +// USBNetwork runs `status` and returns the USB network adapter's state. +func (c *Console) USBNetwork(ctx context.Context) (USBNet, error) { + out, err := c.sendCommand(ctx, "status") + if err != nil { + return USBNet{}, err + } + return ParseUSBNet(out) +} + +// USBReattach makes the pod drop off the USB bus for half a second and connect again, so the +// computer enumerates the console and the network adapter anew. The console closes; reopen it. +func (c *Console) USBReattach(ctx context.Context) error { + out, err := c.sendCommand(ctx, "usb-reattach") + switch { + case strings.Contains(out, "usb-reattach ok"), errors.Is(err, errPortVanished): + return nil // the port may close before the prompt arrives + case strings.Contains(strings.ToLower(out), "unknown command"): + return ErrNoUSBNet + case err != nil: + return err + } + return fmt.Errorf("unexpected reply to usb-reattach: %q", strings.TrimSpace(cleanConsoleOutput(out, "usb-reattach"))) +} diff --git a/internal/serialconsole/usbnet_test.go b/internal/serialconsole/usbnet_test.go new file mode 100644 index 0000000..4c1d492 --- /dev/null +++ b/internal/serialconsole/usbnet_test.go @@ -0,0 +1,30 @@ +package serialconsole + +import ( + "errors" + "testing" +) + +func TestParseUSBNet(t *testing.T) { + on := " device : benchpod\r\n ip : 192.168.1.220\r\n" + + " usb : net on pod 10.237.8.85 host 10.237.8.86 link up rx 2946 (drop 0) tx 5759 (full 2357)\r\n" + u, err := ParseUSBNet(on) + if err != nil || u != (USBNet{On: true, Link: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86"}) { + t.Fatalf("on: %+v %v", u, err) + } + off := " 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)\n" + u, err = ParseUSBNet(off) + if err != nil || u.On || u.Link || u.PodIP != "10.237.8.85" { + t.Fatalf("off: %+v %v", u, err) + } + // Pre-release builds print no net field: the adapter is there and on. + if u, err = ParseUSBNet(" usb : pod 10.237.8.85 host 10.237.8.86 link up rx 1 (drop 0) tx 1 (full 0)"); err != nil || !u.Link || !u.On { + t.Fatalf("no net field: %+v %v", u, err) + } + if _, err := ParseUSBNet(" device : benchpod\n ip : 0.0.0.0\n"); !errors.Is(err, ErrNoUSBNet) { + t.Fatalf("old firmware: err = %v, want ErrNoUSBNet", err) + } + if _, err := ParseUSBNet(" usb : net on pod ? host ?"); err == nil || errors.Is(err, ErrNoUSBNet) { + t.Fatalf("garbled line: err = %v", err) + } +} From b9dfab8c7220650f91e61f7ab857f5bc25c0b878 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 20:53:10 -0500 Subject: [PATCH 02/29] usb: --connection help names the USB network adapter Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index b8906c6..717485c 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -80,7 +80,7 @@ func newRootCmd() *cobra.Command { pf := root.PersistentFlags() pf.StringVar(&g.connection, "connection", "", - `how to reach the pod: its network address (192.168.1.5[:8080]), a device path (/dev/tty..., COM3), "usb" to auto-detect the pod over USB, or embeddedci: to go through embeddedci.com (default: the saved set-connection target)`) + `how to reach the pod: its network address (192.168.1.5[:8080]), a device path (/dev/tty..., COM3), "usb" to auto-detect the pod over USB (its console, and its USB network adapter for the network commands), or embeddedci: to go through embeddedci.com (default: the saved set-connection target)`) pf.StringVar(&g.configFile, "config-file", "", "path to config file") pf.StringVar(&g.outputFilename, "output-filename", "", "write command output to this file instead of stdout") pf.DurationVar(&g.timeout, "timeout", 0, "overall command deadline (0 = per-command default)") From 3cf5bf9a4e36924997bbf68e37617f4d64077b97 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 20:53:20 -0500 Subject: [PATCH 03/29] usb-bridge: document the Windows firewall rule Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index df267e6..6bd7afc 100644 --- a/README.md +++ b/README.md @@ -976,6 +976,11 @@ and only tunnels to port 443. It refuses to replace a proxy the pod already has (`benchpod cloud proxy clear --connection usb` removes it). Needs firmware 3.9.0 or later and no Wi-Fi configured on the pod. +On Windows the USB link is an unidentified network, which the firewall treats as +Public: allow `benchpod` through Windows Defender Firewall when it asks, or add an +inbound rule for TCP port 3128 from `10.237.0.0/16`, or the pod's connections to +the bridge are blocked. + | Flag | Default | Purpose | |----------------|---------|------------------------------------------------------| | `--port` | `3128` | TCP port of the proxy on this computer's USB address. | From 544a9d22523a12aa45500c10675a4ce8e64057aa Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:04:41 -0500 Subject: [PATCH 04/29] discover: prefer the pod's USB link address; --save stores usb for it A pod advertises itself on every interface, so a computer with the pod on USB and on the LAN heard both and kept whichever came last. discover now merges the addresses per pod and picks the USB link (10.237.0.0/16) when there is one; --save then stores usb, which keeps working over the console when Wi-Fi turns USB networking off. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++- cmd/benchpod-cli/commands_discover.go | 35 +++++++++++++++++++--- cmd/benchpod-cli/commands_discover_test.go | 7 +++++ cmd/benchpod-cli/usbnet_test.go | 14 +++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6bd7afc..91901e8 100644 --- a/README.md +++ b/README.md @@ -650,10 +650,13 @@ report whether it is registered. When this machine is signed in (`benchpod login a registered pod is also checked against that account: discover names it when it is yours, and says so when it is registered to a different account (usually a second account; a pod set up for you is on the account your activation email was sent to). +A pod advertises itself on each of its interfaces; when this computer hears it on the +pod's USB network link (firmware 3.9.0+), discover shows and uses that address rather +than the LAN one, and `--save` stores `usb` for it. | Flag | Default | Purpose | |-------------------|---------|-----------------------------------------------------------------| -| `--save` | off | Save the pod as the default connection when exactly one is found (the network address first). | +| `--save` | off | Save the pod as the default connection when exactly one is found (the network address first; `usb` for a pod on its USB link). | | `--no-usb` | off | Skip probing USB ports. | | `--no-network` | off | Skip mDNS browsing of the LAN. | | `--wait` | `3s` | How long to browse the LAN for mDNS replies. | diff --git a/cmd/benchpod-cli/commands_discover.go b/cmd/benchpod-cli/commands_discover.go index 924ed71..2f15d90 100644 --- a/cmd/benchpod-cli/commands_discover.go +++ b/cmd/benchpod-cli/commands_discover.go @@ -226,6 +226,7 @@ func discoverPods(wait time.Duration) ([]discoveredPod, error) { // the context expires, so the loop ends on its own; byKey is read only after // the drain has finished (below), so no lock is needed. drained := make(chan struct{}) + ips := map[string][]net.IP{} // every IPv4 heard per instance, across interfaces go func() { defer close(drained) for e := range entries { @@ -234,11 +235,12 @@ func discoverPods(wait time.Duration) ([]discoveredPod, error) { hostname: strings.TrimSuffix(e.HostName, "."), id: txtValue(e.Text, "id"), } + ips[e.Instance] = append(ips[e.Instance], e.AddrIPv4...) // Prefer a numeric IPv4 over the .local name so we don't depend on - // the OS mDNS resolver later. + // the OS mDNS resolver later, and the pod's USB link over the LAN. host := p.hostname - if len(e.AddrIPv4) > 0 { - host = e.AddrIPv4[0].String() + if ip := preferredPodIP(ips[e.Instance]); ip != nil { + host = ip.String() } p.addr = benchpodconfig.EnsurePort(fmt.Sprintf("%s:%d", host, e.Port)) byKey[e.Instance] = p @@ -275,6 +277,25 @@ func discoverPods(wait time.Duration) ([]discoveredPod, error) { return pods, nil } +// usbLinkNet is where a pod's USB network adapter lives (firmware 3.9.0+): a /30 per pod, +// derived from its unique ID. Only the computer the pod is plugged into hears it there. +var usbLinkNet = &net.IPNet{IP: net.IPv4(10, 237, 0, 0), Mask: net.CIDRMask(16, 32)} + +// preferredPodIP picks the address to reach a pod by from what mDNS returned for it on every +// interface: its USB link when it has one (a pod advertises on each of its interfaces, and the +// OS would otherwise pick any), else the first. nil when there is none. +func preferredPodIP(ips []net.IP) net.IP { + for _, ip := range ips { + if usbLinkNet.Contains(ip) { + return ip + } + } + if len(ips) > 0 { + return ips[0] + } + return nil +} + // multicastIfaces lists the interfaces worth browsing for mDNS: up, multicast // capable, not loopback, and carrying an IPv4 address. Loopback is excluded // because a pod is never on it and including it is one of the ways the default @@ -693,9 +714,15 @@ func plural(n int, one, many string) string { // saveTarget is the connection string --save would store: the network address // of the single pod found (preferred — it works from other machines and from // CI), else the serial device of the single pod found over USB. It returns "" -// when the choice is ambiguous, so --save never guesses between two pods. +// when the choice is ambiguous, so --save never guesses between two pods. A pod +// heard on its USB network link is saved as `usb`: its link address is only +// reachable from this computer, and `usb` keeps working (over the console) when +// Wi-Fi turns the pod's USB networking off. func saveTarget(netPods []discoveredPod, serialPods []serialconsole.SerialPod) string { if len(netPods) == 1 { + if host, _, err := net.SplitHostPort(netPods[0].addr); err == nil && usbLinkNet.Contains(net.ParseIP(host)) { + return "usb" + } return netPods[0].addr } if len(netPods) == 0 && len(serialPods) == 1 { diff --git a/cmd/benchpod-cli/commands_discover_test.go b/cmd/benchpod-cli/commands_discover_test.go index c7ed688..c693244 100644 --- a/cmd/benchpod-cli/commands_discover_test.go +++ b/cmd/benchpod-cli/commands_discover_test.go @@ -333,3 +333,10 @@ func TestDescribeSerialRegistration(t *testing.T) { }) } } + +func TestSaveTargetSavesUSBForAPodOnItsUSBLink(t *testing.T) { + got := saveTarget([]discoveredPod{{addr: "10.237.8.85:8080"}}, []serialconsole.SerialPod{{Device: "/dev/cu.usbmodem1"}}) + if got != "usb" { + t.Fatalf("saveTarget = %q, want usb for a pod found on its USB network link", got) + } +} diff --git a/cmd/benchpod-cli/usbnet_test.go b/cmd/benchpod-cli/usbnet_test.go index 6bc1ccf..64ed308 100644 --- a/cmd/benchpod-cli/usbnet_test.go +++ b/cmd/benchpod-cli/usbnet_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "net" "strings" "testing" @@ -69,3 +70,16 @@ func TestNetworkTargetSaysWhyUSBNetworkIsUnavailable(t *testing.T) { } } } + +func TestPreferredPodIPPicksTheUSBLink(t *testing.T) { + lan, usb := net.ParseIP("192.168.1.221"), net.ParseIP("10.237.8.85") + if got := preferredPodIP([]net.IP{lan, usb}); !got.Equal(usb) { + t.Fatalf("got %v, want the USB link address", got) + } + if got := preferredPodIP([]net.IP{lan}); !got.Equal(lan) { + t.Fatalf("got %v, want the LAN address when there is no USB link", got) + } + if preferredPodIP(nil) != nil { + t.Fatal("no addresses: want nil") + } +} From bd51200c32a285230d885f4d2ba142e0ac0dba82 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:07:18 -0500 Subject: [PATCH 05/29] usb: picks one of several pods on USB; plain usb lists them Two pods on one computer each have their own USB link. The console probe now reads each pod's identity: usb: (baea06, benchpod-baea06) opens that pod, and plain usb with several pods errors with their names instead of taking whichever port came first. One pod's macOS cu/tty twins count once. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + cmd/benchpod-cli/connection.go | 15 +++ cmd/benchpod-cli/helpers.go | 14 ++- cmd/benchpod-cli/root.go | 2 +- cmd/benchpod-cli/usbnet.go | 7 ++ internal/serialconsole/select.go | 137 ++++++++++++++++++++++++ internal/serialconsole/select_test.go | 79 ++++++++++++++ internal/serialconsole/serialconsole.go | 9 +- 8 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 internal/serialconsole/select.go create mode 100644 internal/serialconsole/select_test.go diff --git a/README.md b/README.md index 91901e8..7dabf25 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ reach the pod; the transport is inferred from its value: | `192.168.1.5[:8080]` | The pod's network TCP/JSON API (Ethernet or Wi-Fi). | | `/dev/tty...`, `COM3` | USB, explicit console device path. | | `usb` | USB, the console auto-detected by probing the ports. | +| `usb:` | USB, the pod with that name (`usb:benchpod-a1b2c3`) when several are plugged in; plain `usb` then lists them. | | `embeddedci:` | Through embeddedci.com to the pod with that name on your account (see below). | | *(omitted)* | the default saved by `benchpod discover --save`, `benchpod setup` or `benchpod set-connection`. | diff --git a/cmd/benchpod-cli/connection.go b/cmd/benchpod-cli/connection.go index 4fe6e69..25f5e73 100644 --- a/cmd/benchpod-cli/connection.go +++ b/cmd/benchpod-cli/connection.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/embeddedci-com/benchpod-cli/internal/benchpodconfig" + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" "github.com/spf13/cobra" ) @@ -27,11 +28,13 @@ const ( // 192.168.1.5[:port], host -> network/TCP (Addr set) // /dev/tty..., COM3, \\.\COM3 -> USB console (Device set) // usb (legacy: serial) -> USB console, auto-detect (Device == "") +// usb: -> USB, the pod with that name when several are plugged in (Pod set) // embeddedci: -> through embeddedci.com (Name set), where a command can work over it type ConnSpec struct { Kind connKind Addr string // TCP host:port for the network transport Device string // serial device path; "" means auto-detect + Pod string // usb:: the pod's identity short id ("baea06") among several on USB Name string // the pod's name on embeddedci.com, for the cloud form } @@ -83,6 +86,15 @@ func parseTarget(raw string) (ConnSpec, error) { // written before the rename keep working; it is not advertised anywhere. case "usb", "serial": return ConnSpec{Kind: connSerial}, nil + } + if strings.HasPrefix(strings.ToLower(raw), "usb:") { + pod := serialconsole.NormalizePodName(raw[len("usb:"):]) + if pod == "" { + return ConnSpec{}, fmt.Errorf("--connection %s needs the pod's name, e.g. usb:benchpod-a1b2c3", raw) + } + return ConnSpec{Kind: connSerial, Pod: pod}, nil + } + switch strings.ToLower(raw) { case "wifi", "tcp": return ConnSpec{}, fmt.Errorf("--connection %s needs an address, e.g. --connection 192.168.1.5", strings.ToLower(raw)) } @@ -116,6 +128,9 @@ func describeConn(c ConnSpec) string { if c.Device != "" { return "usb " + c.Device } + if c.Pod != "" { + return "usb benchpod-" + c.Pod + } return "usb (auto-detect)" } diff --git a/cmd/benchpod-cli/helpers.go b/cmd/benchpod-cli/helpers.go index 9b7709d..3207072 100644 --- a/cmd/benchpod-cli/helpers.go +++ b/cmd/benchpod-cli/helpers.go @@ -158,7 +158,7 @@ func (g *globalFlags) openBenchpodSerial(device string, probeTimeout time.Durati hint = strings.TrimSpace(cfg.LastSerial) } } - console, path, err := serialconsole.OpenBenchpod(device, hint, probeTimeout) + console, path, err := serialconsole.OpenBenchpodSelect(device, hint, g.usbPodName(), probeTimeout) if err != nil { return nil, "", err } @@ -171,6 +171,18 @@ func (g *globalFlags) openBenchpodSerial(device string, probeTimeout time.Durati return console, path, nil } +// usbPodName is the pod a usb: connection selects, or "". +func (g *globalFlags) usbPodName() string { + raw, err := g.rawConnection() + if err != nil { + return "" + } + if spec, err := parseTarget(raw); err == nil && spec.IsSerial() { + return spec.Pod + } + return "" +} + // saveLastSerial persists the auto-detected serial device path to config. func saveLastSerial(cfgPath, path string) error { cfg, err := benchpodconfig.Load(cfgPath) diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index 717485c..05afd31 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -80,7 +80,7 @@ func newRootCmd() *cobra.Command { pf := root.PersistentFlags() pf.StringVar(&g.connection, "connection", "", - `how to reach the pod: its network address (192.168.1.5[:8080]), a device path (/dev/tty..., COM3), "usb" to auto-detect the pod over USB (its console, and its USB network adapter for the network commands), or embeddedci: to go through embeddedci.com (default: the saved set-connection target)`) + `how to reach the pod: its network address (192.168.1.5[:8080]), a device path (/dev/tty..., COM3), "usb" to auto-detect the pod over USB (its console, and its USB network adapter for the network commands), usb: for one of several pods on USB, or embeddedci: to go through embeddedci.com (default: the saved set-connection target)`) pf.StringVar(&g.configFile, "config-file", "", "path to config file") pf.StringVar(&g.outputFilename, "output-filename", "", "write command output to this file instead of stdout") pf.DurationVar(&g.timeout, "timeout", 0, "overall command deadline (0 = per-command default)") diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go index 3ed3e67..9d7d8e6 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -8,6 +8,7 @@ import ( "time" "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" ) // The pod's USB network adapter (firmware 3.9.0+): the USB device that carries the console is @@ -45,6 +46,9 @@ var dialUSBPod = func(addr string) error { func (g *globalFlags) usbNetAddr(device string) (string, error) { u, err := usbNetwork(g, device) if err != nil { + if errors.Is(err, tcpclient.ErrUnreachable) { + return "", err // no pod (or not that one) on USB: the console's own message says which + } if errors.Is(err, serialconsole.ErrNoUSBNet) { return "", errors.New("the pod's firmware has no USB network adapter (it needs 3.9.0 or later: `benchpod flash-self`)") } @@ -74,6 +78,9 @@ func (g *globalFlags) networkTarget(cmd string) (ConnSpec, error) { return spec, err } addr, uerr := g.usbNetAddr(spec.Device) + if errors.Is(uerr, tcpclient.ErrUnreachable) { + return spec, uerr + } if uerr != nil { return spec, fmt.Errorf("%w. Here: %v", spec.RequireNetwork(cmd), uerr) } diff --git a/internal/serialconsole/select.go b/internal/serialconsole/select.go new file mode 100644 index 0000000..a45f22d --- /dev/null +++ b/internal/serialconsole/select.go @@ -0,0 +1,137 @@ +package serialconsole + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go.bug.st/serial" +) + +// NormalizePodName turns the ways a pod is named into its identity short id: "baea06", +// "benchpod-baea06" (its embeddedci.com and mDNS host name) and "BenchPod baea06" (its mDNS +// instance) all give "baea06". +func NormalizePodName(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + for _, p := range []string{"benchpod-", "benchpod "} { + s = strings.TrimPrefix(s, p) + } + return strings.TrimSuffix(s, ".local") +} + +// foundPod is a pod console found while probing. +type foundPod struct { + c *Console + port string + id string // identity short id, "" when it has none +} + +// openPort is a seam for tests: open a port as a console. +var openPort = func(name string) (*Console, error) { + port, err := serial.Open(name, &serial.Mode{BaudRate: baudRate}) + if err != nil { + return nil, err + } + c := newConsole(port) + c.logf = serialLogf + return c, nil +} + +// OpenBenchpodSelect opens the console of one pod on USB when several may be plugged in. With +// name ("baea06", "benchpod-baea06") it opens the pod with that identity; without, the only pod, +// and it errors listing them when there are several (one pod's console shows up twice on macOS, +// as /dev/cu.* and /dev/tty.*; the identity tells those apart from a second pod). The cached +// hint is probed first. explicit != "" opens that device as OpenBenchpod does. +func OpenBenchpodSelect(explicit, preferred, name string, probeTimeout time.Duration) (*Console, string, error) { + if strings.TrimSpace(explicit) != "" { + return OpenBenchpod(explicit, preferred, probeTimeout) + } + want := NormalizePodName(name) + cands, vid, err := candidatePortsVID() + if err != nil { + return nil, "", err + } + cands = preferFirst(strings.TrimSpace(preferred), cands) + if len(cands) == 0 { + return nil, "", noPod(errors.New("no USB ports found; plug in the pod or pass --connection ")) + } + var found []foundPod + closeAll := func(keep string) { + for _, f := range found { + if f.port != keep { + _ = f.c.Close() + } + } + } + for _, port := range cands { + // Once one pod is found, only the ports with a pod's USB VID can hold another. + if len(found) > 0 && !isBenchPodVID(vid[port]) { + continue + } + serialLogf("probing %s for a bench-pod console...", port) + c, err := openPort(port) + if err != nil { + serialLogf(" %s: open failed: %v", port, err) + continue + } + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + ok := c.IsBenchpod(ctx) + cancel() + if !ok { + _ = c.Close() + continue + } + ctx, cancel = context.WithTimeout(context.Background(), probeTimeout) + st, _ := c.Identity(ctx) + cancel() + f := foundPod{c: c, port: port, id: st.ShortID} + serialLogf(" %s: pod %q", port, f.id) + if want != "" && f.id == want { + closeAll("") + return c, port, nil + } + dup := false + for _, g := range found { + if g.id != "" && g.id == f.id { + dup = true // the same pod on its second device node + } + } + if dup { + _ = c.Close() + continue + } + found = append(found, f) + } + if want != "" { + closeAll("") + return nil, "", noPod(fmt.Errorf("no pod named %s on USB (found: %s)", want, listFound(found))) + } + switch len(found) { + case 0: + return nil, "", noPod(errors.New("no bench-pod console found on USB; pass --connection to force one")) + case 1: + return found[0].c, found[0].port, nil + } + closeAll("") + return nil, "", fmt.Errorf("%d pods on USB (%s): pick one with --connection usb:, e.g. usb:benchpod-%s", + len(found), listFound(found), found[0].id) +} + +func listFound(found []foundPod) string { + if len(found) == 0 { + return "none" + } + var parts []string + for _, f := range found { + id := f.id + if id == "" { + id = "no identity" + } else { + id = "benchpod-" + id + } + parts = append(parts, fmt.Sprintf("%s on %s", id, f.port)) + } + return strings.Join(parts, ", ") +} diff --git a/internal/serialconsole/select_test.go b/internal/serialconsole/select_test.go new file mode 100644 index 0000000..fdc44ec --- /dev/null +++ b/internal/serialconsole/select_test.go @@ -0,0 +1,79 @@ +package serialconsole + +import ( + "bytes" + "errors" + "fmt" + "strings" + "testing" + "time" + + "go.bug.st/serial/enumerator" +) + +// withPods makes candidate ports out of ports (name -> pod short id; "" = not a pod) and opens +// each as a fake console answering status and identity. +func withPods(t *testing.T, ports map[string]string, vid string) { + t.Helper() + var details []*enumerator.PortDetails + for name := range ports { + details = append(details, &enumerator.PortDetails{Name: name, IsUSB: true, VID: vid}) + } + withLister(t, func() ([]*enumerator.PortDetails, error) { return details, nil }) + withGlobber(t) + saved := openPort + openPort = func(name string) (*Console, error) { + id, ok := ports[name] + if !ok { + return nil, errors.New("no such port") + } + fc := &fakeConsole{onWrite: func(line string, out *bytes.Buffer) { + if id == "-" { // not a pod: silent + return + } + out.WriteString(line + "\r\n") + switch strings.Fields(line)[0] { + case "status": + out.WriteString(" device : benchpod\r\n") + case "identity": + fmt.Fprintf(out, "identity %s KEY\r\n", id) + } + out.WriteString("> ") + }} + return newConsole(fc), nil + } + t.Cleanup(func() { openPort = saved }) +} + +func TestNormalizePodName(t *testing.T) { + for _, in := range []string{"baea06", "benchpod-baea06", "BenchPod baea06", "BENCHPOD-BAEA06", "benchpod-baea06.local"} { + if got := NormalizePodName(in); got != "baea06" { + t.Errorf("NormalizePodName(%q) = %q", in, got) + } + } +} + +func TestOpenBenchpodSelectOnePod(t *testing.T) { + // One pod seen twice (macOS cu/tty twins) is still one pod. + withPods(t, map[string]string{"/dev/cu.usbmodemA": "aaaaaa", "/dev/cu.usbmodemA2": "aaaaaa"}, "1209") + _, port, err := OpenBenchpodSelect("", "", "", 300*time.Millisecond) + if err != nil || !strings.HasPrefix(port, "/dev/cu.usbmodemA") { + t.Fatalf("port %q err %v", port, err) + } +} + +func TestOpenBenchpodSelectTwoPods(t *testing.T) { + withPods(t, map[string]string{"/dev/cu.usbmodemA": "aaaaaa", "/dev/cu.usbmodemB": "bbbbbb"}, "1209") + _, _, err := OpenBenchpodSelect("", "", "", 300*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "usb:") || !strings.Contains(err.Error(), "benchpod-bbbbbb") { + t.Fatalf("two pods without a name: err = %v", err) + } + _, port, err := OpenBenchpodSelect("", "", "benchpod-bbbbbb", 300*time.Millisecond) + if err != nil || port != "/dev/cu.usbmodemB" { + t.Fatalf("by name: port %q err %v", port, err) + } + _, _, err = OpenBenchpodSelect("", "", "cccccc", 300*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "no pod named cccccc") { + t.Fatalf("unknown name: err = %v", err) + } +} diff --git a/internal/serialconsole/serialconsole.go b/internal/serialconsole/serialconsole.go index b5e76a8..357a75c 100644 --- a/internal/serialconsole/serialconsole.go +++ b/internal/serialconsole/serialconsole.go @@ -318,6 +318,13 @@ var globPorts = func() []string { // globbed /dev/cu.* and /dev/tty.* nodes (deduped), ordered by benchPodVIDs first // (the VIDs a pod's console enumerates under) then everything else by name. func candidatePorts() ([]string, error) { + names, _, err := candidatePortsVID() + return names, err +} + +// candidatePortsVID is candidatePorts plus each port's USB VID ("" when the enumerator did not +// list it, as for the /dev/tty.* dial-in twins). +func candidatePortsVID() ([]string, map[string]string, error) { // VID by port name, from the enumerator (cross-platform), for ordering. vid := map[string]string{} if ports, err := portLister(); err == nil { @@ -351,7 +358,7 @@ func candidatePorts() ([]string, error) { } return names[i] < names[j] }) - return names, nil + return names, vid, nil } func portPrio(vid string) int { From cb5fea46020da649b1a48dfdf202a996053ca6dd Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:10:27 -0500 Subject: [PATCH 06/29] usb-bridge --watch and a background service: install, uninstall, status --watch waits for the pod, notices when this computer loses the USB link address and picks the pod up again, keeping the pod's proxy if it already points here. usb-bridge install runs it at login for this user (launchd agent on macOS, systemd user unit on Linux, a scheduled task at logon on Windows); uninstall removes it and clears the pod's proxy when it points at this computer; status says whether it is installed and running. HW (macOS, baea06 with Ethernet stopped): install -> cloud connected through the agent in ~4 s; usb-reattach -> the agent waited and resumed, cloud connected; uninstall -> agent gone, proxy cleared. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++ cmd/benchpod-cli/usbbridge.go | 165 ++++++--- cmd/benchpod-cli/usbbridge_service.go | 375 +++++++++++++++++++++ cmd/benchpod-cli/usbbridge_service_test.go | 73 ++++ 4 files changed, 600 insertions(+), 39 deletions(-) create mode 100644 cmd/benchpod-cli/usbbridge_service.go create mode 100644 cmd/benchpod-cli/usbbridge_service_test.go diff --git a/README.md b/README.md index 7dabf25..7330c95 100644 --- a/README.md +++ b/README.md @@ -989,6 +989,32 @@ the bridge are blocked. |----------------|---------|------------------------------------------------------| | `--port` | `3128` | TCP port of the proxy on this computer's USB address. | | `--keep-proxy` | off | Leave the pod's cloud proxy set when the bridge stops. | +| `--watch` | off | Wait for the pod and pick it up again after it is unplugged or restarted (how the background service runs it). | + +### `benchpod usb-bridge install` + +Run `benchpod usb-bridge --watch` in the background for this user, at every login: a +launchd agent on macOS (`~/Library/LaunchAgents/com.embeddedci.benchpod-usb-bridge.plist`, +log in `~/Library/Logs/benchpod-usb-bridge.log`), a systemd user unit on Linux +(`~/.config/systemd/user/benchpod-usb-bridge.service`; `loginctl enable-linger` keeps it +running while you are logged out), or a scheduled task at logon on Windows +(`BenchPod usb-bridge`). A pod with only USB then keeps its cloud link, and so the web +app, after this computer restarts. `--connection` is passed on, so +`--connection usb:` installs it for one of several pods. On Windows, also let +the pod in through Windows Defender Firewall (see `usb-bridge`). + +| Flag | Default | Purpose | +|----------|---------|------------------------------------------------------| +| `--port` | `3128` | TCP port of the proxy on this computer's USB address. | + +### `benchpod usb-bridge uninstall` + +Stop and remove the background service, then clear the pod's cloud proxy when it still +points at this computer (the pod must be plugged in for that). No flags. + +### `benchpod usb-bridge status` + +Show whether the background service is installed and running. No flags. ### `benchpod usb-reattach` diff --git a/cmd/benchpod-cli/usbbridge.go b/cmd/benchpod-cli/usbbridge.go index 2380771..e222dc2 100644 --- a/cmd/benchpod-cli/usbbridge.go +++ b/cmd/benchpod-cli/usbbridge.go @@ -33,7 +33,7 @@ const ( func newUSBBridgeCmd(g *globalFlags) *cobra.Command { var port int - var keep bool + var keep, watch bool cmd := &cobra.Command{ Use: "usb-bridge", Short: "Carry the pod's cloud link over USB through this computer (runs until Ctrl-C)", @@ -41,92 +41,179 @@ func newUSBBridgeCmd(g *globalFlags) *cobra.Command { "this computer's end of the USB link and set it as the pod's cloud proxy, so the pod\n" + "reaches embeddedci.com through this computer's internet connection. It runs until\n" + "Ctrl-C, then clears the pod's proxy again (--keep-proxy leaves it set).\n\n" + + "--watch keeps going when no pod is plugged in or the USB link goes away, and picks the\n" + + "pod up again when it comes back: that is how `usb-bridge install` runs it in the\n" + + "background.\n\n" + "Needs firmware 3.9.0 or later and no Wi-Fi configured on the pod. The proxy accepts\n" + "only the pod, and only tunnels to port 443. It refuses to replace a proxy the pod\n" + "already has (`benchpod cloud proxy clear` removes it).", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - return runUSBBridge(g, port, keep) + return runUSBBridge(g, port, keep, watch) }, } cmd.Flags().IntVar(&port, "port", usbBridgeDefaultPort, "TCP port of the proxy on this computer's USB address") cmd.Flags().BoolVar(&keep, "keep-proxy", false, "leave the pod's cloud proxy set when the bridge stops") + cmd.Flags().BoolVar(&watch, "watch", false, "wait for the pod and survive unplugging it (for running in the background)") + cmd.AddCommand(newUSBBridgeInstallCmd(g), newUSBBridgeUninstallCmd(g), newUSBBridgeStatusCmd(g)) return cmd } -func runUSBBridge(g *globalFlags, port int, keep bool) error { - if port <= 0 || port > 65535 { - return fmt.Errorf("usb-bridge: --port %d is out of range", port) - } +// usbBridgeRetry is how often --watch looks for the pod again. +const usbBridgeRetry = 10 * time.Second + +// bridgeSession is one bridge run: the pod's USB link, the proxy's listener on this computer's +// end of it, and the console device it was set up through. +type bridgeSession struct { + u serialconsole.USBNet + proxyAddr string + ln net.Listener + device string +} + +// setupBridge checks the pod's USB link, listens on this computer's end of it and points the +// pod's cloud proxy there (unless it already is). The console is closed again before it returns. +func setupBridge(g *globalFlags, port int) (*bridgeSession, error) { device, err := g.usbDevice("usb-bridge") if err != nil { - return err + return nil, err } console, path, ctx, cancel, err := g.openSerialConsole(device, 15*time.Second) if err != nil { - return err + return nil, err } + defer cancel() + defer console.Close() u, err := console.USBNetwork(ctx) if err == nil { err = usbBridgeReady(u) } if err != nil { - console.Close() - cancel() - return fmt.Errorf("usb-bridge: %w", err) + return nil, err } proxyAddr := net.JoinHostPort(u.HostIP, strconv.Itoa(port)) cur, err := console.CloudProxy(ctx) if err != nil { - console.Close() - cancel() - return fmt.Errorf("usb-bridge: read the pod's cloud proxy: %w", err) + return nil, fmt.Errorf("read the pod's cloud proxy: %w", err) } if cur.Addr != "" && cur.Addr != proxyAddr { - console.Close() - cancel() - return fmt.Errorf("usb-bridge: the pod already has a cloud proxy (%s); the bridge would replace it. "+ + return nil, fmt.Errorf("the pod already has a cloud proxy (%s); the bridge would replace it. "+ "Remove it first with `benchpod cloud proxy clear --connection usb`", cur.Addr) } - ln, err := net.Listen("tcp", proxyAddr) if err != nil { - console.Close() - cancel() - return fmt.Errorf("usb-bridge: listen on %s, this computer's end of the USB link: %w "+ + return nil, fmt.Errorf("listen on %s, this computer's end of the USB link: %w "+ "(if the adapter has no address yet, run `benchpod usb-reattach`)", proxyAddr, err) } - if _, err := console.CloudProxySet(ctx, proxyAddr, "", ""); err != nil { - ln.Close() - console.Close() - cancel() - return fmt.Errorf("usb-bridge: set the pod's cloud proxy: %w", err) + if cur.Addr != proxyAddr { + if _, err := console.CloudProxySet(ctx, proxyAddr, "", ""); err != nil { + ln.Close() + return nil, fmt.Errorf("set the pod's cloud proxy: %w", err) + } } - // Free the console for other commands while the bridge runs. - console.Close() - cancel() - fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s. Ctrl-C stops it.\n", u.PodIP, proxyAddr) + return &bridgeSession{u: u, proxyAddr: proxyAddr, ln: ln, device: path}, nil +} - runCtx, stop := context.WithCancel(context.Background()) - defer stop() - defer installSignalHandler(runCtx, stop)() - go func() { <-runCtx.Done(); ln.Close() }() - p := &connectProxy{allowFrom: u.PodIP, allowPort: usbBridgeAllowedPort, logf: log.Printf} - p.serve(ln) +// hostHasIP reports whether a local interface holds ip: the USB link is gone when it does not. +var hostHasIP = func(ip string) bool { + addrs, err := net.InterfaceAddrs() + if err != nil { + return true // cannot tell: keep serving + } + for _, a := range addrs { + if ipn, ok := a.(*net.IPNet); ok && ipn.IP.String() == ip { + return true + } + } + return false +} + +// serveBridge runs the proxy until ctx ends (true) or the USB link goes away (false). +func serveBridge(ctx context.Context, s *bridgeSession) bool { + gone := make(chan struct{}) + done := make(chan struct{}) + go func() { + t := time.NewTicker(5 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + s.ln.Close() + return + case <-done: + return + case <-t.C: + if !hostHasIP(s.u.HostIP) { + close(gone) + s.ln.Close() + return + } + } + } + }() + p := &connectProxy{allowFrom: s.u.PodIP, allowPort: usbBridgeAllowedPort, logf: log.Printf} + p.serve(s.ln) + close(done) p.wait() + select { + case <-gone: + return false + default: + return ctx.Err() != nil + } +} + +func runUSBBridge(g *globalFlags, port int, keep, watch bool) error { + if port <= 0 || port > 65535 { + return fmt.Errorf("usb-bridge: --port %d is out of range", port) + } + ctx, stop := context.WithCancel(context.Background()) + defer stop() + defer installSignalHandler(ctx, stop)() + + var last *bridgeSession + lastErr := "" + for { + s, err := setupBridge(g, port) + if err != nil { + if !watch { + return fmt.Errorf("usb-bridge: %w", err) + } + if err.Error() != lastErr { // one line per new reason, not one per retry + log.Printf("usb-bridge: waiting for the pod: %v", err) + lastErr = err.Error() + } + select { + case <-ctx.Done(): + return nil + case <-time.After(usbBridgeRetry): + } + continue + } + lastErr = "" + last = s + fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s. Ctrl-C stops it.\n", s.u.PodIP, s.proxyAddr) + if serveBridge(ctx, s) { + break // stopped + } + log.Printf("usb-bridge: the USB link to the pod (%s) went away", s.u.PodIP) + if !watch { + return errors.New("usb-bridge: the USB link to the pod went away") + } + } if keep { - fmt.Fprintf(os.Stderr, "usb-bridge: stopped; the pod's cloud proxy stays %s (--keep-proxy)\n", proxyAddr) + fmt.Fprintf(os.Stderr, "usb-bridge: stopped; the pod's cloud proxy stays %s (--keep-proxy)\n", last.proxyAddr) return nil } - console, _, ctx, cancel, err = g.openSerialConsole(path, 15*time.Second) + console, _, cctx, cancel, err := g.openSerialConsole(last.device, 15*time.Second) if err != nil { return fmt.Errorf("usb-bridge: stopped, but could not clear the pod's cloud proxy (%w); "+ "run `benchpod cloud proxy clear --connection usb`", err) } defer cancel() defer console.Close() - if _, err := console.CloudProxyClear(ctx); err != nil { + if _, err := console.CloudProxyClear(cctx); err != nil { return fmt.Errorf("usb-bridge: stopped, but clearing the pod's cloud proxy failed: %w", err) } fmt.Fprintln(os.Stderr, "usb-bridge: stopped; cleared the pod's cloud proxy") diff --git a/cmd/benchpod-cli/usbbridge_service.go b/cmd/benchpod-cli/usbbridge_service.go new file mode 100644 index 0000000..e1d2c4d --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_service.go @@ -0,0 +1,375 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "html" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// The usb-bridge background service: `usb-bridge --watch` started at login, per user, so a pod +// that has only USB keeps its cloud link (and so the web app) after this computer restarts. +// +// macOS a launchd agent: ~/Library/LaunchAgents/com.embeddedci.benchpod-usb-bridge.plist +// Linux a systemd user unit: ~/.config/systemd/user/benchpod-usb-bridge.service +// Windows a scheduled task "BenchPod usb-bridge" that runs at logon +// +// Only the --watch mode runs there: it waits for the pod and picks it up again after an unplug. + +const ( + usbBridgeLabel = "com.embeddedci.benchpod-usb-bridge" + usbBridgeUnit = "benchpod-usb-bridge.service" + usbBridgeTaskName = "BenchPod usb-bridge" +) + +// runCommand runs a service manager command. A variable so tests can replace it. +var runCommand = func(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// serviceOS is runtime.GOOS; a variable for tests. +var serviceOS = runtime.GOOS + +// bridgeServiceArgs is the command line the service runs. +func bridgeServiceArgs(exe, conn string, port int) []string { + args := []string{exe, "usb-bridge", "--watch"} + if conn != "" { + args = append(args, "--connection", conn) + } + if port != usbBridgeDefaultPort { + args = append(args, "--port", strconv.Itoa(port)) + } + return args +} + +// benchpodExecutable is the path the service starts: the one on PATH when it is this binary +// (Homebrew's link survives upgrades; the Cellar path behind it does not), else this binary. +func benchpodExecutable() (string, error) { + self, err := os.Executable() + if err != nil { + return "", err + } + if onPath, err := exec.LookPath("benchpod"); err == nil { + a, _ := filepath.EvalSymlinks(onPath) + b, _ := filepath.EvalSymlinks(self) + if a != "" && a == b { + return filepath.Abs(onPath) + } + } + return self, nil +} + +func launchdPlist(args []string, logPath string) string { + var b bytes.Buffer + b.WriteString(` + + + + Label + ` + usbBridgeLabel + ` + ProgramArguments + +`) + for _, a := range args { + b.WriteString("\t\t" + html.EscapeString(a) + "\n") + } + b.WriteString(` + RunAtLoad + + KeepAlive + + StandardOutPath + ` + html.EscapeString(logPath) + ` + StandardErrorPath + ` + html.EscapeString(logPath) + ` + + +`) + return b.String() +} + +func systemdUnit(args []string) string { + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = strconv.Quote(a) + } + return `[Unit] +Description=BenchPod usb-bridge: the pod's cloud link over USB through this computer +After=network-online.target + +[Service] +ExecStart=` + strings.Join(quoted, " ") + ` +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target +` +} + +// windowsTaskRun is the /TR of the scheduled task: conhost --headless keeps a console window +// from opening at every logon (Windows 10 1809 and later). +func windowsTaskRun(args []string) string { + quoted := make([]string, len(args)) + for i, a := range args { + if strings.ContainsAny(a, " \t") { + a = `"` + a + `"` + } + quoted[i] = a + } + return "conhost.exe --headless " + strings.Join(quoted, " ") +} + +func homePath(parts ...string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(append([]string{home}, parts...)...), nil +} + +func installBridgeService(args []string) (string, error) { + switch serviceOS { + case "darwin": + plist, err := homePath("Library", "LaunchAgents", usbBridgeLabel+".plist") + if err != nil { + return "", err + } + logPath, err := homePath("Library", "Logs", "benchpod-usb-bridge.log") + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(plist), 0o755); err != nil { + return "", err + } + _, _ = runCommand("launchctl", "bootout", launchdTarget()) // replace an older install + if err := os.WriteFile(plist, []byte(launchdPlist(args, logPath)), 0o644); err != nil { + return "", err + } + if out, err := runCommand("launchctl", "bootstrap", launchdDomain(), plist); err != nil { + return "", fmt.Errorf("launchctl bootstrap: %v: %s", err, out) + } + return fmt.Sprintf("installed the launchd agent %s (log: %s)", plist, logPath), nil + case "linux": + unit, err := homePath(".config", "systemd", "user", usbBridgeUnit) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(unit), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(unit, []byte(systemdUnit(args)), 0o644); err != nil { + return "", err + } + if out, err := runCommand("systemctl", "--user", "daemon-reload"); err != nil { + return "", fmt.Errorf("systemctl --user daemon-reload: %v: %s", err, out) + } + if out, err := runCommand("systemctl", "--user", "enable", "--now", usbBridgeUnit); err != nil { + return "", fmt.Errorf("systemctl --user enable --now: %v: %s", err, out) + } + return fmt.Sprintf("installed the systemd user unit %s (log: journalctl --user -u %s). "+ + "To keep it running while you are logged out: loginctl enable-linger", unit, usbBridgeUnit), nil + case "windows": + if out, err := runCommand("schtasks", "/Create", "/TN", usbBridgeTaskName, "/TR", windowsTaskRun(args), + "/SC", "ONLOGON", "/RL", "LIMITED", "/F"); err != nil { + return "", fmt.Errorf("schtasks /Create: %v: %s", err, out) + } + if out, err := runCommand("schtasks", "/Run", "/TN", usbBridgeTaskName); err != nil { + return "", fmt.Errorf("schtasks /Run: %v: %s", err, out) + } + return fmt.Sprintf("installed the scheduled task %q (runs at logon). Windows Defender Firewall must let "+ + "the pod in: allow benchpod when it asks, or as administrator: netsh advfirewall firewall add rule "+ + "name=\"BenchPod usb-bridge\" dir=in action=allow protocol=TCP localport=%d remoteip=10.237.0.0/16", + usbBridgeTaskName, usbBridgeDefaultPort), nil + } + return "", fmt.Errorf("usb-bridge install is not supported on %s; run `benchpod usb-bridge --watch` yourself", serviceOS) +} + +func uninstallBridgeService() (string, error) { + switch serviceOS { + case "darwin": + plist, err := homePath("Library", "LaunchAgents", usbBridgeLabel+".plist") + if err != nil { + return "", err + } + _, _ = runCommand("launchctl", "bootout", launchdTarget()) + if err := os.Remove(plist); err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + return "removed the launchd agent", nil + case "linux": + unit, err := homePath(".config", "systemd", "user", usbBridgeUnit) + if err != nil { + return "", err + } + _, _ = runCommand("systemctl", "--user", "disable", "--now", usbBridgeUnit) + if err := os.Remove(unit); err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + _, _ = runCommand("systemctl", "--user", "daemon-reload") + return "removed the systemd user unit", nil + case "windows": + _, _ = runCommand("schtasks", "/End", "/TN", usbBridgeTaskName) + if out, err := runCommand("schtasks", "/Delete", "/TN", usbBridgeTaskName, "/F"); err != nil && + !strings.Contains(strings.ToLower(out), "cannot find") { + return "", fmt.Errorf("schtasks /Delete: %v: %s", err, out) + } + return "removed the scheduled task", nil + } + return "", fmt.Errorf("usb-bridge uninstall is not supported on %s", serviceOS) +} + +// bridgeServiceStatus says whether the service is installed and running. +func bridgeServiceStatus() (installed, running bool, detail string, err error) { + switch serviceOS { + case "darwin": + plist, err := homePath("Library", "LaunchAgents", usbBridgeLabel+".plist") + if err != nil { + return false, false, "", err + } + if _, err := os.Stat(plist); err != nil { + return false, false, "", nil + } + out, err := runCommand("launchctl", "print", launchdTarget()) + return true, err == nil && strings.Contains(out, "state = running"), plist, nil + case "linux": + unit, err := homePath(".config", "systemd", "user", usbBridgeUnit) + if err != nil { + return false, false, "", err + } + if _, err := os.Stat(unit); err != nil { + return false, false, "", nil + } + out, _ := runCommand("systemctl", "--user", "is-active", usbBridgeUnit) + return true, out == "active", unit, nil + case "windows": + out, err := runCommand("schtasks", "/Query", "/TN", usbBridgeTaskName, "/FO", "LIST") + if err != nil { + return false, false, "", nil + } + return true, strings.Contains(out, "Running"), usbBridgeTaskName, nil + } + return false, false, "", fmt.Errorf("usb-bridge status is not supported on %s", serviceOS) +} + +func launchdDomain() string { return "gui/" + strconv.Itoa(os.Getuid()) } +func launchdTarget() string { return launchdDomain() + "/" + usbBridgeLabel } + +func newUSBBridgeInstallCmd(g *globalFlags) *cobra.Command { + var port int + cmd := &cobra.Command{ + Use: "install", + Short: "Run usb-bridge in the background at every login (this user)", + Long: "Install `benchpod usb-bridge --watch` as a background service for this user: a launchd\n" + + "agent on macOS, a systemd user unit on Linux, a scheduled task at logon on Windows.\n" + + "It waits for the pod on USB, carries its cloud link while it is plugged in, and comes\n" + + "back after a restart. --connection is passed on (usb: for one of several pods).", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if port <= 0 || port > 65535 { + return fmt.Errorf("usb-bridge install: --port %d is out of range", port) + } + exe, err := benchpodExecutable() + if err != nil { + return fmt.Errorf("usb-bridge install: %w", err) + } + msg, err := installBridgeService(bridgeServiceArgs(exe, strings.TrimSpace(g.connection), port)) + if err != nil { + return fmt.Errorf("usb-bridge install: %w", err) + } + fmt.Fprintln(os.Stderr, "usb-bridge: "+msg) + return nil + }, + } + cmd.Flags().IntVar(&port, "port", usbBridgeDefaultPort, "TCP port of the proxy on this computer's USB address") + return cmd +} + +func newUSBBridgeUninstallCmd(g *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "uninstall", + Short: "Remove the usb-bridge background service and clear the pod's cloud proxy", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + msg, err := uninstallBridgeService() + if err != nil { + return fmt.Errorf("usb-bridge uninstall: %w", err) + } + fmt.Fprintln(os.Stderr, "usb-bridge: "+msg) + // The stopped service may not have cleared the proxy (the pod unplugged, or killed). + time.Sleep(time.Second) + if err := clearBridgeProxy(g); err != nil { + fmt.Fprintf(os.Stderr, "usb-bridge: could not clear the pod's cloud proxy (%v); "+ + "run `benchpod cloud proxy clear --connection usb` with the pod plugged in\n", err) + } + return nil + }, + } +} + +// clearBridgeProxy clears the pod's cloud proxy when it points at a bridge on this computer. +func clearBridgeProxy(g *globalFlags) error { + device, err := g.usbDevice("usb-bridge uninstall") + if err != nil { + return err + } + console, _, ctx, cancel, err := g.openSerialConsole(device, 15*time.Second) + if err != nil { + return err + } + defer cancel() + defer console.Close() + u, err := console.USBNetwork(ctx) + if err != nil { + return err + } + cur, err := console.CloudProxy(ctx) + if err != nil { + return err + } + host := cur.Addr + if i := strings.LastIndex(host, ":"); i >= 0 { + host = host[:i] + } + if cur.Addr == "" || host != u.HostIP { + return nil // not ours + } + if _, err := console.CloudProxyClear(ctx); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "usb-bridge: cleared the pod's cloud proxy") + return nil +} + +func newUSBBridgeStatusCmd(_ *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show whether the usb-bridge background service is installed and running", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + installed, running, detail, err := bridgeServiceStatus() + if err != nil { + return fmt.Errorf("usb-bridge status: %w", err) + } + switch { + case !installed: + fmt.Println("usb-bridge: not installed (`benchpod usb-bridge install` installs it)") + case running: + fmt.Printf("usb-bridge: installed and running (%s)\n", detail) + default: + fmt.Printf("usb-bridge: installed, not running (%s)\n", detail) + } + return nil + }, + } +} diff --git a/cmd/benchpod-cli/usbbridge_service_test.go b/cmd/benchpod-cli/usbbridge_service_test.go new file mode 100644 index 0000000..9ad9d46 --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_service_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBridgeServiceArgs(t *testing.T) { + got := strings.Join(bridgeServiceArgs("/opt/homebrew/bin/benchpod", "usb:benchpod-baea06", 3129), " ") + if got != "/opt/homebrew/bin/benchpod usb-bridge --watch --connection usb:benchpod-baea06 --port 3129" { + t.Fatalf("args = %q", got) + } + if got := bridgeServiceArgs("/b", "", usbBridgeDefaultPort); len(got) != 3 { + t.Fatalf("defaults add no flags: %v", got) + } +} + +func TestServiceDefinitions(t *testing.T) { + args := []string{"/Applications/My Tools/benchpod", "usb-bridge", "--watch"} + plist := launchdPlist(args, "/Users/me/Library/Logs/b.log") + for _, want := range []string{"" + usbBridgeLabel + "", "/Applications/My Tools/benchpod", + "--watch", "KeepAlive", "RunAtLoad"} { + if !strings.Contains(plist, want) { + t.Errorf("plist lacks %q", want) + } + } + unit := systemdUnit(args) + if !strings.Contains(unit, `ExecStart="/Applications/My Tools/benchpod" "usb-bridge" "--watch"`) || !strings.Contains(unit, "Restart=always") { + t.Errorf("unit:\n%s", unit) + } + if got := windowsTaskRun([]string{`C:\Program Files\benchpod.exe`, "usb-bridge", "--watch"}); got != `conhost.exe --headless "C:\Program Files\benchpod.exe" usb-bridge --watch` { + t.Errorf("task run = %q", got) + } +} + +func TestInstallBridgeServiceLinux(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + var ran []string + oldRun, oldOS := runCommand, serviceOS + runCommand = func(name string, args ...string) (string, error) { + ran = append(ran, name+" "+strings.Join(args, " ")) + if len(args) > 1 && args[1] == "is-active" { + return "active", nil + } + return "", nil + } + serviceOS = "linux" + t.Cleanup(func() { runCommand, serviceOS = oldRun, oldOS }) + + if _, err := installBridgeService([]string{"/usr/bin/benchpod", "usb-bridge", "--watch"}); err != nil { + t.Fatal(err) + } + unit := filepath.Join(home, ".config", "systemd", "user", usbBridgeUnit) + if _, err := os.Stat(unit); err != nil { + t.Fatalf("unit not written: %v", err) + } + if !strings.Contains(strings.Join(ran, "\n"), "systemctl --user enable --now "+usbBridgeUnit) { + t.Fatalf("ran %v", ran) + } + installed, running, _, err := bridgeServiceStatus() + if err != nil || !installed || !running { + t.Fatalf("status installed=%v running=%v err=%v", installed, running, err) + } + if _, err := uninstallBridgeService(); err != nil { + t.Fatal(err) + } + if installed, _, _, _ := bridgeServiceStatus(); installed { + t.Fatal("still installed after uninstall") + } +} From b2d678cc636ceb813ccd4dfaa76aa5ec42ff9947 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:10:44 -0500 Subject: [PATCH 07/29] Linux udev rule that keeps ModemManager off the pod's console packaging/linux/70-benchpod.rules sets ID_MM_DEVICE_IGNORE for 1209:0001 (firmware 3.9.0+) and 0483:5740 (older firmware); the release archives ship it and the README says how to install it. Co-Authored-By: Claude Opus 5.5 --- .goreleaser.yaml | 1 + README.md | 17 +++++++++++++++++ packaging/linux/70-benchpod.rules | 6 ++++++ 3 files changed, 24 insertions(+) create mode 100644 packaging/linux/70-benchpod.rules diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 60e4c43..58d4152 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -74,6 +74,7 @@ archives: files: - README.md - LICENSE + - packaging/linux/70-benchpod.rules checksum: name_template: checksums.txt diff --git a/README.md b/README.md index 7330c95..7d843c8 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,23 @@ You need Go (see `go.mod` for the version), then `make build`. On macOS the build requires cgo (`CGO_ENABLED=1`, the default for a native build) because USB serial-port enumeration uses the IOKit framework; Linux and Windows are pure Go. +### Linux: keep ModemManager off the pod + +On Linux, ModemManager probes new USB serial ports as if they were modems, which +garbles the pod's USB console for its first seconds. The release archives include +`packaging/linux/70-benchpod.rules`; install it once: + +```bash +sudo cp packaging/linux/70-benchpod.rules /etc/udev/rules.d/ +``` + +```bash +sudo udevadm control --reload-rules && sudo udevadm trigger +``` + +It sets `ENV{ID_MM_DEVICE_IGNORE}="1"` for the pod's USB IDs: `1209:0001` (firmware +3.9.0 and later) and `0483:5740` (older firmware). + ### OpenOCD (for `flash`) The `flash` command shells out to **OpenOCD**, which must be installed diff --git a/packaging/linux/70-benchpod.rules b/packaging/linux/70-benchpod.rules new file mode 100644 index 0000000..c6e6cf9 --- /dev/null +++ b/packaging/linux/70-benchpod.rules @@ -0,0 +1,6 @@ +# BenchPod: keep ModemManager off the pod's USB console. +# Install: sudo cp 70-benchpod.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules && sudo udevadm trigger +# Firmware 3.9.0 and later (pid.codes VID 1209; PID 0001 is the test ID until ours is assigned). +ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0001", ENV{ID_MM_DEVICE_IGNORE}="1" +# Older firmware (ST's stock CDC ID). +ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ENV{ID_MM_DEVICE_IGNORE}="1" From e211a7e81b812face5fd71e69e15b8e0a762d1f7 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:13:11 -0500 Subject: [PATCH 08/29] status: USB line with the USB network and a weak-port warning; usb uses the network status shows the pod's USB network address and warns when the USB-C port powering the pod advertises only default USB power (500 mA). status over usb now asks over the USB network adapter when it is up (the full summary) and falls back to the console's text. networkTarget probes the console once per run. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++- cmd/benchpod-cli/root.go | 2 ++ cmd/benchpod-cli/status.go | 45 +++++++++++++++++++++++++++++++-- cmd/benchpod-cli/usbnet.go | 4 +++ cmd/benchpod-cli/usbnet_test.go | 14 ++++++++++ 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7d843c8..91ef4e5 100644 --- a/README.md +++ b/README.md @@ -692,7 +692,11 @@ release exists, when GitHub answers within a few seconds), gateware, board, the I/O (LA) voltage or "not set", target power (the internal 5 V and external eFuses), the network address and link, the cloud registration (and, when this machine is signed in, whether the pod is on your account), and a cloud job's lease when one -holds the pod. Over USB it prints the console's `status` text. +holds the pod. With firmware 3.9.0+ a `USB` line shows the pod's USB network address +and warns when the USB port powering the pod offers only default USB power (500 mA), +which may not carry the pod plus a DUT on the internal 5 V rail. Over USB it asks over +the pod's USB network adapter when that is up, else it prints the console's `status` +text. ```text BenchPod at 192.168.1.220:8080 diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index 05afd31..fa53ab0 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -18,6 +18,8 @@ type globalFlags struct { configFile string outputFilename string timeout time.Duration + + usbAddr string // the pod's USB network address, once networkTarget found it (one probe per run) } // effectiveTimeout returns the explicit --timeout when set (> 0), else the diff --git a/cmd/benchpod-cli/status.go b/cmd/benchpod-cli/status.go index dd86931..86bc701 100644 --- a/cmd/benchpod-cli/status.go +++ b/cmd/benchpod-cli/status.go @@ -25,7 +25,8 @@ func newStatusCmd(g *globalFlags) *cobra.Command { "network address, whether it is registered with embeddedci.com and, when this\n" + "machine is signed in, whether it is on your account, and a cloud job's lease.\n\n" + "--json prints the pod's raw `status` reply instead (network and embeddedci.com).\n" + - "Over USB (--connection usb) this prints the console's `status` text. With\n" + + "Over USB (--connection usb) it asks over the pod's USB network adapter when that is\n" + + "up, else it prints the console's `status` text. With\n" + "--connection embeddedci: it asks the pod through embeddedci.com.", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { @@ -34,7 +35,10 @@ func newStatusCmd(g *globalFlags) *cobra.Command { return err } if spec.IsSerial() { - return runStatusSerial(g, spec.Device) + // The full status over the pod's USB network adapter when it is up; else the console's. + if _, err := g.networkTarget("status"); err != nil { + return runStatusSerial(g, spec.Device) + } } ctx, cancel, pod, err := g.podClient("status", 30*time.Second) @@ -109,6 +113,7 @@ type podStatus struct { 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"` Holder string `json:"holder"` @@ -116,6 +121,39 @@ type podStatus struct { } `json:"lease"` } +// podUSB is status.usb: the USB network adapter and what the USB-C source advertises. +type podUSB struct { + Net string `json:"net"` + Link bool `json:"link"` + IP string `json:"ip"` + HostIP string `json:"host_ip"` + Source string `json:"source"` // "none", "default", "1.5A", "3.0A"; "" on v2 + SourceMA *int `json:"source_ma"` // nil on v2 +} + +// describeUSB is the status row for the USB side, with a warning when the port powering the pod +// offers only default USB power. +func describeUSB(u podUSB) string { + var parts []string + switch { + case u.Net == "off": + parts = append(parts, "network off (Wi-Fi is configured)") + case u.Link: + parts = append(parts, "network "+u.IP+" (this computer "+u.HostIP+")") + default: + parts = append(parts, "network "+u.IP+", not connected") + } + switch u.Source { + case "default": + parts = append(parts, "power: this USB port offers only default USB power (500 mA), which may not "+ + "carry the pod plus a DUT on the internal 5 V rail; use a USB-C port or charger with 1.5 A or more, "+ + "or power the DUT externally") + case "1.5A", "3.0A": + parts = append(parts, "power "+u.Source) + } + return strings.Join(parts, "; ") +} + // targetPower is the firmware's `target_status` reply: eFuse 1 is the internal 5 V rail, eFuse 2 // the external supply. type targetPower struct { @@ -209,6 +247,9 @@ func printStatusSummary(out io.Writer, s statusSummary) { row("Target power", describeTargetPower(*s.power)) } row("Network", describeNetwork(st)) + if st.USB != nil { + row("USB", describeUSB(*st.USB)) + } row("Cloud", describeStatusCloud(st, s.cloud, s.account)) if st.Lease != nil && st.Lease.Held { row("Lease", fmt.Sprintf("held by cloud job %s (%d s left); the LAN is read-only until it ends", diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go index 9d7d8e6..ad34236 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -77,6 +77,9 @@ func (g *globalFlags) networkTarget(cmd string) (ConnSpec, error) { if err != nil || !spec.IsSerial() { return spec, err } + if g.usbAddr != "" { + return ConnSpec{Kind: connNetwork, Addr: g.usbAddr}, nil + } addr, uerr := g.usbNetAddr(spec.Device) if errors.Is(uerr, tcpclient.ErrUnreachable) { return spec, uerr @@ -84,5 +87,6 @@ func (g *globalFlags) networkTarget(cmd string) (ConnSpec, error) { if uerr != nil { return spec, fmt.Errorf("%w. Here: %v", spec.RequireNetwork(cmd), uerr) } + g.usbAddr = addr return ConnSpec{Kind: connNetwork, Addr: addr}, nil } diff --git a/cmd/benchpod-cli/usbnet_test.go b/cmd/benchpod-cli/usbnet_test.go index 64ed308..e77221b 100644 --- a/cmd/benchpod-cli/usbnet_test.go +++ b/cmd/benchpod-cli/usbnet_test.go @@ -83,3 +83,17 @@ func TestPreferredPodIPPicksTheUSBLink(t *testing.T) { t.Fatal("no addresses: want nil") } } + +func TestDescribeUSBWarnsAboutDefaultPower(t *testing.T) { + ma := 500 + got := describeUSB(podUSB{Net: "on", Link: true, IP: "10.237.8.85", HostIP: "10.237.8.86", Source: "default", SourceMA: &ma}) + if !strings.Contains(got, "10.237.8.85") || !strings.Contains(got, "500 mA") || !strings.Contains(got, "1.5 A") { + t.Fatalf("got %q", got) + } + if got := describeUSB(podUSB{Net: "on", Source: "3.0A"}); strings.Contains(got, "500 mA") || !strings.Contains(got, "power 3.0A") { + t.Fatalf("3.0A: %q", got) + } + if got := describeUSB(podUSB{Net: "off"}); !strings.Contains(got, "Wi-Fi") { + t.Fatalf("off: %q", got) + } +} From 900ed649d08be411f4a9fbd7e101094af10513d7 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:18:40 -0500 Subject: [PATCH 09/29] 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 3011984c12c2e3129e32c6966505a5b3ad3b0591 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:21:02 -0500 Subject: [PATCH 10/29] status: power draw row from power_status, with the pod's own current monitor benchpod status reads power_status: the current and voltage of each rail that is on and, on boards with the pod's 0x41 monitor, the pod's own draw and the total it takes from USB. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ cmd/benchpod-cli/status.go | 50 +++++++++++++++++++++++++++++++++ cmd/benchpod-cli/usbnet_test.go | 23 +++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/README.md b/README.md index 91ef4e5..6f67091 100644 --- a/README.md +++ b/README.md @@ -690,6 +690,8 @@ A short summary of the pod over the network or through embeddedci.com (`--connection embeddedci:`): firmware version (and whether a newer release exists, when GitHub answers within a few seconds), gateware, board, the board I/O (LA) voltage or "not set", target power (the internal 5 V and external eFuses), +the power draw (each rail that is on and, on boards with the pod's own current monitor, +the pod's draw and the total it takes from USB), the network address and link, the cloud registration (and, when this machine is signed in, whether the pod is on your account), and a cloud job's lease when one holds the pod. With firmware 3.9.0+ a `USB` line shows the pod's USB network address diff --git a/cmd/benchpod-cli/status.go b/cmd/benchpod-cli/status.go index 86bc701..2fbdd62 100644 --- a/cmd/benchpod-cli/status.go +++ b/cmd/benchpod-cli/status.go @@ -154,6 +154,42 @@ func describeUSB(u podUSB) string { return strings.Join(parts, "; ") } +// powerDraw is the firmware's `power_status` reply: the rails' monitors and, on boards with the +// pod's own current monitor (0x41), the pod's draw and the total from its 5 V input. +type powerDraw struct { + Internal powerRail `json:"internal"` + External powerRail `json:"external"` + Pod *struct { + powerRail + TotalUA *int `json:"total_ua"` + } `json:"pod"` +} + +type powerRail struct { + OK bool `json:"ok"` + BusMV int `json:"bus_mv"` + CurrentUA int `json:"current_ua"` +} + +// describePowerDraw: the rails that are on, then the pod's own draw and the total. +func describePowerDraw(d powerDraw, tp *targetPower) string { + ma := func(ua int) string { return fmt.Sprintf("%d mA", (ua+500)/1000) } + var parts []string + if d.Internal.OK && (tp == nil || tp.Efuse1.Enabled != 0) { + parts = append(parts, fmt.Sprintf("internal 5 V rail %s at %s", ma(d.Internal.CurrentUA), formatMV(d.Internal.BusMV))) + } + if d.External.OK && (tp == nil || tp.Efuse2.Enabled != 0) { + parts = append(parts, fmt.Sprintf("external rail %s at %s", ma(d.External.CurrentUA), formatMV(d.External.BusMV))) + } + if d.Pod != nil && d.Pod.OK { + parts = append(parts, "pod "+ma(d.Pod.CurrentUA)) + if d.Pod.TotalUA != nil { + parts = append(parts, "total from USB "+ma(*d.Pod.TotalUA)) + } + } + return strings.Join(parts, ", ") +} + // targetPower is the firmware's `target_status` reply: eFuse 1 is the internal 5 V rail, eFuse 2 // the external supply. type targetPower struct { @@ -174,6 +210,7 @@ type statusSummary struct { st podStatus cloud *cloudState power *targetPower + draw *powerDraw account map[string]string // signed-in account's devices by id, nil = not signed in / unknown latest string // latest firmware release tag, "" = unknown } @@ -191,6 +228,14 @@ func gatherStatusExtras(ctx context.Context, client podCommander, sum *statusSum } cancel() cctx, cancel = context.WithTimeout(ctx, 5*time.Second) + if raw, err := client.Command(cctx, map[string]any{"cmd": "power_status"}); err == nil { + var d powerDraw + if json.Unmarshal(raw, &d) == nil { + sum.draw = &d + } + } + cancel() + cctx, cancel = context.WithTimeout(ctx, 5*time.Second) if raw, err := client.Command(cctx, map[string]any{"cmd": "target_status"}); err == nil { var p targetPower if json.Unmarshal(raw, &p) == nil { @@ -246,6 +291,11 @@ func printStatusSummary(out io.Writer, s statusSummary) { if s.power != nil { row("Target power", describeTargetPower(*s.power)) } + if s.draw != nil { + if d := describePowerDraw(*s.draw, s.power); d != "" { + row("Power draw", d) + } + } row("Network", describeNetwork(st)) if st.USB != nil { row("USB", describeUSB(*st.USB)) diff --git a/cmd/benchpod-cli/usbnet_test.go b/cmd/benchpod-cli/usbnet_test.go index e77221b..2056b82 100644 --- a/cmd/benchpod-cli/usbnet_test.go +++ b/cmd/benchpod-cli/usbnet_test.go @@ -97,3 +97,26 @@ func TestDescribeUSBWarnsAboutDefaultPower(t *testing.T) { t.Fatalf("off: %q", got) } } + +func TestDescribePowerDraw(t *testing.T) { + total := 373300 + d := powerDraw{Internal: powerRail{OK: true, BusMV: 5008, CurrentUA: 142300}, External: powerRail{OK: true}} + d.Pod = &struct { + powerRail + TotalUA *int `json:"total_ua"` + }{powerRail{OK: true, BusMV: 5012, CurrentUA: 231000}, &total} + tp := &targetPower{} + tp.Efuse1.Enabled = 1 + got := describePowerDraw(d, tp) + for _, want := range []string{"internal 5 V rail 142 mA", "pod 231 mA", "total from USB 373 mA"} { + if !strings.Contains(got, want) { + t.Errorf("%q lacks %q", got, want) + } + } + if strings.Contains(got, "external") { + t.Errorf("an off rail is listed: %q", got) + } + if got := describePowerDraw(powerDraw{}, nil); got != "" { + t.Errorf("nothing readable: %q", got) + } +} From 7299d8841910cd39ba66a8935b19ace679cfb0a4 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:39:45 -0500 Subject: [PATCH 11/29] 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 12/29] 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 13/29] 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 14/29] 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)") From d0d968fba32b861ddce47a4ba004e27f5ff0025e Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:46:19 -0500 Subject: [PATCH 15/29] README: compatibility table (old/new firmware x old/new CLI), Windows 10 note The CLI decides per pod from what it reports, never from its own version. Windows 10 (1903+) is supported but not tested, with the manual driver pick (UsbNcm Host Device) as the fallback. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 6f67091..7143308 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,19 @@ command says the adapter is not up, unlock the screen (macOS ignores a new adapt while it is locked) and run `benchpod usb-reattach`. `benchpod usb-bridge` carries the pod's cloud link over the same cable. +The USB network adapter works with the in-box drivers of macOS, Linux and Windows 11 +(tested). Windows 10 (1903 or newer) is supported but not tested: if the adapter shows +up without a driver there, open Device Manager, update its driver, and pick "UsbNcm +Host Device" from the list of network adapters. + +**Compatibility with older firmware and older CLIs.** The CLI decides per pod, from what +the pod reports (the `usb` line of its console `status`), never from its own version: + +| | Pod firmware before 3.9.0 | Pod firmware 3.9.0 and later | +|---|---|---| +| **This CLI (0.6.0+)** | Works as before: the USB console for `status`, `la voltage`, `install-blobs`, `flash-self`, `set-wifi`, the policies, `cloud ca`/`proxy` and `identity`; the network commands need the pod's network address, as before, and say so. The serial auto-detect still finds `0483:5740` and `2E8A` consoles. | Over `usb`, every command uses the pod's USB network adapter when it is up (including `flash`, which the STM32 pod's console never ran), and the console for the commands that stay there (and as the fallback). | +| **An older CLI (0.5.x)** | Unchanged. | The kept console commands keep their names and replies (`status`, `dfu`, `identity`, `identity wipe`, `lan-policy`, `sig-policy`, `set-wifi`, `show-network`, `clear-wifi`, `install-blobs`, `flash-self`, `cloud ca`/`proxy`), so those still work. A command 3.9.0 moved to the network API (`la voltage` over USB) prints the pod's "moved to the network API ... update the benchpod CLI to 0.6.0 or later" line. Over a network address everything works. | + Which command works over which connection: | Connection | Commands | From 1b690d01bad9d8ff8fea099c2f44df92e4113f6a Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:23:58 -0500 Subject: [PATCH 16/29] usb: fall back to the pod's network address when its USB network is off On firmware 3.9.0+ with Wi-Fi configured (USB networking off) or the adapter not up, the trimmed console has no la-voltage or power, so a command over usb had no way in. It now uses the pod's Ethernet or Wi-Fi address from the console's status when that answers on :8080, says which address and why, and notes that the external LAN policy applies there; when it does not answer either, the error names both and clear-wifi. Firmware before 3.9.0 keeps the console path. README table updated. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- cmd/benchpod-cli/status.go | 26 ++++++++--------- cmd/benchpod-cli/usbnet.go | 40 +++++++++++++++++++++------ cmd/benchpod-cli/usbnet_test.go | 28 +++++++++++++++++-- internal/serialconsole/usbnet.go | 11 +++++++- internal/serialconsole/usbnet_test.go | 2 +- 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 7143308..156b937 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ the pod reports (the `usb` line of its console `status`), never from its own ver | | Pod firmware before 3.9.0 | Pod firmware 3.9.0 and later | |---|---|---| -| **This CLI (0.6.0+)** | Works as before: the USB console for `status`, `la voltage`, `install-blobs`, `flash-self`, `set-wifi`, the policies, `cloud ca`/`proxy` and `identity`; the network commands need the pod's network address, as before, and say so. The serial auto-detect still finds `0483:5740` and `2E8A` consoles. | Over `usb`, every command uses the pod's USB network adapter when it is up (including `flash`, which the STM32 pod's console never ran), and the console for the commands that stay there (and as the fallback). | +| **This CLI (0.6.0+)** | Works as before: the USB console for `status`, `la voltage`, `install-blobs`, `flash-self`, `set-wifi`, the policies, `cloud ca`/`proxy` and `identity`; the network commands need the pod's network address, as before, and say so. The serial auto-detect still finds `0483:5740` and `2E8A` consoles. | Over `usb`, every command uses the pod's USB network adapter when it is up (including `flash`, which the STM32 pod's console never ran), and the console for the commands that stay there. When USB networking is off (Wi-Fi configured) or not up, the network commands use the pod's Ethernet or Wi-Fi address from its console `status` if this computer reaches it, say so, and are subject to the pod's external LAN policy there (`benchpod clear-wifi` turns USB networking back on). | | **An older CLI (0.5.x)** | Unchanged. | The kept console commands keep their names and replies (`status`, `dfu`, `identity`, `identity wipe`, `lan-policy`, `sig-policy`, `set-wifi`, `show-network`, `clear-wifi`, `install-blobs`, `flash-self`, `cloud ca`/`proxy`), so those still work. A command 3.9.0 moved to the network API (`la voltage` over USB) prints the pod's "moved to the network API ... update the benchpod CLI to 0.6.0 or later" line. Over a network address everything works. | Which command works over which connection: diff --git a/cmd/benchpod-cli/status.go b/cmd/benchpod-cli/status.go index 2fbdd62..b5b8b13 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..06e1107 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "io" "net" + "os" "time" "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" @@ -54,21 +56,41 @@ func (g *globalFlags) usbNetAddr(device string) (string, error) { } return "", fmt.Errorf("the pod's console did not report its USB network: %w", err) } - if !u.On { - return "", errors.New("USB networking is off because Wi-Fi is configured on the pod (`benchpod clear-wifi` turns it back on)") - } addr := net.JoinHostPort(u.PodIP, "8080") - if !u.Link { - return "", fmt.Errorf("the pod's USB network adapter (%s) is not up on this computer; "+ + var why error + switch { + case !u.On: + why = errors.New("USB networking is off because Wi-Fi is configured on the pod (`benchpod clear-wifi` turns it back on)") + case !u.Link: + why = fmt.Errorf("the pod's USB network adapter (%s) is not up on this computer; "+ "macOS ignores a new adapter while the screen is locked: unlock and run `benchpod usb-reattach`", u.PodIP) + default: + if err := dialUSBPod(addr); err != nil { + why = fmt.Errorf("the pod did not answer on its USB network address %s (%v); this computer should have %s on that adapter, "+ + "run `benchpod usb-reattach` if it does not", addr, err, u.HostIP) + } + } + if why == nil { + return addr, nil } - if err := dialUSBPod(addr); err != nil { - return "", fmt.Errorf("the pod did not answer on its USB network address %s (%v); this computer should have %s on that adapter, "+ - "run `benchpod usb-reattach` if it does not", addr, err, u.HostIP) + // Firmware 3.9.0+ keeps only the console commands that need physical presence, so without + // the USB network the way in is the pod's Ethernet or Wi-Fi address, when this computer can + // reach it (the external LAN policy applies there). + if u.LANIP == "" { + return "", why } - return addr, nil + lan := net.JoinHostPort(u.LANIP, "8080") + if err := dialUSBPod(lan); err != nil { + return "", fmt.Errorf("%v; its network address %s did not answer either (%v; the pod's external LAN policy "+ + "`off` refuses it there)", why, lan, err) + } + fmt.Fprintf(usbNote, "usb: using the pod's network address %s: %v. Its external LAN policy applies there.\n", lan, why) + return lan, nil } +// usbNote is where networkTarget says which way it took (stderr; tests capture it). +var usbNote io.Writer = os.Stderr + // 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. diff --git a/cmd/benchpod-cli/usbnet_test.go b/cmd/benchpod-cli/usbnet_test.go index 2056b82..3818fab 100644 --- a/cmd/benchpod-cli/usbnet_test.go +++ b/cmd/benchpod-cli/usbnet_test.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "errors" + "io" "net" "strings" "testing" @@ -12,13 +14,35 @@ import ( func withUSBNet(t *testing.T, u serialconsole.USBNet, uerr, dialErr error) *[]string { t.Helper() var dialed []string - oldNet, oldDial := usbNetwork, dialUSBPod + oldNet, oldDial, oldNote := usbNetwork, dialUSBPod, usbNote usbNetwork = func(*globalFlags, string) (serialconsole.USBNet, error) { return u, uerr } dialUSBPod = func(addr string) error { dialed = append(dialed, addr); return dialErr } - t.Cleanup(func() { usbNetwork, dialUSBPod = oldNet, oldDial }) + usbNote = io.Discard + t.Cleanup(func() { usbNetwork, dialUSBPod, usbNote = oldNet, oldDial, oldNote }) return &dialed } +// Firmware 3.9.0 with Wi-Fi configured: no USB network, so the pod's network address. +func TestNetworkTargetFallsBackToThePodsNetworkAddress(t *testing.T) { + var note bytes.Buffer + withUSBNet(t, serialconsole.USBNet{PodIP: "10.237.8.85", HostIP: "10.237.8.86", LANIP: "192.168.1.221"}, nil, nil) + usbNote = ¬e + spec, err := (&globalFlags{connection: "usb"}).networkTarget("la voltage") + if err != nil || spec.Addr != "192.168.1.221:8080" { + t.Fatalf("spec %+v err %v", spec, err) + } + if !strings.Contains(note.String(), "192.168.1.221:8080") || !strings.Contains(note.String(), "Wi-Fi is configured") { + t.Fatalf("note %q", note.String()) + } + // The network address does not answer either (no route, or the external LAN policy is off). + withUSBNet(t, serialconsole.USBNet{PodIP: "10.237.8.85", LANIP: "192.168.1.221"}, nil, errors.New("refused")) + _, err = (&globalFlags{connection: "usb"}).networkTarget("la voltage") + if err == nil || !strings.Contains(err.Error(), "192.168.1.221:8080") || !strings.Contains(err.Error(), "clear-wifi") || + !strings.Contains(err.Error(), "policy") { + t.Fatalf("err = %v", err) + } +} + func TestNetworkTargetUsesTheUSBNetworkAdapter(t *testing.T) { dialed := withUSBNet(t, serialconsole.USBNet{On: true, Link: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86"}, nil, nil) for _, conn := range []string{"usb", "/dev/cu.usbmodem1"} { diff --git a/internal/serialconsole/usbnet.go b/internal/serialconsole/usbnet.go index 122c595..dd086a8 100644 --- a/internal/serialconsole/usbnet.go +++ b/internal/serialconsole/usbnet.go @@ -20,6 +20,7 @@ type USBNet struct { Link bool PodIP string HostIP string + LANIP string // the status `ip` line: the pod's Ethernet (else Wi-Fi) address, "" when it has none } // ErrNoUSBNet means the pod's console reports no USB network adapter: firmware older than 3.9.0. @@ -28,12 +29,20 @@ var ErrNoUSBNet = errors.New("the pod's firmware has no USB network adapter (nee // ParseUSBNet reads the `usb` line of the console's `status` output. It returns ErrNoUSBNet when // there is none. func ParseUSBNet(status string) (USBNet, error) { + lan := "" + for _, ln := range strings.Split(strings.ReplaceAll(status, "\r", "\n"), "\n") { + if k, v, ok := strings.Cut(ln, ":"); ok && strings.TrimSpace(k) == "ip" { + if ip := net.ParseIP(strings.TrimSpace(v)).To4(); ip != nil && !ip.IsUnspecified() { + lan = ip.String() + } + } + } for _, ln := range strings.Split(strings.ReplaceAll(status, "\r", "\n"), "\n") { k, v, ok := strings.Cut(ln, ":") if !ok || strings.TrimSpace(k) != "usb" { continue } - u := USBNet{On: true} // the line without a net field (pre-release builds): on + u := USBNet{On: true, LANIP: lan} // the line without a net field (pre-release builds): on f := strings.Fields(v) for i := 0; i+1 < len(f); i++ { switch f[i] { diff --git a/internal/serialconsole/usbnet_test.go b/internal/serialconsole/usbnet_test.go index 4c1d492..ef6bd50 100644 --- a/internal/serialconsole/usbnet_test.go +++ b/internal/serialconsole/usbnet_test.go @@ -9,7 +9,7 @@ func TestParseUSBNet(t *testing.T) { on := " device : benchpod\r\n ip : 192.168.1.220\r\n" + " usb : net on pod 10.237.8.85 host 10.237.8.86 link up rx 2946 (drop 0) tx 5759 (full 2357)\r\n" u, err := ParseUSBNet(on) - if err != nil || u != (USBNet{On: true, Link: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86"}) { + if err != nil || u != (USBNet{On: true, Link: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86", LANIP: "192.168.1.220"}) { t.Fatalf("on: %+v %v", u, err) } off := " 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)\n" From 5b82e426b27ba423687dacfa7727e711ed08d0f5 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:30:18 -0500 Subject: [PATCH 17/29] usb: ping before falling back to the pod's LAN address With the external LAN policy off the pod accepts a TCP connection and resets it, so the fallback now checks a JSON ping answers, not just a connect. Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/usbnet.go | 12 +++++++++++- cmd/benchpod-cli/usbnet_test.go | 5 +++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go index 06e1107..77ec011 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -43,6 +43,16 @@ var dialUSBPod = func(addr string) error { return c.Close() } +// pingLANPod checks the pod answers its API on a network address: a JSON ping, not just a TCP +// connect, because with the external LAN policy off the pod accepts the connection and resets it. +// A variable so tests can replace it. +var pingLANPod = func(addr string) error { + ctx, cancel := context.WithTimeout(context.Background(), usbNetProbeTimeout) + defer cancel() + _, err := (&tcpclient.Client{Addr: addr}).Command(ctx, map[string]any{"cmd": "ping"}) + return err +} + // usbNetAddr returns the pod's API address on its USB network adapter (podIP:8080), or why there // is none, phrased to follow "over USB, ". func (g *globalFlags) usbNetAddr(device string) (string, error) { @@ -80,7 +90,7 @@ func (g *globalFlags) usbNetAddr(device string) (string, error) { return "", why } lan := net.JoinHostPort(u.LANIP, "8080") - if err := dialUSBPod(lan); err != nil { + if err := pingLANPod(lan); err != nil { return "", fmt.Errorf("%v; its network address %s did not answer either (%v; the pod's external LAN policy "+ "`off` refuses it there)", why, lan, err) } diff --git a/cmd/benchpod-cli/usbnet_test.go b/cmd/benchpod-cli/usbnet_test.go index 3818fab..cee2282 100644 --- a/cmd/benchpod-cli/usbnet_test.go +++ b/cmd/benchpod-cli/usbnet_test.go @@ -14,11 +14,12 @@ import ( func withUSBNet(t *testing.T, u serialconsole.USBNet, uerr, dialErr error) *[]string { t.Helper() var dialed []string - oldNet, oldDial, oldNote := usbNetwork, dialUSBPod, usbNote + oldNet, oldDial, oldPing, oldNote := usbNetwork, dialUSBPod, pingLANPod, usbNote usbNetwork = func(*globalFlags, string) (serialconsole.USBNet, error) { return u, uerr } dialUSBPod = func(addr string) error { dialed = append(dialed, addr); return dialErr } + pingLANPod = func(addr string) error { dialed = append(dialed, addr); return dialErr } usbNote = io.Discard - t.Cleanup(func() { usbNetwork, dialUSBPod, usbNote = oldNet, oldDial, oldNote }) + t.Cleanup(func() { usbNetwork, dialUSBPod, pingLANPod, usbNote = oldNet, oldDial, oldPing, oldNote }) return &dialed } From 28f0d826d9e0f4f5a364d9c6e44b5ae971b1e75f Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:31:20 -0500 Subject: [PATCH 18/29] usb: probe the USB network once per run, failures included la voltage asked again for the reason after opening the console, which then failed to open the port a second time and reported no console found. Keep the first answer. Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/root.go | 1 + cmd/benchpod-cli/usbnet.go | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index fa53ab0..4b9d789 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -20,6 +20,7 @@ type globalFlags struct { timeout time.Duration usbAddr string // the pod's USB network address, once networkTarget found it (one probe per run) + usbErr error // why it found none (also one probe per run: the console may be open later) } // effectiveTimeout returns the explicit --timeout when set (> 0), else the diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go index 77ec011..5650b9e 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -112,7 +112,11 @@ func (g *globalFlags) networkTarget(cmd string) (ConnSpec, error) { if g.usbAddr != "" { return ConnSpec{Kind: connNetwork, Addr: g.usbAddr}, nil } - addr, uerr := g.usbNetAddr(spec.Device) + addr, uerr := g.usbAddr, g.usbErr + if uerr == nil { + addr, uerr = g.usbNetAddr(spec.Device) + g.usbErr = uerr + } if errors.Is(uerr, tcpclient.ErrUnreachable) { return spec, uerr } From b70a9a500db622881e40480a585952b579efe13d Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:34:51 -0500 Subject: [PATCH 19/29] usb-bridge --watch: never poll the console The background service opened the pod's USB console every 10 s while the pod was not ready, which can last (Wi-Fi configured, a locked Mac, no pod): it fought the web app's setup wizard (WebSerial), terminals and other commands for the port (exclusive on Windows), and with no pod plugged in the auto-detect probed other serial devices, typing status into ST-Link COM ports. --watch now watches the local interfaces for a pod's USB network link (10.237.x.y/30; the pod is the other host), reads the pod's proxy over that link (cloud_proxy is readable at the LAN tier), and opens the console once only to set the proxy, and once to clear it on stop. usb: picks the link by the pod's identity over the API. Tests: no console use while idle, none when the proxy is already ours or someone else's, exactly one set and one clear otherwise. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- cmd/benchpod-cli/usbbridge.go | 47 ++--- cmd/benchpod-cli/usbbridge_watch.go | 235 +++++++++++++++++++++++ cmd/benchpod-cli/usbbridge_watch_test.go | 99 ++++++++++ 4 files changed, 349 insertions(+), 34 deletions(-) create mode 100644 cmd/benchpod-cli/usbbridge_watch.go create mode 100644 cmd/benchpod-cli/usbbridge_watch_test.go diff --git a/README.md b/README.md index 156b937..6011654 100644 --- a/README.md +++ b/README.md @@ -1025,7 +1025,7 @@ the bridge are blocked. |----------------|---------|------------------------------------------------------| | `--port` | `3128` | TCP port of the proxy on this computer's USB address. | | `--keep-proxy` | off | Leave the pod's cloud proxy set when the bridge stops. | -| `--watch` | off | Wait for the pod and pick it up again after it is unplugged or restarted (how the background service runs it). | +| `--watch` | off | Wait for the pod and pick it up again after it is unplugged or restarted (how the background service runs it). It never polls or holds the pod's USB console, so the web app's setup wizard, a terminal and other `benchpod` commands keep it: it watches this computer's network interfaces for the pod's USB network link, reads the pod's proxy over that link, and opens the console once only to set the proxy (and to clear it when it stops). With Wi-Fi configured on the pod there is no USB link, so it does nothing. | ### `benchpod usb-bridge install` diff --git a/cmd/benchpod-cli/usbbridge.go b/cmd/benchpod-cli/usbbridge.go index e222dc2..efb7c0f 100644 --- a/cmd/benchpod-cli/usbbridge.go +++ b/cmd/benchpod-cli/usbbridge.go @@ -43,7 +43,9 @@ func newUSBBridgeCmd(g *globalFlags) *cobra.Command { "Ctrl-C, then clears the pod's proxy again (--keep-proxy leaves it set).\n\n" + "--watch keeps going when no pod is plugged in or the USB link goes away, and picks the\n" + "pod up again when it comes back: that is how `usb-bridge install` runs it in the\n" + - "background.\n\n" + + "background. It never polls or holds the pod's USB console: it watches for this\n" + + "computer's USB network address, reads the pod's proxy over that link, and opens the\n" + + "console once only to set the proxy (or clear it when it stops).\n\n" + "Needs firmware 3.9.0 or later and no Wi-Fi configured on the pod. The proxy accepts\n" + "only the pod, and only tunnels to port 443. It refuses to replace a proxy the pod\n" + "already has (`benchpod cloud proxy clear` removes it).", @@ -59,9 +61,6 @@ func newUSBBridgeCmd(g *globalFlags) *cobra.Command { return cmd } -// usbBridgeRetry is how often --watch looks for the pod again. -const usbBridgeRetry = 10 * time.Second - // bridgeSession is one bridge run: the pod's USB link, the proxy's listener on this computer's // end of it, and the console device it was set up through. type bridgeSession struct { @@ -167,39 +166,21 @@ func runUSBBridge(g *globalFlags, port int, keep, watch bool) error { if port <= 0 || port > 65535 { return fmt.Errorf("usb-bridge: --port %d is out of range", port) } + if watch { + return runUSBBridgeWatch(g, port, keep) // never polls the console (usbbridge_watch.go) + } ctx, stop := context.WithCancel(context.Background()) defer stop() defer installSignalHandler(ctx, stop)() - var last *bridgeSession - lastErr := "" - for { - s, err := setupBridge(g, port) - if err != nil { - if !watch { - return fmt.Errorf("usb-bridge: %w", err) - } - if err.Error() != lastErr { // one line per new reason, not one per retry - log.Printf("usb-bridge: waiting for the pod: %v", err) - lastErr = err.Error() - } - select { - case <-ctx.Done(): - return nil - case <-time.After(usbBridgeRetry): - } - continue - } - lastErr = "" - last = s - fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s. Ctrl-C stops it.\n", s.u.PodIP, s.proxyAddr) - if serveBridge(ctx, s) { - break // stopped - } - log.Printf("usb-bridge: the USB link to the pod (%s) went away", s.u.PodIP) - if !watch { - return errors.New("usb-bridge: the USB link to the pod went away") - } + last, err := setupBridge(g, port) + if err != nil { + return fmt.Errorf("usb-bridge: %w", err) + } + fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s. Ctrl-C stops it.\n", last.u.PodIP, last.proxyAddr) + if !serveBridge(ctx, last) { + log.Printf("usb-bridge: the USB link to the pod (%s) went away", last.u.PodIP) + return errors.New("usb-bridge: the USB link to the pod went away (`usb-bridge --watch` waits for it instead)") } if keep { diff --git a/cmd/benchpod-cli/usbbridge_watch.go b/cmd/benchpod-cli/usbbridge_watch.go new file mode 100644 index 0000000..9f4df2b --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_watch.go @@ -0,0 +1,235 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "os" + "strconv" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" +) + +// usb-bridge --watch, the background service. It never polls the pod's USB console: the console +// is shared with the web app's setup wizard (WebSerial), other benchpod commands and terminals +// (exclusive on Windows), and probing serial ports in a loop would also type into unrelated +// devices (ST-Link virtual COM ports share VID 0483). Instead it watches for this computer's end +// of a pod's USB network link (a 10.237.x.y/30 address on a local interface; the pod is the +// other host), reads the pod's cloud proxy over the JSON API there, and opens the console once, +// only when the proxy has to be set (the LAN tier may read it but not change it) or cleared. +// With Wi-Fi configured the pod's USB networking is off: no address appears, nothing is opened. + +// usbWatchPoll is how often --watch looks at the local interfaces (no I/O to the pod). +const usbWatchPoll = 5 * time.Second + +// usbLink is one pod's USB network link as this computer sees it. +type usbLink struct{ host, pod string } + +// localUSBLinks lists this computer's ends of pods' USB network links: interface addresses in +// 10.237.0.0/16 with a /30 mask. The pod is the first host of the /30, this computer the second. +// A variable so tests can replace it. +var localUSBLinks = func() []usbLink { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil + } + var out []usbLink + for _, a := range addrs { + ipn, ok := a.(*net.IPNet) + if !ok || ipn.IP.To4() == nil || !usbLinkNet.Contains(ipn.IP) { + continue + } + if ones, bits := ipn.Mask.Size(); ones != 30 || bits != 32 { + continue + } + n := binary.BigEndian.Uint32(ipn.IP.To4()) &^ 3 + pod := make(net.IP, 4) + binary.BigEndian.PutUint32(pod, n+1) + out = append(out, usbLink{host: ipn.IP.To4().String(), pod: pod.String()}) + } + return out +} + +// bridgePodAPI is what --watch asks the pod over its USB network. A variable so tests can fake it. +var bridgePodAPI = func(ctx context.Context, addr string, cmd map[string]any) (json.RawMessage, error) { + return (&tcpclient.Client{Addr: addr}).Command(ctx, cmd) +} + +// bridgeConsoleOpens counts console opens by --watch (tests assert it stays 0 while idle). +var bridgeConsoleOpens int + +// bridgeSetProxy sets (addr != "") or clears the pod's cloud proxy over its console, once. +// A variable so tests can replace it. +var bridgeSetProxy = func(g *globalFlags, addr string) error { + bridgeConsoleOpens++ + device, err := g.usbDevice("usb-bridge") + if err != nil { + return err + } + console, _, ctx, cancel, err := g.openSerialConsole(device, 15*time.Second) + if err != nil { + return err + } + defer cancel() + defer console.Close() + if addr == "" { + _, err = console.CloudProxyClear(ctx) + } else { + _, err = console.CloudProxySet(ctx, addr, "", "") + } + return err +} + +// shortIDOf is a pod's name from its identity public key (base64url): its first 3 bytes in hex. +func shortIDOf(pub string) string { + b, err := base64.RawURLEncoding.DecodeString(pub) + if err != nil || len(b) < 3 { + return "" + } + return hex.EncodeToString(b[:3]) +} + +// pickUSBLink chooses the link to bridge: the one pod there is, or the one usb: names. +func pickUSBLink(ctx context.Context, links []usbLink, name string) (usbLink, error) { + if name == "" { + switch len(links) { + case 0: + return usbLink{}, errors.New("no pod on a USB network link") + case 1: + return links[0], nil + } + return usbLink{}, fmt.Errorf("%d pods on USB network links: install the bridge per pod with --connection usb:", len(links)) + } + for _, l := range links { + raw, err := bridgePodAPI(ctx, net.JoinHostPort(l.pod, "8080"), map[string]any{"cmd": "identity_public"}) + if err != nil { + continue + } + var rep struct { + PublicKey string `json:"public"` + } + if json.Unmarshal(raw, &rep) == nil && shortIDOf(rep.PublicKey) == name { + return l, nil + } + } + return usbLink{}, fmt.Errorf("pod %s is not on a USB network link", name) +} + +// runUSBBridgeWatch is `usb-bridge --watch`. +func runUSBBridgeWatch(g *globalFlags, port int, keep bool) error { + ctx, stop := context.WithCancel(context.Background()) + defer stop() + defer installSignalHandler(ctx, stop)() + return watchBridge(ctx, g, port, keep, usbWatchPoll) +} + +func watchBridge(ctx context.Context, g *globalFlags, port int, keep bool, poll time.Duration) error { + name := g.usbPodName() + var setByUs string // the proxy this run set on the pod, cleared again on stop + lastWhy := "" + note := func(why string) { + if why != lastWhy { + log.Printf("usb-bridge: waiting: %s", why) + lastWhy = why + } + } + wait := func() bool { + select { + case <-ctx.Done(): + return false + case <-time.After(poll): + return true + } + } + for ctx.Err() == nil { + links := localUSBLinks() + if len(links) == 0 { + note("no pod on a USB network link (none plugged in, Wi-Fi configured on it, or the adapter not up yet)") + if !wait() { + break + } + continue + } + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + link, err := pickUSBLink(cctx, links, name) + cancel() + if err != nil { + note(err.Error()) + if !wait() { + break + } + continue + } + proxyAddr := net.JoinHostPort(link.host, strconv.Itoa(port)) + cctx, cancel = context.WithTimeout(ctx, 10*time.Second) + raw, err := bridgePodAPI(cctx, net.JoinHostPort(link.pod, "8080"), map[string]any{"cmd": "cloud_proxy"}) + cancel() + if err != nil { + note(fmt.Sprintf("the pod at %s does not answer yet (%v)", link.pod, err)) + if !wait() { + break + } + continue + } + var cur struct { + Host string `json:"host"` + Port int `json:"port"` + } + _ = json.Unmarshal(raw, &cur) + curAddr := "" + if cur.Host != "" { + curAddr = net.JoinHostPort(cur.Host, strconv.Itoa(cur.Port)) + } + if curAddr != "" && curAddr != proxyAddr { + note(fmt.Sprintf("the pod already has a cloud proxy (%s); remove it with `benchpod cloud proxy clear --connection usb`", curAddr)) + if !wait() { + break + } + continue + } + ln, err := net.Listen("tcp", proxyAddr) + if err != nil { + note(fmt.Sprintf("listen on %s: %v", proxyAddr, err)) + if !wait() { + break + } + continue + } + if curAddr != proxyAddr { + // The one console use: the LAN tier may read the proxy but not set it. + if err := bridgeSetProxy(g, proxyAddr); err != nil { + ln.Close() + note(fmt.Sprintf("set the pod's cloud proxy over its console: %v", err)) + if !wait() { + break + } + continue + } + setByUs = proxyAddr + } + lastWhy = "" + fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s\n", link.pod, proxyAddr) + s := &bridgeSession{u: serialconsole.USBNet{On: true, Link: true, PodIP: link.pod, HostIP: link.host}, proxyAddr: proxyAddr, ln: ln} + if serveBridge(ctx, s) { + break + } + log.Printf("usb-bridge: the USB link to the pod (%s) went away", link.pod) + } + if setByUs == "" || keep { + return nil + } + if err := bridgeSetProxy(g, ""); err != nil { + return fmt.Errorf("usb-bridge: stopped, but could not clear the pod's cloud proxy (%v); "+ + "run `benchpod cloud proxy clear --connection usb`", err) + } + fmt.Fprintln(os.Stderr, "usb-bridge: stopped; cleared the pod's cloud proxy") + return nil +} diff --git a/cmd/benchpod-cli/usbbridge_watch_test.go b/cmd/benchpod-cli/usbbridge_watch_test.go new file mode 100644 index 0000000..abd6d14 --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_watch_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "testing" + "time" +) + +// fakeBridge stubs everything --watch touches: the local links, the pod's JSON API and the one +// console call. It records every console use. +type fakeBridge struct { + links []usbLink + proxy string // the pod's current proxy "host:port", "" = none + apiCalls int + console []string // proxy addresses set over the console ("" = clear) +} + +func withFakeBridge(t *testing.T, f *fakeBridge) { + t.Helper() + oldLinks, oldAPI, oldSet := localUSBLinks, bridgePodAPI, bridgeSetProxy + localUSBLinks = func() []usbLink { return f.links } + bridgePodAPI = func(_ context.Context, _ string, cmd map[string]any) (json.RawMessage, error) { + f.apiCalls++ + if cmd["cmd"] != "cloud_proxy" { + return nil, errors.New("unexpected") + } + if f.proxy == "" { + return json.RawMessage(`{}`), nil + } + host, port, _ := net.SplitHostPort(f.proxy) + return json.RawMessage(fmt.Sprintf(`{"host":%q,"port":%s,"auth":false}`, host, port)), nil + } + bridgeSetProxy = func(_ *globalFlags, addr string) error { + f.console = append(f.console, addr) + f.proxy = addr + return nil + } + t.Cleanup(func() { localUSBLinks, bridgePodAPI, bridgeSetProxy = oldLinks, oldAPI, oldSet }) +} + +func runWatchFor(t *testing.T, d time.Duration, port int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), d) + defer cancel() + if err := watchBridge(ctx, &globalFlags{connection: "usb"}, port, false, 5*time.Millisecond); err != nil { + t.Fatal(err) + } +} + +// Idle (no pod, or Wi-Fi configured on it so its USB networking is off): never the console, never +// the pod. +func TestWatchBridgeNeverTouchesTheConsoleWhileIdle(t *testing.T) { + f := &fakeBridge{} + withFakeBridge(t, f) + runWatchFor(t, 100*time.Millisecond, 39128) + if len(f.console) != 0 || f.apiCalls != 0 { + t.Fatalf("idle watch used the console %d times and the API %d times", len(f.console), f.apiCalls) + } +} + +// The pod's proxy already points here (set by an earlier run): no console at all. +func TestWatchBridgeKeepsAProxyThatIsAlreadyOurs(t *testing.T) { + f := &fakeBridge{links: []usbLink{{host: "127.0.0.1", pod: "127.0.0.2"}}, proxy: "127.0.0.1:39129"} + withFakeBridge(t, f) + runWatchFor(t, 100*time.Millisecond, 39129) + if len(f.console) != 0 { + t.Fatalf("console used %v", f.console) + } +} + +// A new pod: one console call to set the proxy, one to clear it on stop, and nothing in between. +func TestWatchBridgeSetsTheProxyOnceAndClearsItOnStop(t *testing.T) { + f := &fakeBridge{links: []usbLink{{host: "127.0.0.1", pod: "127.0.0.2"}}} + withFakeBridge(t, f) + runWatchFor(t, 150*time.Millisecond, 39130) + if len(f.console) != 2 || f.console[0] != "127.0.0.1:39130" || f.console[1] != "" { + t.Fatalf("console calls %q, want set once then clear", f.console) + } +} + +// Another proxy on the pod: left alone, and no console. +func TestWatchBridgeLeavesSomeoneElsesProxy(t *testing.T) { + f := &fakeBridge{links: []usbLink{{host: "127.0.0.1", pod: "127.0.0.2"}}, proxy: "proxy.corp:3128"} + withFakeBridge(t, f) + runWatchFor(t, 60*time.Millisecond, 39131) + if len(f.console) != 0 || f.proxy != "proxy.corp:3128" { + t.Fatalf("console %q proxy %q", f.console, f.proxy) + } +} + +func TestShortIDOf(t *testing.T) { + if got := shortIDOf("uuoGf1v6n9yeBZZVzswU4MDC1Lndgx6OXY5FG9_dUJQ"); got != "baea06" { + t.Fatalf("got %q", got) + } +} From b61690597416d54548438b0e545a1ebd1febfd98 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:43:34 -0500 Subject: [PATCH 20/29] flash-self: find the pod again by its USB serial number after the update From firmware 3.8.x to 3.9.0 the pod's USB ID changes (0483:5740 -> 1209:xxxx), so after the reboot it is a different USB device: the port may be renamed, and Windows installs drivers and assigns a new COM port. flash-self --enter-dfu records the console's USB serial number (the same on every firmware) and waits up to 150 s for a pod console with that serial number under any pod USB ID before installing the blobs. Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/flash_self.go | 25 ++++++++++-- internal/serialconsole/portsn.go | 55 +++++++++++++++++++++++++++ internal/serialconsole/portsn_test.go | 32 ++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 internal/serialconsole/portsn.go create mode 100644 internal/serialconsole/portsn_test.go diff --git a/cmd/benchpod-cli/flash_self.go b/cmd/benchpod-cli/flash_self.go index 8609759..df501bf 100644 --- a/cmd/benchpod-cli/flash_self.go +++ b/cmd/benchpod-cli/flash_self.go @@ -372,10 +372,12 @@ func dfuDevicePresent(ctx context.Context, dfuPath string) bool { // enterDfuViaConsole opens the serial console and sends the `dfu` command to // reboot a running pod into its ROM USB DFU bootloader. func enterDfuViaConsole(g *globalFlags) error { - console, _, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(10*time.Second)) + console, path, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(10*time.Second)) if err != nil { return fmt.Errorf("enter-dfu: open USB console: %w", err) } + // The USB serial number finds this pod again after the update, under its new USB ID. + flashedPodSerial = serialconsole.PortSerialNumber(path) defer cancel() defer console.Close() if err := console.Dfu(ctx); err != nil { @@ -476,13 +478,27 @@ func checkImageFitsPod(ctx context.Context, dfuPath, file string) error { return nil } +// flashedPodSerial is the USB serial number of the pod flash-self rebooted into DFU over its +// console ("" when it was put in DFU by hand): it finds the pod again after the update. +var flashedPodSerial string + // Seams for tests: how long installBlobsAfterFlash waits for the flashed pod's USB console, // how often it tries, and how it opens it. var ( - flashedConsoleWait = 60 * time.Second + // Generous: from 3.8.x to 3.9.0 the pod comes back under a new USB ID, and Windows installs + // drivers and assigns a new COM port before the console appears. + flashedConsoleWait = 150 * time.Second flashedConsoleRetry = time.Second openFlashedConsole = func(g *globalFlags) (*serialconsole.Console, error) { - c, _, err := g.openBenchpodSerial(g.serialDevice(), 2*time.Second) + device := g.serialDevice() + if flashedPodSerial != "" { + // The same pod, whatever its port is called now (0483:5740 -> 1209:xxxx renames it). + device = serialconsole.FindPodPortBySerial(flashedPodSerial) + if device == "" { + return nil, fmt.Errorf("the pod (USB serial number %s) is not back on USB yet", flashedPodSerial) + } + } + c, _, err := g.openBenchpodSerial(device, 2*time.Second) return c, err } ) @@ -492,7 +508,8 @@ var ( // has no slots to fill and is left alone. Anything else that keeps the blobs from being // installed is an error, so flash-self does not report success for a pod that is missing them. func installBlobsAfterFlash(g *globalFlags, src blobSource) error { - fmt.Fprintln(os.Stderr, "flash-self: waiting for the pod's USB console to install its blobs...") + fmt.Fprintln(os.Stderr, "flash-self: waiting for the pod's USB console to install its blobs (after an update to 3.9.0 "+ + "it comes back under a new USB ID: Windows gives it a new COM port and may install drivers first)...") var console *serialconsole.Console deadline := time.Now().Add(flashedConsoleWait) for { diff --git a/internal/serialconsole/portsn.go b/internal/serialconsole/portsn.go new file mode 100644 index 0000000..2b286e4 --- /dev/null +++ b/internal/serialconsole/portsn.go @@ -0,0 +1,55 @@ +package serialconsole + +import ( + "path/filepath" + "strings" +) + +// The pod's USB serial number (12 hex digits from the MCU's unique ID) is the same on every +// firmware, while its USB ID is not (0483:5740 up to 3.8, 1209:xxxx from 3.9.0) and the console's +// port name may change with it (a new COM port number on Windows). After a firmware update the +// serial number is how the same pod is found again. + +// PortSerialNumber is the USB serial number of the port at path ("" when the enumerator does not +// know it). macOS's /dev/tty.* twin of a /dev/cu.* port matches too. +func PortSerialNumber(path string) string { + ports, err := portLister() + if err != nil { + return "" + } + want := twinName(path) + for _, p := range ports { + if p != nil && p.IsUSB && twinName(p.Name) == want { + return strings.TrimSpace(p.SerialNumber) + } + } + return "" +} + +// FindPodPortBySerial is the console port of the pod with USB serial number sn, under any pod +// USB ID, or "" when it is not (yet) enumerated. +func FindPodPortBySerial(sn string) string { + sn = strings.TrimSpace(sn) + if sn == "" { + return "" + } + ports, err := portLister() + if err != nil { + return "" + } + for _, p := range ports { + if p != nil && p.IsUSB && isBenchPodVID(p.VID) && strings.EqualFold(strings.TrimSpace(p.SerialNumber), sn) { + return p.Name + } + } + return "" +} + +// twinName folds macOS's /dev/tty.X and /dev/cu.X into one key. +func twinName(path string) string { + b := filepath.Base(path) + for _, pre := range []string{"tty.", "cu."} { + b = strings.TrimPrefix(b, pre) + } + return b +} diff --git a/internal/serialconsole/portsn_test.go b/internal/serialconsole/portsn_test.go new file mode 100644 index 0000000..ee900bd --- /dev/null +++ b/internal/serialconsole/portsn_test.go @@ -0,0 +1,32 @@ +package serialconsole + +import ( + "testing" + + "go.bug.st/serial/enumerator" +) + +// Firmware 3.8 (0483:5740) to 3.9 (1209:0001): the port may be renamed, the serial number stays. +func TestFindPodPortBySerialAcrossUSBIDs(t *testing.T) { + withLister(t, func() ([]*enumerator.PortDetails, error) { + return []*enumerator.PortDetails{ + {Name: "/dev/cu.usbmodem3464395E34351", IsUSB: true, VID: "0483", PID: "5740", SerialNumber: "3464395E3435"}, + }, nil + }) + sn := PortSerialNumber("/dev/tty.usbmodem3464395E34351") + if sn != "3464395E3435" { + t.Fatalf("serial number %q", sn) + } + withLister(t, func() ([]*enumerator.PortDetails, error) { + return []*enumerator.PortDetails{ + {Name: "COM3", IsUSB: true, VID: "0483", PID: "374B", SerialNumber: "066DFF"}, // an ST-Link + {Name: "COM7", IsUSB: true, VID: "1209", PID: "0001", SerialNumber: "3464395e3435"}, + }, nil + }) + if got := FindPodPortBySerial(sn); got != "COM7" { + t.Fatalf("found %q, want the pod's new COM7", got) + } + if got := FindPodPortBySerial("FFFFFFFFFFFF"); got != "" { + t.Fatalf("an absent pod was found on %q", got) + } +} From 2564511fed34eb53a4fd31b74db0e36b685fa817 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:43:51 -0500 Subject: [PATCH 21/29] README: WSL2 and Docker on Windows are not tested Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 6011654..5226b64 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ The USB network adapter works with the in-box drivers of macOS, Linux and Window (tested). Windows 10 (1903 or newer) is supported but not tested: if the adapter shows up without a driver there, open Device Manager, update its driver, and pick "UsbNcm Host Device" from the list of network adapters. +WSL2 and Docker on Windows are not tested: Windows routes the pod's `10.237.x.y` address +itself, so a WSL2 distribution or a container on the host network should reach it with +`--connection `, but USB auto-detect and mDNS may not work from inside them. **Compatibility with older firmware and older CLIs.** The CLI decides per pod, from what the pod reports (the `usb` line of its console `status`), never from its own version: From 3e02477af5009bd2e53462c14d451c229e6b0f46 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:54:40 -0500 Subject: [PATCH 22/29] usb: CA, proxy and blobs over the USB network; usb-bridge never opens the console Firmware 3.9.0 lets the USB network link change the company CA and the cloud proxy (physical presence) and drops the console uploads. So over usb: cloud ca and cloud proxy use the JSON API on the USB link (the console only on older firmware or without the link); install-blobs and the blob steps of flash-self and set-wifi go over the USB network, else the pod's Ethernet or Wi-Fi address, with a clear error instead of the gone console upload; and usb-bridge (foreground and --watch) and usb-bridge uninstall read, set and clear the proxy over the USB link, so they never open the console. README: the per-connection table, the compatibility table (older CLIs on 3.9.0 can no longer upload), cloud ca set and usb-bridge. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++-- cmd/benchpod-cli/blobs_target.go | 38 ++++++++-- cmd/benchpod-cli/blobs_target_test.go | 17 ++++- cmd/benchpod-cli/cloudcfg.go | 17 +++++ cmd/benchpod-cli/flash_self.go | 27 +++++-- cmd/benchpod-cli/usbbridge.go | 97 +----------------------- cmd/benchpod-cli/usbbridge_service.go | 32 ++++---- cmd/benchpod-cli/usbbridge_test.go | 14 ---- cmd/benchpod-cli/usbbridge_watch.go | 89 +++++++++++++--------- cmd/benchpod-cli/usbbridge_watch_test.go | 14 +++- cmd/benchpod-cli/usbnet.go | 21 +++++ cmd/benchpod-cli/usbnet_test.go | 16 ++++ 12 files changed, 213 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index 4fbceea..6b89d95 100644 --- a/README.md +++ b/README.md @@ -158,14 +158,14 @@ the pod reports (the `usb` line of its console `status`), never from its own ver | | Pod firmware before 3.9.0 | Pod firmware 3.9.0 and later | |---|---|---| | **This CLI (0.6.0+)** | Works as before: the USB console for `status`, `la voltage`, `install-blobs`, `flash-self`, `set-wifi`, the policies, `cloud ca`/`proxy` and `identity`; the network commands need the pod's network address, as before, and say so. The serial auto-detect still finds `0483:5740` and `2E8A` consoles. | Over `usb`, every command uses the pod's USB network adapter when it is up (including `flash`, which the STM32 pod's console never ran), and the console for the commands that stay there. When USB networking is off (Wi-Fi configured) or not up, the network commands use the pod's Ethernet or Wi-Fi address from its console `status` if this computer reaches it, say so, and are subject to the pod's external LAN policy there (`benchpod clear-wifi` turns USB networking back on). | -| **An older CLI (0.5.x)** | Unchanged. | The kept console commands keep their names and replies (`status`, `dfu`, `identity`, `identity wipe`, `lan-policy`, `sig-policy`, `set-wifi`, `show-network`, `clear-wifi`, `install-blobs`, `flash-self`, `cloud ca`/`proxy`), so those still work. A command 3.9.0 moved to the network API (`la voltage` over USB) prints the pod's "moved to the network API ... update the benchpod CLI to 0.6.0 or later" line. Over a network address everything works. | +| **An older CLI (0.5.x)** | Unchanged. | The kept console commands keep their names and replies (`status`, `dfu`, `identity`, `identity wipe`, `lan-policy`, `sig-policy`, `set-wifi`, `show-network`, `clear-wifi`, `cloud ca show`/`clear`, `cloud proxy`), so those still work, and `flash-self` still flashes over DFU. What 3.9.0 moved to the network API (`la voltage` over USB, and every upload: `install-blobs`, the blob step of `flash-self` and `set-wifi`, `cloud ca set`) prints the pod's "moved to the network API ... update the benchpod CLI to 0.6.0 or later" line: update the CLI with the firmware. Over a network address everything works. | Which command works over which connection: | Connection | Commands | |---------------------|----------| | Network address | Every pod command except the USB-only ones below. | -| 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). | +| 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), `install-blobs`, `cloud ca` and `cloud proxy`; without it the pod's Ethernet or Wi-Fi address, and the console where it still has the command. Firmware before 3.9.0: the console, as before. `lan-policy`, `sig-policy` and `identity` 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. | @@ -1013,7 +1013,8 @@ Erase the stored Wi-Fi credentials (USB). Wi-Fi goes down at once, no reboot nee Carry the pod's cloud link over USB through this computer, until Ctrl-C (USB). The pod's USB network adapter has no router, so the pod cannot reach embeddedci.com over it by itself. The bridge runs an HTTP CONNECT proxy on this computer's end of the -USB link and sets it as the pod's cloud proxy; the pod then connects to +USB link and sets it as the pod's cloud proxy over that link (never the USB console: +the web app's setup wizard, terminals and other commands keep it); the pod then connects to embeddedci.com through this computer's internet connection, with no Ethernet or Wi-Fi. When it stops it clears the pod's proxy again. The proxy serves only the pod, and only tunnels to port 443. It refuses to replace a proxy the pod already has @@ -1029,7 +1030,7 @@ the bridge are blocked. |----------------|---------|------------------------------------------------------| | `--port` | `3128` | TCP port of the proxy on this computer's USB address. | | `--keep-proxy` | off | Leave the pod's cloud proxy set when the bridge stops. | -| `--watch` | off | Wait for the pod and pick it up again after it is unplugged or restarted (how the background service runs it). It never polls or holds the pod's USB console, so the web app's setup wizard, a terminal and other `benchpod` commands keep it: it watches this computer's network interfaces for the pod's USB network link, reads the pod's proxy over that link, and opens the console once only to set the proxy (and to clear it when it stops). With Wi-Fi configured on the pod there is no USB link, so it does nothing. | +| `--watch` | off | Wait for the pod and pick it up again after it is unplugged or restarted (how the background service runs it). It watches this computer's network interfaces for the pod's USB network link and reads, sets and clears the pod's proxy over that link; it never opens the USB console. With Wi-Fi configured on the pod there is no USB link, so it does nothing. | ### `benchpod usb-bridge install` @@ -1197,8 +1198,9 @@ Show the company CA certificates the pod holds. No flags. ### `benchpod cloud ca set ` Check a PEM file (at most 16 KB) and store its CA certificates on the pod. Over USB -only (the network refuses the change, and embeddedci.com does not carry the upload). -No flags. +only: through the pod's USB network adapter on firmware 3.9.0+ (which may change the CA, +physical presence; the pod's Ethernet and Wi-Fi refuse it, and embeddedci.com does not +carry the upload), or the USB console on older firmware. No flags. ### `benchpod cloud ca clear` diff --git a/cmd/benchpod-cli/blobs_target.go b/cmd/benchpod-cli/blobs_target.go index b98240e..c403b84 100644 --- a/cmd/benchpod-cli/blobs_target.go +++ b/cmd/benchpod-cli/blobs_target.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "errors" "fmt" "strings" @@ -148,13 +149,36 @@ func (l lanBlobs) UploadSigned(ctx context.Context, target string, data []byte, // 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 - } + u, err := console.USBNetwork(ctx) + if errors.Is(err, serialconsole.ErrNoUSBNet) { + return consoleBlobs{Console: console, path: path}, func() {}, nil // firmware before 3.9.0 + } + if err != nil { + return nil, func() {}, fmt.Errorf("read the pod's status: %w", err) + } + try := func(addr string, check func(string) error) (blobTarget, func(), bool) { + if check(addr) != nil { + return nil, nil, false + } + lan, err := openLAN(ctx, addr) + if err != nil { + return nil, nil, false + } + return lanBlobs{lan: lan, addr: addr}, func() { lan.s.Close() }, true + } + if u.On && u.Link { + if t, c, ok := try(u.PodIP+":8080", dialUSBPod); ok { + return t, c, nil + } + } + // Firmware 3.9.0+ has no console upload: without the USB network, the pod's Ethernet or Wi-Fi + // address (its external LAN policy applies there). + if u.LANIP != "" { + if t, c, ok := try(u.LANIP+":8080", pingLANPod); ok { + fmt.Fprintf(usbNote, "blobs: the pod's USB network is not usable; using its network address %s:8080\n", u.LANIP) + return t, c, nil } } - return consoleBlobs{Console: console, path: path}, func() {}, nil + return nil, func() {}, errors.New("this pod's firmware (3.9.0 or later) takes blobs over the network, and neither its USB " + + "network adapter (Wi-Fi configured on the pod turns it off: `benchpod clear-wifi`) nor its network address answers") } diff --git a/cmd/benchpod-cli/blobs_target_test.go b/cmd/benchpod-cli/blobs_target_test.go index 26f36a4..f248661 100644 --- a/cmd/benchpod-cli/blobs_target_test.go +++ b/cmd/benchpod-cli/blobs_target_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "errors" "io" "strings" "testing" @@ -45,7 +46,6 @@ var _ io.ReadWriteCloser = (*textConsole)(nil) 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}) @@ -61,3 +61,18 @@ func TestBlobTargetFallsBackToTheConsole(t *testing.T) { } } } + +// Firmware 3.9.0 with Wi-Fi configured and no network address answering: no console upload to fall +// back to, so a clear error. +func TestBlobTargetOn39WithoutANetworkIsAnError(t *testing.T) { + oldPing := pingLANPod + pingLANPod = func(string) error { return errors.New("refused") } + t.Cleanup(func() { pingLANPod = oldPing }) + c := serialconsole.NewConsole(&textConsole{status: " device : benchpod\r\n ip : 192.168.1.221\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"}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, _, err := blobTargetFor(ctx, c, "/dev/cu.usbmodem1"); err == nil || !strings.Contains(err.Error(), "clear-wifi") { + t.Fatalf("err = %v", err) + } +} diff --git a/cmd/benchpod-cli/cloudcfg.go b/cmd/benchpod-cli/cloudcfg.go index b5cd7da..634ed8c 100644 --- a/cmd/benchpod-cli/cloudcfg.go +++ b/cmd/benchpod-cli/cloudcfg.go @@ -358,7 +358,18 @@ func runCloudCA(g *globalFlags, action, file string, out, warn io.Writer) error case spec.IsCloud(): label, certs, err = cloudCACloud(g, spec, action) case spec.IsSerial(): + // Firmware 3.9.0+: over the USB network link, which may change the CA (physical presence). + if addr := g.usbLinkAddr(spec.Device); addr != "" { + label = addr + certs, err = cloudCALAN(g, addr, action, upload, warn) + break + } label, certs, err = cloudCAUSB(g, spec.Device, action, upload, warn) + if err != nil && strings.Contains(err.Error(), "moved to the network API") { + err = errors.New("this pod's firmware (3.9.0 or later) installs a CA over the network: its USB network " + + "adapter is not usable here (Wi-Fi configured on the pod turns it off: `benchpod clear-wifi`), so use the " + + "cloud (`--connection embeddedci:`) or turn USB networking back on") + } default: label = spec.Addr certs, err = cloudCALAN(g, spec.Addr, action, upload, warn) @@ -669,6 +680,12 @@ func runCloudProxy(g *globalFlags, action, addr, user, password string, out, war case spec.IsCloud(): label, p, err = cloudProxyCloud(g, spec, action, addr, user, password) case spec.IsSerial(): + // Firmware 3.9.0+: over the USB network link, which may change the proxy (physical presence). + if link := g.usbLinkAddr(spec.Device); link != "" { + label = link + p, err = cloudProxyLAN(g, link, action, addr, user, password) + break + } label, p, err = cloudProxyUSB(g, spec.Device, action, addr, user, password) default: label = spec.Addr diff --git a/cmd/benchpod-cli/flash_self.go b/cmd/benchpod-cli/flash_self.go index 724d458..e405193 100644 --- a/cmd/benchpod-cli/flash_self.go +++ b/cmd/benchpod-cli/flash_self.go @@ -530,18 +530,31 @@ func installBlobsAfterFlash(g *globalFlags, src blobSource) error { ctx, cancel := context.WithTimeout(context.Background(), blobsInstallTimeout) defer cancel() 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() + if !skipUSBNetForBlobs { + // Firmware before 3.9.0: the console. 3.9.0+: the USB network (or the pod's network + // address), which may need some seconds on the computer's side after the reboot. + var lastErr error + for i := 0; i < 20; i++ { + t, closeT, err := blobTargetFor(ctx, console, "the USB console") + if err == nil { + target = t + defer closeT() + lastErr = nil + if _, ok := t.(lanBlobs); ok || i >= 10 { break } + if u, uerr := console.USBNetwork(ctx); uerr != nil || !u.On { + break // older firmware, or USB networking off: no point waiting for it + } + } else { + lastErr = err } time.Sleep(time.Second) } + if lastErr != nil { + return fmt.Errorf("flash-self: the firmware is flashed, but its blobs are not installed: %w "+ + "(retry with `benchpod install-blobs`)", lastErr) + } } if _, err := target.Blobs(ctx); err != nil { if errors.Is(err, serialconsole.ErrNoBlobSlots) { diff --git a/cmd/benchpod-cli/usbbridge.go b/cmd/benchpod-cli/usbbridge.go index efb7c0f..63111b6 100644 --- a/cmd/benchpod-cli/usbbridge.go +++ b/cmd/benchpod-cli/usbbridge.go @@ -3,7 +3,6 @@ package main import ( "bufio" "context" - "errors" "fmt" "io" "log" @@ -61,56 +60,12 @@ func newUSBBridgeCmd(g *globalFlags) *cobra.Command { return cmd } -// bridgeSession is one bridge run: the pod's USB link, the proxy's listener on this computer's -// end of it, and the console device it was set up through. +// bridgeSession is one bridge run: the pod's USB link and the proxy's listener on this +// computer's end of it. type bridgeSession struct { u serialconsole.USBNet proxyAddr string ln net.Listener - device string -} - -// setupBridge checks the pod's USB link, listens on this computer's end of it and points the -// pod's cloud proxy there (unless it already is). The console is closed again before it returns. -func setupBridge(g *globalFlags, port int) (*bridgeSession, error) { - device, err := g.usbDevice("usb-bridge") - if err != nil { - return nil, err - } - console, path, ctx, cancel, err := g.openSerialConsole(device, 15*time.Second) - if err != nil { - return nil, err - } - defer cancel() - defer console.Close() - u, err := console.USBNetwork(ctx) - if err == nil { - err = usbBridgeReady(u) - } - if err != nil { - return nil, err - } - proxyAddr := net.JoinHostPort(u.HostIP, strconv.Itoa(port)) - cur, err := console.CloudProxy(ctx) - if err != nil { - return nil, fmt.Errorf("read the pod's cloud proxy: %w", err) - } - if cur.Addr != "" && cur.Addr != proxyAddr { - return nil, fmt.Errorf("the pod already has a cloud proxy (%s); the bridge would replace it. "+ - "Remove it first with `benchpod cloud proxy clear --connection usb`", cur.Addr) - } - ln, err := net.Listen("tcp", proxyAddr) - if err != nil { - return nil, fmt.Errorf("listen on %s, this computer's end of the USB link: %w "+ - "(if the adapter has no address yet, run `benchpod usb-reattach`)", proxyAddr, err) - } - if cur.Addr != proxyAddr { - if _, err := console.CloudProxySet(ctx, proxyAddr, "", ""); err != nil { - ln.Close() - return nil, fmt.Errorf("set the pod's cloud proxy: %w", err) - } - } - return &bridgeSession{u: u, proxyAddr: proxyAddr, ln: ln, device: path}, nil } // hostHasIP reports whether a local interface holds ip: the USB link is gone when it does not. @@ -166,52 +121,8 @@ func runUSBBridge(g *globalFlags, port int, keep, watch bool) error { if port <= 0 || port > 65535 { return fmt.Errorf("usb-bridge: --port %d is out of range", port) } - if watch { - return runUSBBridgeWatch(g, port, keep) // never polls the console (usbbridge_watch.go) - } - ctx, stop := context.WithCancel(context.Background()) - defer stop() - defer installSignalHandler(ctx, stop)() - - last, err := setupBridge(g, port) - if err != nil { - return fmt.Errorf("usb-bridge: %w", err) - } - fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s. Ctrl-C stops it.\n", last.u.PodIP, last.proxyAddr) - if !serveBridge(ctx, last) { - log.Printf("usb-bridge: the USB link to the pod (%s) went away", last.u.PodIP) - return errors.New("usb-bridge: the USB link to the pod went away (`usb-bridge --watch` waits for it instead)") - } - - if keep { - fmt.Fprintf(os.Stderr, "usb-bridge: stopped; the pod's cloud proxy stays %s (--keep-proxy)\n", last.proxyAddr) - return nil - } - console, _, cctx, cancel, err := g.openSerialConsole(last.device, 15*time.Second) - if err != nil { - return fmt.Errorf("usb-bridge: stopped, but could not clear the pod's cloud proxy (%w); "+ - "run `benchpod cloud proxy clear --connection usb`", err) - } - defer cancel() - defer console.Close() - if _, err := console.CloudProxyClear(cctx); err != nil { - return fmt.Errorf("usb-bridge: stopped, but clearing the pod's cloud proxy failed: %w", err) - } - fmt.Fprintln(os.Stderr, "usb-bridge: stopped; cleared the pod's cloud proxy") - return nil -} - -// usbBridgeReady says why the USB link cannot carry the bridge, or nil. -func usbBridgeReady(u serialconsole.USBNet) error { - if !u.On { - return errors.New("USB networking is off because Wi-Fi is configured on the pod; " + - "the pod reaches the cloud over Wi-Fi then (`benchpod clear-wifi` turns USB networking back on)") - } - if !u.Link { - return fmt.Errorf("the pod's USB network adapter (%s) is not up on this computer; "+ - "macOS ignores a new adapter while the screen is locked: unlock and run `benchpod usb-reattach`", u.PodIP) - } - return nil + // Never the console (usbbridge_watch.go): the pod's USB network link only. + return runUSBBridgeWatch(g, port, keep, watch) } // connectProxy is a minimal HTTP CONNECT proxy for one client address and one destination port. diff --git a/cmd/benchpod-cli/usbbridge_service.go b/cmd/benchpod-cli/usbbridge_service.go index e1d2c4d..b1211f8 100644 --- a/cmd/benchpod-cli/usbbridge_service.go +++ b/cmd/benchpod-cli/usbbridge_service.go @@ -2,9 +2,12 @@ package main import ( "bytes" + "context" + "encoding/json" "errors" "fmt" "html" + "net" "os" "os/exec" "path/filepath" @@ -317,34 +320,31 @@ func newUSBBridgeUninstallCmd(g *globalFlags) *cobra.Command { } } -// clearBridgeProxy clears the pod's cloud proxy when it points at a bridge on this computer. +// clearBridgeProxy clears the pod's cloud proxy over its USB network link when it points at this +// computer's end of that link (never the console). func clearBridgeProxy(g *globalFlags) error { - device, err := g.usbDevice("usb-bridge uninstall") - if err != nil { - return err - } - console, _, ctx, cancel, err := g.openSerialConsole(device, 15*time.Second) - if err != nil { - return err + links := localUSBLinks() + if len(links) == 0 { + return errors.New("no pod on a USB network link") } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - defer console.Close() - u, err := console.USBNetwork(ctx) + link, err := pickUSBLink(ctx, links, g.usbPodName()) if err != nil { return err } - cur, err := console.CloudProxy(ctx) + raw, err := bridgePodAPI(ctx, net.JoinHostPort(link.pod, "8080"), map[string]any{"cmd": "cloud_proxy"}) if err != nil { return err } - host := cur.Addr - if i := strings.LastIndex(host, ":"); i >= 0 { - host = host[:i] + var cur struct { + Host string `json:"host"` } - if cur.Addr == "" || host != u.HostIP { + _ = json.Unmarshal(raw, &cur) + if cur.Host != link.host { return nil // not ours } - if _, err := console.CloudProxyClear(ctx); err != nil { + if err := bridgeSetProxy(link.pod, ""); err != nil { return err } fmt.Fprintln(os.Stderr, "usb-bridge: cleared the pod's cloud proxy") diff --git a/cmd/benchpod-cli/usbbridge_test.go b/cmd/benchpod-cli/usbbridge_test.go index a010bd6..ac6b769 100644 --- a/cmd/benchpod-cli/usbbridge_test.go +++ b/cmd/benchpod-cli/usbbridge_test.go @@ -8,8 +8,6 @@ import ( "strings" "testing" "time" - - "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" ) // startProxy runs a connectProxy on a loopback listener that serves 127.0.0.1 and tunnels to @@ -121,15 +119,3 @@ func TestConnectProxyRefusesPlainRequests(t *testing.T) { t.Fatalf("status %q, want 405", status) } } - -func TestUSBBridgeReady(t *testing.T) { - if err := usbBridgeReady(serialconsole.USBNet{On: true, Link: true}); err != nil { - t.Fatal(err) - } - if err := usbBridgeReady(serialconsole.USBNet{}); err == nil || !strings.Contains(err.Error(), "clear-wifi") { - t.Fatalf("net off: %v", err) - } - if err := usbBridgeReady(serialconsole.USBNet{On: true}); err == nil || !strings.Contains(err.Error(), "usb-reattach") { - t.Fatalf("link down: %v", err) - } -} diff --git a/cmd/benchpod-cli/usbbridge_watch.go b/cmd/benchpod-cli/usbbridge_watch.go index 9f4df2b..3b3620e 100644 --- a/cmd/benchpod-cli/usbbridge_watch.go +++ b/cmd/benchpod-cli/usbbridge_watch.go @@ -18,14 +18,14 @@ import ( "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" ) -// usb-bridge --watch, the background service. It never polls the pod's USB console: the console +// usb-bridge (and --watch, the background service) never opens the pod's USB console: the console // is shared with the web app's setup wizard (WebSerial), other benchpod commands and terminals -// (exclusive on Windows), and probing serial ports in a loop would also type into unrelated -// devices (ST-Link virtual COM ports share VID 0483). Instead it watches for this computer's end -// of a pod's USB network link (a 10.237.x.y/30 address on a local interface; the pod is the -// other host), reads the pod's cloud proxy over the JSON API there, and opens the console once, -// only when the proxy has to be set (the LAN tier may read it but not change it) or cleared. -// With Wi-Fi configured the pod's USB networking is off: no address appears, nothing is opened. +// (exclusive on Windows), and probing serial ports would also type into unrelated devices +// (ST-Link virtual COM ports share VID 0483). It watches for this computer's end of a pod's USB +// network link (a 10.237.x.y/30 address on a local interface; the pod is the other host) and reads, +// sets and clears the pod's cloud proxy over the JSON API there (firmware 3.9.0 lets the USB link +// change it: physical presence). With Wi-Fi configured the pod's USB networking is off: no address +// appears and nothing happens. // usbWatchPoll is how often --watch looks at the local interfaces (no I/O to the pod). const usbWatchPoll = 5 * time.Second @@ -63,28 +63,16 @@ var bridgePodAPI = func(ctx context.Context, addr string, cmd map[string]any) (j return (&tcpclient.Client{Addr: addr}).Command(ctx, cmd) } -// bridgeConsoleOpens counts console opens by --watch (tests assert it stays 0 while idle). -var bridgeConsoleOpens int - -// bridgeSetProxy sets (addr != "") or clears the pod's cloud proxy over its console, once. +// bridgeSetProxy sets (proxy != "") or clears the pod's cloud proxy over its USB network link. // A variable so tests can replace it. -var bridgeSetProxy = func(g *globalFlags, addr string) error { - bridgeConsoleOpens++ - device, err := g.usbDevice("usb-bridge") - if err != nil { - return err - } - console, _, ctx, cancel, err := g.openSerialConsole(device, 15*time.Second) - if err != nil { - return err - } +var bridgeSetProxy = func(pod, proxy string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - defer console.Close() - if addr == "" { - _, err = console.CloudProxyClear(ctx) - } else { - _, err = console.CloudProxySet(ctx, addr, "", "") + req := map[string]any{"cmd": "cloud_proxy", "clear": true} + if proxy != "" { + req = map[string]any{"cmd": "cloud_proxy", "set": proxy} } + _, err := bridgePodAPI(ctx, net.JoinHostPort(pod, "8080"), req) return err } @@ -124,24 +112,39 @@ func pickUSBLink(ctx context.Context, links []usbLink, name string) (usbLink, er } // runUSBBridgeWatch is `usb-bridge --watch`. -func runUSBBridgeWatch(g *globalFlags, port int, keep bool) error { +func runUSBBridgeWatch(g *globalFlags, port int, keep, watch bool) error { ctx, stop := context.WithCancel(context.Background()) defer stop() defer installSignalHandler(ctx, stop)() - return watchBridge(ctx, g, port, keep, usbWatchPoll) + return watchBridge(ctx, g, port, keep, usbWatchPoll, !watch) } -func watchBridge(ctx context.Context, g *globalFlags, port int, keep bool, poll time.Duration) error { +// errBridgeStop ends a one-shot (not --watch) bridge with its reason. +type errBridgeStop struct{ why string } + +func (e errBridgeStop) Error() string { return "usb-bridge: " + e.why } + +// watchBridge runs the bridge until ctx ends. once (no --watch): stop with the reason instead of +// waiting when the pod is not ready or its USB link goes away. +func watchBridge(ctx context.Context, g *globalFlags, port int, keep bool, poll time.Duration, once bool) (rerr error) { name := g.usbPodName() - var setByUs string // the proxy this run set on the pod, cleared again on stop + var setByUs, setOn string // the proxy this run set on the pod (and the pod), cleared again on stop lastWhy := "" + var stopWhy string note := func(why string) { + if once { + stopWhy = why + return + } if why != lastWhy { log.Printf("usb-bridge: waiting: %s", why) lastWhy = why } } wait := func() bool { + if once && stopWhy != "" { + return false + } select { case <-ctx.Done(): return false @@ -149,10 +152,16 @@ func watchBridge(ctx context.Context, g *globalFlags, port int, keep bool, poll return true } } + defer func() { + if rerr == nil && stopWhy != "" { + rerr = errBridgeStop{stopWhy} + } + }() for ctx.Err() == nil { links := localUSBLinks() if len(links) == 0 { - note("no pod on a USB network link (none plugged in, Wi-Fi configured on it, or the adapter not up yet)") + note("no pod on a USB network link: none plugged in, firmware older than 3.9.0, Wi-Fi configured on it " + + "(`benchpod clear-wifi`), or the adapter not up (macOS under a locked screen: unlock and run `benchpod usb-reattach`)") if !wait() { break } @@ -204,29 +213,35 @@ func watchBridge(ctx context.Context, g *globalFlags, port int, keep bool, poll continue } if curAddr != proxyAddr { - // The one console use: the LAN tier may read the proxy but not set it. - if err := bridgeSetProxy(g, proxyAddr); err != nil { + if err := bridgeSetProxy(link.pod, proxyAddr); err != nil { ln.Close() - note(fmt.Sprintf("set the pod's cloud proxy over its console: %v", err)) + note(fmt.Sprintf("set the pod's cloud proxy: %v", err)) if !wait() { break } continue } - setByUs = proxyAddr + setByUs, setOn = proxyAddr, link.pod } lastWhy = "" - fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s\n", link.pod, proxyAddr) + fmt.Fprintf(os.Stderr, "usb-bridge: the pod (%s) reaches embeddedci.com through %s. Ctrl-C stops it.\n", link.pod, proxyAddr) s := &bridgeSession{u: serialconsole.USBNet{On: true, Link: true, PodIP: link.pod, HostIP: link.host}, proxyAddr: proxyAddr, ln: ln} if serveBridge(ctx, s) { break } log.Printf("usb-bridge: the USB link to the pod (%s) went away", link.pod) + if once { + stopWhy = "the USB link to the pod went away (`usb-bridge --watch` waits for it instead)" + break + } } if setByUs == "" || keep { + if keep && setByUs != "" { + fmt.Fprintf(os.Stderr, "usb-bridge: stopped; the pod's cloud proxy stays %s (--keep-proxy)\n", setByUs) + } return nil } - if err := bridgeSetProxy(g, ""); err != nil { + if err := bridgeSetProxy(setOn, ""); err != nil { return fmt.Errorf("usb-bridge: stopped, but could not clear the pod's cloud proxy (%v); "+ "run `benchpod cloud proxy clear --connection usb`", err) } diff --git a/cmd/benchpod-cli/usbbridge_watch_test.go b/cmd/benchpod-cli/usbbridge_watch_test.go index abd6d14..caeac51 100644 --- a/cmd/benchpod-cli/usbbridge_watch_test.go +++ b/cmd/benchpod-cli/usbbridge_watch_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net" + "strings" "testing" "time" ) @@ -34,7 +35,7 @@ func withFakeBridge(t *testing.T, f *fakeBridge) { host, port, _ := net.SplitHostPort(f.proxy) return json.RawMessage(fmt.Sprintf(`{"host":%q,"port":%s,"auth":false}`, host, port)), nil } - bridgeSetProxy = func(_ *globalFlags, addr string) error { + bridgeSetProxy = func(_ string, addr string) error { f.console = append(f.console, addr) f.proxy = addr return nil @@ -46,7 +47,7 @@ func runWatchFor(t *testing.T, d time.Duration, port int) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), d) defer cancel() - if err := watchBridge(ctx, &globalFlags{connection: "usb"}, port, false, 5*time.Millisecond); err != nil { + if err := watchBridge(ctx, &globalFlags{connection: "usb"}, port, false, 5*time.Millisecond, false); err != nil { t.Fatal(err) } } @@ -97,3 +98,12 @@ func TestShortIDOf(t *testing.T) { t.Fatalf("got %q", got) } } + +// Without --watch the bridge stops with the reason instead of waiting. +func TestBridgeWithoutWatchSaysWhyItCannotStart(t *testing.T) { + withFakeBridge(t, &fakeBridge{}) + err := watchBridge(context.Background(), &globalFlags{connection: "usb"}, 39132, false, time.Millisecond, true) + if err == nil || !strings.Contains(err.Error(), "clear-wifi") || !strings.Contains(err.Error(), "usb-reattach") { + t.Fatalf("err = %v", err) + } +} diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go index cc8c15e..bf392b8 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -115,6 +115,27 @@ func (g *globalFlags) preferUSBNetwork(spec ConnSpec, cmd string) ConnSpec { // usbNote is where networkTarget says which way it took (stderr; tests capture it). var usbNote io.Writer = os.Stderr +// usbLinkAddr is the pod's API address on its USB network link when that works (firmware 3.9.0+, +// no Wi-Fi configured, adapter up), else "": never the Ethernet or Wi-Fi fallback. For the settings +// only the USB link may change over the network (the company CA and the cloud proxy). +func (g *globalFlags) usbLinkAddr(device string) string { + if g.usbAddr != "" { + if host, _, err := net.SplitHostPort(g.usbAddr); err == nil && usbLinkNet.Contains(net.ParseIP(host)) { + return g.usbAddr + } + return "" + } + u, err := usbNetwork(g, device) + if err != nil || !u.On || !u.Link { + return "" + } + addr := net.JoinHostPort(u.PodIP, "8080") + if dialUSBPod(addr) != nil { + return "" + } + return addr +} + // 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. diff --git a/cmd/benchpod-cli/usbnet_test.go b/cmd/benchpod-cli/usbnet_test.go index cee2282..280c1b1 100644 --- a/cmd/benchpod-cli/usbnet_test.go +++ b/cmd/benchpod-cli/usbnet_test.go @@ -145,3 +145,19 @@ func TestDescribePowerDraw(t *testing.T) { t.Errorf("nothing readable: %q", got) } } + +// The CA and proxy may change only over the USB link itself, never over the LAN fallback. +func TestUSBLinkAddrNeverFallsBackToTheLAN(t *testing.T) { + withUSBNet(t, serialconsole.USBNet{On: true, Link: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86", LANIP: "192.168.1.221"}, nil, nil) + if got := (&globalFlags{connection: "usb"}).usbLinkAddr(""); got != "10.237.8.85:8080" { + t.Fatalf("up: %q", got) + } + withUSBNet(t, serialconsole.USBNet{PodIP: "10.237.8.85", LANIP: "192.168.1.221"}, nil, nil) // Wi-Fi set + if got := (&globalFlags{connection: "usb"}).usbLinkAddr(""); got != "" { + t.Fatalf("USB networking off: %q, want none (the console instead)", got) + } + g := &globalFlags{connection: "usb", usbAddr: "192.168.1.221:8080"} // networkTarget took the LAN + if got := g.usbLinkAddr(""); got != "" { + t.Fatalf("LAN fallback used for the CA/proxy: %q", got) + } +} From e1f8c85c02bc09a7c840a467d1fae3a0c422684e Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:22:35 -0500 Subject: [PATCH 23/29] scripts/hw-e2e.sh: CLI hardware check over any connection Runs status, ping, LA, capture, network, policies, identity, cloud proxy/CA set+clear, install-blobs gw1, SWD flash and usb-reattach against a real pod; prints PASS/FAIL/SKIP per step and exits with the failure count. Over usb on baea06 (3.9.0 candidate): 19 passed. Co-Authored-By: Claude Opus 5.5 --- scripts/hw-e2e.sh | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100755 scripts/hw-e2e.sh diff --git a/scripts/hw-e2e.sh b/scripts/hw-e2e.sh new file mode 100755 index 0000000..e8aee9f --- /dev/null +++ b/scripts/hw-e2e.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Hardware end-to-end check of the benchpod CLI against a real pod, over any connection. +# +# scripts/hw-e2e.sh [options] +# +# what --connection gets: usb, usb:, a device path, 192.168.1.5, ... +# --bin PATH the benchpod binary to test (default: build ./cmd/benchpod-cli) +# --flash ELF also flash this DUT image over SWD (--target, pins from the wiring profile) +# --target CFG OpenOCD target config for --flash (default target/stm32f4x.cfg) +# --power N power the target from eFuse N (1 internal 5V, 2 external) for --flash; the +# DUT stays powered afterwards +# --blobs also reinstall the gw1 blob (install-blobs --only gw1 --force) +# --cloudcfg also set and clear a test cloud proxy and company CA on the pod +# --reattach also run usb-reattach and check the pod answers again (USB connections) +# +# Every step prints PASS, FAIL or SKIP; the last line is the summary and the exit code is the +# number of failures (0 = all passed). It changes nothing it does not put back: the proxy and the +# CA are cleared again, and gw1 is rewritten with the release's own image. Hold the bench lock. +set -u +CONN=${1:?usage: scripts/hw-e2e.sh [--bin PATH] [--flash ELF] [--target CFG] [--power N] [--blobs] [--cloudcfg] [--reattach]} +shift +BIN="" FLASH="" TARGET="target/stm32f4x.cfg" POWER="" BLOBS=0 CLOUDCFG=0 REATTACH=0 +while [ $# -gt 0 ]; do + case "$1" in + --bin) BIN=$2; shift ;; + --flash) FLASH=$2; shift ;; + --target) TARGET=$2; shift ;; + --power) POWER=$2; shift ;; + --blobs) BLOBS=1 ;; + --cloudcfg) CLOUDCFG=1 ;; + --reattach) REATTACH=1 ;; + *) echo "unknown option $1" >&2; exit 2 ;; + esac + shift +done +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +if [ -z "$BIN" ]; then + BIN=$TMP/benchpod + (cd "$(dirname "$0")/.." && go build -o "$BIN" ./cmd/benchpod-cli) || exit 1 +fi + +PASS=0 FAIL=0 SKIP=0 +# step NAME REGEX -- command args: runs `benchpod args --connection CONN`, passes when it exits 0 +# and its output matches REGEX (extended, case-insensitive). +step() { + local name=$1 re=$2 + shift 3 + local out rc + out=$("$BIN" "$@" --connection "$CONN" 2>&1) + rc=$? + if [ $rc -eq 0 ] && grep -Eiq -- "$re" <<<"$out"; then + PASS=$((PASS + 1)); echo "PASS $name" + else + FAIL=$((FAIL + 1)); echo "FAIL $name (exit $rc)"; grep -v '\[serial\]' <<<"$out" | tail -5 | sed 's/^/ /' + fi +} +skip() { SKIP=$((SKIP + 1)); echo "SKIP $1 ($2)"; } + +step "status" 'BenchPod|device : benchpod' -- status +step "ping" 'pong' -- ping +step "la voltage" 'LA voltage' -- la voltage +step "la status" '.' -- la status +step "capture 256" '^255,' -- capture --samples 256 --output csv +step "show-network" 'IP:' -- show-network +step "lan-policy" 'LAN policy' -- lan-policy +step "sig-policy" 'Signature policy' -- sig-policy +step "identity" 'benchpod-[0-9a-f]{6}' -- identity +step "cloud proxy show" 'proxy' -- cloud proxy show +step "cloud ca show" 'CA|certificate|none' -- cloud ca show + +if [ $CLOUDCFG -eq 1 ]; then + step "cloud proxy set" '10\.255\.255\.1:3128' -- cloud proxy set 10.255.255.1:3128 + step "cloud proxy clear" 'none|without a proxy|directly' -- cloud proxy clear + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 1 -subj "/CN=benchpod hw-e2e test CA" \ + -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign" \ + -keyout "$TMP/ca.key" -out "$TMP/ca.pem" >/dev/null 2>&1 + step "cloud ca set" 'hw-e2e test CA' -- cloud ca set "$TMP/ca.pem" + step "cloud ca clear" 'none|built-in' -- cloud ca clear +else + skip "cloud proxy/ca set+clear" "pass --cloudcfg" +fi + +if [ $BLOBS -eq 1 ]; then + step "install-blobs gw1" 'gw1 installed' -- install-blobs --only gw1 --force +else + skip "install-blobs" "pass --blobs" +fi + +if [ -n "$FLASH" ]; then + step "flash (SWD)" 'flash: done|Verified OK|verified' -- flash --file "$FLASH" --target "$TARGET" ${POWER:+--target-power "$POWER"} +else + skip "flash" "pass --flash ELF" +fi + +if [ $REATTACH -eq 1 ]; then + step "usb-reattach" 'reconnected' -- usb-reattach + sleep 10 + step "ping after reattach" 'pong' -- ping +else + skip "usb-reattach" "pass --reattach" +fi + +echo "hw-e2e $CONN: $PASS passed, $FAIL failed, $SKIP skipped" +exit $FAIL From bf7a9b5e52d7705eb0e08d9d1f3aa730309a5ee7 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:24:03 -0500 Subject: [PATCH 24/29] docs: two pods on one computer are not tested on hardware yet Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6b89d95..21ed687 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ reach the pod; the transport is inferred from its value: | `192.168.1.5[:8080]` | The pod's network TCP/JSON API (Ethernet or Wi-Fi). | | `/dev/tty...`, `COM3` | USB, explicit console device path. | | `usb` | USB, the console auto-detected by probing the ports. | -| `usb:` | USB, the pod with that name (`usb:benchpod-a1b2c3`) when several are plugged in; plain `usb` then lists them. | +| `usb:` | USB, the pod with that name (`usb:benchpod-a1b2c3`) when several are plugged in; plain `usb` then lists them (not yet tested with two physical pods). | | `embeddedci:` | Through embeddedci.com to the pod with that name on your account (see below). | | *(omitted)* | the default saved by `benchpod discover --save`, `benchpod setup` or `benchpod set-connection`. | From f88f301f64e13944a686087ead3517a996405c8d Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:30:01 -0500 Subject: [PATCH 25/29] Refusal of a CA/proxy change from the LAN says "or over USB" Firmware 3.9.0 takes cloud_ca and cloud_proxy changes over the USB network adapter as well as the console, so the refusal on Ethernet/Wi-Fi now reads ": change it from the cloud or over USB". sig_policy and lan_policy keep "... or the USB console". Host tools accept both texts (older firmware). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- cmd/benchpod-cli/cloudcfg.go | 19 ++++++++++++------- cmd/benchpod-cli/cloudcfg_test.go | 6 +++--- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 21ed687..d183a47 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,7 @@ The pod reconnects to the cloud through proxy.corp:3128. ``` $ benchpod cloud proxy clear --connection 192.168.1.50 - [benchpod] cloud proxy clear: the pod refused: cloud_proxy: change it from the cloud or the USB console (the pod only takes this change over USB or from the cloud, never from the LAN; run it over the pod's USB console with --connection usb) + [benchpod] cloud proxy clear: the pod refused: cloud_proxy: change it from the cloud or over USB (the pod only takes this change over USB or from the cloud, never from Ethernet or Wi-Fi; run it with --connection usb) ``` Older firmware with its LAN policy `locked` refuses the same way, with its own diff --git a/cmd/benchpod-cli/cloudcfg.go b/cmd/benchpod-cli/cloudcfg.go index 634ed8c..bcc6cf1 100644 --- a/cmd/benchpod-cli/cloudcfg.go +++ b/cmd/benchpod-cli/cloudcfg.go @@ -34,7 +34,7 @@ import ( // with target "ca") or the LAN JSON API (`cloud_ca`, `cloud_proxy`, ota_* with target "ca"). // Both settings are T2 commands, so a pod whose LAN policy is locked refuses changes on the LAN. // Newer firmware goes further: it refuses every change to them from the LAN, whatever the LAN -// policy ("cloud_ca: change it from the cloud or the USB console"); only reading works there. +// policy ("cloud_ca: change it from the cloud or over USB"); only reading works there. const ( caMaxPEM = 16 * 1024 // the pod's limit on the stored PEM @@ -42,18 +42,23 @@ const ( caLANChunk = 768 // raw bytes per ota_data line (as the hwe2e LAN OTA sends) caMissing = "this firmware has no company CA support; update the pod's firmware" proxyMissing = "this firmware has no HTTP proxy support; update the pod's firmware" - lanGateHint = "the pod only takes this change over USB or from the cloud, never from the LAN; run it over the pod's USB console with --connection usb" + lanGateHint = "the pod only takes this change over USB or from the cloud, never from Ethernet or Wi-Fi; run it with --connection usb" ) -// lanGateRefusal is the tail of the firmware's refusal of a cloud link change from the LAN -// (pod_policy_cloud_link_gate): "cloud_ca: ..." or "cloud_proxy: ...". -const lanGateRefusal = ": change it from the cloud or the USB console" +// lanGateRefusals are the tails of the firmware's refusal of a cloud link change from the LAN +// (pod_policy_cloud_link_gate): "cloud_ca: ..." or "cloud_proxy: ...". Firmware 3.9.0 also takes +// the change over its USB network adapter and says so; older firmware names the console. +var lanGateRefusals = []string{": change it from the cloud or over USB", ": change it from the cloud or the USB console"} // isLANGateRefusal reports whether msg is the firmware's refusal to change the company CA or // the HTTP proxy from the LAN. func isLANGateRefusal(msg string) bool { - verb, ok := strings.CutSuffix(strings.TrimSpace(msg), lanGateRefusal) - return ok && (verb == "cloud_ca" || verb == "cloud_proxy") + for _, tail := range lanGateRefusals { + if verb, ok := strings.CutSuffix(strings.TrimSpace(msg), tail); ok { + return verb == "cloud_ca" || verb == "cloud_proxy" + } + } + return false } func newCloudCmd(g *globalFlags) *cobra.Command { diff --git a/cmd/benchpod-cli/cloudcfg_test.go b/cmd/benchpod-cli/cloudcfg_test.go index f268369..9eb3b6e 100644 --- a/cmd/benchpod-cli/cloudcfg_test.go +++ b/cmd/benchpod-cli/cloudcfg_test.go @@ -422,7 +422,7 @@ func (p *lanCfgPod) handle(req map[string]any) string { return errReply("locked: " + cmd + " needs the cloud or the USB console") } if p.gated && (cmd == "cloud_ca" || cmd == "cloud_proxy") && changes { - return errReply(cmd + ": change it from the cloud or the USB console") + return errReply(cmd + ": change it from the cloud or over USB") } if p.gated && cmd == "ota_begin" && req["target"] == "ca" { return errReply("cloud_ca: change it from the cloud or the USB console") @@ -568,7 +568,7 @@ func TestCloudLANGated(t *testing.T) { t.Fatalf("requests = %s", pod.cmds()) } _, _, err = runCACapture(g, "clear", "") - want = "cloud ca clear: the pod refused: cloud_ca: change it from the cloud or the USB console (" + lanGateHint + ")" + want = "cloud ca clear: the pod refused: cloud_ca: change it from the cloud or over USB (" + lanGateHint + ")" if err == nil || err.Error() != want { t.Fatalf("ca clear: err = %v\nwant %s", err, want) } @@ -578,7 +578,7 @@ func TestCloudLANGated(t *testing.T) { addrArg, user, pw = "proxy.corp:3128", "alice", "s3cret" } _, _, err = runProxyCapture(g, action, addrArg, user, pw) - want = "cloud proxy " + action + ": the pod refused: cloud_proxy: change it from the cloud or the USB console (" + lanGateHint + ")" + want = "cloud proxy " + action + ": the pod refused: cloud_proxy: change it from the cloud or over USB (" + lanGateHint + ")" if err == nil || err.Error() != want { t.Fatalf("proxy %s: err = %v\nwant %s", action, err, want) } From b31bcb6b206cdc3ae561b0dc2b11b8751b17d64d Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:33:21 -0500 Subject: [PATCH 26/29] Locked-LAN refusal says "needs the cloud or USB" Under external LAN policy locked, firmware 3.9.0 still takes T2/T3 commands over its USB network adapter, so the refusal on Ethernet/Wi-Fi now reads "locked: needs the cloud or USB". Host tools match the "locked:" prefix; hwe2e accepts both texts (older firmware). Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/benchpod-cli/errors.go b/cmd/benchpod-cli/errors.go index 2c038ab..1bde639 100644 --- a/cmd/benchpod-cli/errors.go +++ b/cmd/benchpod-cli/errors.go @@ -18,7 +18,7 @@ import ( // pins the texts) and explainRefusal is the one place that says what to do about each. const ( - lockedUSBHint = "the pod's LAN policy is locked; run this over the pod's USB console with --connection usb" + lockedUSBHint = "the pod's external LAN policy is locked; run this over USB with --connection usb" leaseBusyHint = "on the LAN the pod can only be read while a cloud job holds it; try again when the job ends" heavyBusyHint = "the pod is running another long command; try again when it ends" forbiddenHint = "API keys need the benchpod:admin scope" From 2ecdc875059095de1dd82411bcfb3f59c320b9c9 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:49:06 -0500 Subject: [PATCH 27/29] usb-bridge: help says it never opens the console The proxy is set and cleared over the USB network link since 3e02477; the help still described the older console step. Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/usbbridge.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/benchpod-cli/usbbridge.go b/cmd/benchpod-cli/usbbridge.go index 63111b6..43602d3 100644 --- a/cmd/benchpod-cli/usbbridge.go +++ b/cmd/benchpod-cli/usbbridge.go @@ -42,9 +42,8 @@ func newUSBBridgeCmd(g *globalFlags) *cobra.Command { "Ctrl-C, then clears the pod's proxy again (--keep-proxy leaves it set).\n\n" + "--watch keeps going when no pod is plugged in or the USB link goes away, and picks the\n" + "pod up again when it comes back: that is how `usb-bridge install` runs it in the\n" + - "background. It never polls or holds the pod's USB console: it watches for this\n" + - "computer's USB network address, reads the pod's proxy over that link, and opens the\n" + - "console once only to set the proxy (or clear it when it stops).\n\n" + + "background. It never opens the pod's USB console: it watches for this computer's\n" + + "USB network address and reads, sets and clears the pod's proxy over that link.\n\n" + "Needs firmware 3.9.0 or later and no Wi-Fi configured on the pod. The proxy accepts\n" + "only the pod, and only tunnels to port 443. It refuses to replace a proxy the pod\n" + "already has (`benchpod cloud proxy clear` removes it).", From 6e2ce557f1e13ea5a4ebf69039fd81344eed3043 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 08:47:00 -0500 Subject: [PATCH 28/29] usb: network commands find the pod from this computer's USB link On Linux the console (/dev/ttyACM0, root:dialout 0660) is closed to users outside the dialout group, and 'usb' then failed with "no bench-pod console found". Network commands over usb (and the CA/proxy USB-link path) now take the pod's address from this computer's end of its USB network link, as usb-bridge does, with no console; usb: picks by identity. A pod console refused for lack of permission now says so, with the dialout / udev fix. The udev rule adds TAG+="uaccess" so the desktop user can open the console. Found in the 3.9.0 Linux e2e run. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++- cmd/benchpod-cli/cloudcfg.go | 4 +-- cmd/benchpod-cli/podclient_test.go | 1 + cmd/benchpod-cli/usbnet.go | 36 +++++++++++++++++++++++- cmd/benchpod-cli/usbnet_test.go | 37 +++++++++++++++++++++++-- internal/serialconsole/denied_test.go | 24 ++++++++++++++++ internal/serialconsole/select.go | 6 +++- internal/serialconsole/serialconsole.go | 29 +++++++++++++++++-- packaging/linux/70-benchpod.rules | 7 +++-- 9 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 internal/serialconsole/denied_test.go diff --git a/README.md b/README.md index d183a47..cc9e931 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,11 @@ sudo udevadm control --reload-rules && sudo udevadm trigger ``` It sets `ENV{ID_MM_DEVICE_IGNORE}="1"` for the pod's USB IDs: `1209:0001` (firmware -3.9.0 and later) and `0483:5740` (older firmware). +3.9.0 and later) and `0483:5740` (older firmware), and `TAG+="uaccess"`, which lets the +user signed in at the desktop open the console without joining the `dialout` group. Over +SSH, or without the rule, add yourself to `dialout` (`sudo usermod -aG dialout $USER`, +then log in again). On firmware 3.9.0 and later the network commands need neither: over +`--connection usb` the CLI finds the pod from this computer's end of its USB network link. ### OpenOCD (for `flash`) diff --git a/cmd/benchpod-cli/cloudcfg.go b/cmd/benchpod-cli/cloudcfg.go index bcc6cf1..fe7852a 100644 --- a/cmd/benchpod-cli/cloudcfg.go +++ b/cmd/benchpod-cli/cloudcfg.go @@ -364,7 +364,7 @@ func runCloudCA(g *globalFlags, action, file string, out, warn io.Writer) error label, certs, err = cloudCACloud(g, spec, action) case spec.IsSerial(): // Firmware 3.9.0+: over the USB network link, which may change the CA (physical presence). - if addr := g.usbLinkAddr(spec.Device); addr != "" { + if addr := g.usbLinkAddr(spec.Device, spec.Pod); addr != "" { label = addr certs, err = cloudCALAN(g, addr, action, upload, warn) break @@ -686,7 +686,7 @@ func runCloudProxy(g *globalFlags, action, addr, user, password string, out, war label, p, err = cloudProxyCloud(g, spec, action, addr, user, password) case spec.IsSerial(): // Firmware 3.9.0+: over the USB network link, which may change the proxy (physical presence). - if link := g.usbLinkAddr(spec.Device); link != "" { + if link := g.usbLinkAddr(spec.Device, spec.Pod); link != "" { label = link p, err = cloudProxyLAN(g, link, action, addr, user, password) break diff --git a/cmd/benchpod-cli/podclient_test.go b/cmd/benchpod-cli/podclient_test.go index 4935a9a..afa2789 100644 --- a/cmd/benchpod-cli/podclient_test.go +++ b/cmd/benchpod-cli/podclient_test.go @@ -35,6 +35,7 @@ func TestMain(m *testing.M) { usbNetwork = func(*globalFlags, string) (serialconsole.USBNet, error) { return serialconsole.USBNet{}, errors.New("no pod on USB (tests)") } + localUSBPod = func(string) string { return "" } code := m.Run() os.RemoveAll(dir) os.Exit(code) diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go index bf392b8..a6fab50 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -112,19 +112,47 @@ func (g *globalFlags) preferUSBNetwork(spec ConnSpec, cmd string) ConnSpec { return spec } +// localUSBPod finds the pod's API address from this computer's own end of its USB network link +// (firmware 3.9.0+), without opening its console: the one link there is, or the one whose pod is +// named pod (usb:). "" when there is none, several and no name, or the pod does not answer. +// So the network commands work over USB without access to the console device (Linux users +// outside the dialout group) and without probing serial ports. A variable so tests can replace it. +var localUSBPod = func(pod string) string { + links := localUSBLinks() + if len(links) == 0 { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + l, err := pickUSBLink(ctx, links, serialconsole.NormalizePodName(pod)) + if err != nil { + return "" + } + addr := net.JoinHostPort(l.pod, "8080") + if dialUSBPod(addr) != nil { + return "" + } + return addr +} + // usbNote is where networkTarget says which way it took (stderr; tests capture it). var usbNote io.Writer = os.Stderr // usbLinkAddr is the pod's API address on its USB network link when that works (firmware 3.9.0+, // no Wi-Fi configured, adapter up), else "": never the Ethernet or Wi-Fi fallback. For the settings // only the USB link may change over the network (the company CA and the cloud proxy). -func (g *globalFlags) usbLinkAddr(device string) string { +func (g *globalFlags) usbLinkAddr(device, pod string) string { if g.usbAddr != "" { if host, _, err := net.SplitHostPort(g.usbAddr); err == nil && usbLinkNet.Contains(net.ParseIP(host)) { return g.usbAddr } return "" } + if device == "" { + if addr := localUSBPod(pod); addr != "" { + return addr + } + } u, err := usbNetwork(g, device) if err != nil || !u.On || !u.Link { return "" @@ -147,6 +175,12 @@ func (g *globalFlags) networkTarget(cmd string) (ConnSpec, error) { if g.usbAddr != "" { return ConnSpec{Kind: connNetwork, Addr: g.usbAddr}, nil } + if spec.Device == "" && g.usbErr == nil { + if addr := localUSBPod(spec.Pod); addr != "" { + g.usbAddr = addr + return ConnSpec{Kind: connNetwork, Addr: addr}, nil + } + } addr, uerr := g.usbAddr, g.usbErr if uerr == nil { addr, uerr = g.usbNetAddr(spec.Device) diff --git a/cmd/benchpod-cli/usbnet_test.go b/cmd/benchpod-cli/usbnet_test.go index 280c1b1..6e29080 100644 --- a/cmd/benchpod-cli/usbnet_test.go +++ b/cmd/benchpod-cli/usbnet_test.go @@ -149,15 +149,46 @@ func TestDescribePowerDraw(t *testing.T) { // The CA and proxy may change only over the USB link itself, never over the LAN fallback. func TestUSBLinkAddrNeverFallsBackToTheLAN(t *testing.T) { withUSBNet(t, serialconsole.USBNet{On: true, Link: true, PodIP: "10.237.8.85", HostIP: "10.237.8.86", LANIP: "192.168.1.221"}, nil, nil) - if got := (&globalFlags{connection: "usb"}).usbLinkAddr(""); got != "10.237.8.85:8080" { + if got := (&globalFlags{connection: "usb"}).usbLinkAddr("", ""); got != "10.237.8.85:8080" { t.Fatalf("up: %q", got) } withUSBNet(t, serialconsole.USBNet{PodIP: "10.237.8.85", LANIP: "192.168.1.221"}, nil, nil) // Wi-Fi set - if got := (&globalFlags{connection: "usb"}).usbLinkAddr(""); got != "" { + if got := (&globalFlags{connection: "usb"}).usbLinkAddr("", ""); got != "" { t.Fatalf("USB networking off: %q, want none (the console instead)", got) } g := &globalFlags{connection: "usb", usbAddr: "192.168.1.221:8080"} // networkTarget took the LAN - if got := g.usbLinkAddr(""); got != "" { + if got := g.usbLinkAddr("", ""); got != "" { t.Fatalf("LAN fallback used for the CA/proxy: %q", got) } } + +// A network command over `usb` takes the pod's USB link from this computer's own interface, with +// no console: Linux users outside the dialout group cannot open the console device. +func TestNetworkTargetUsesTheLocalUSBLinkWithoutTheConsole(t *testing.T) { + old := localUSBPod + t.Cleanup(func() { localUSBPod = old }) + var asked string + localUSBPod = func(pod string) string { asked = pod; return "10.237.8.85:8080" } + consoleUsed := false + oldNet := usbNetwork + t.Cleanup(func() { usbNetwork = oldNet }) + usbNetwork = func(*globalFlags, string) (serialconsole.USBNet, error) { + consoleUsed = true + return serialconsole.USBNet{}, errors.New("permission denied") + } + got, err := (&globalFlags{connection: "usb:benchpod-baea06"}).networkTarget("capture") + if err != nil || !got.IsNetwork() || got.Addr != "10.237.8.85:8080" { + t.Fatalf("got %+v, %v", got, err) + } + if consoleUsed || asked != "baea06" && asked != "benchpod-baea06" { + t.Fatalf("console used %v, pod asked %q", consoleUsed, asked) + } + if got := (&globalFlags{connection: "usb"}).usbLinkAddr("", ""); got != "10.237.8.85:8080" { + t.Fatalf("CA/proxy link: %q", got) + } + // An explicit console device names that pod: no guessing from the interfaces. + localUSBPod = func(string) string { t.Fatal("local link used for an explicit device"); return "" } + if _, err := (&globalFlags{connection: "/dev/ttyACM0"}).networkTarget("capture"); err == nil { + t.Fatal("want the console's error") + } +} diff --git a/internal/serialconsole/denied_test.go b/internal/serialconsole/denied_test.go new file mode 100644 index 0000000..2344c39 --- /dev/null +++ b/internal/serialconsole/denied_test.go @@ -0,0 +1,24 @@ +package serialconsole + +import ( + "errors" + "strings" + "testing" +) + +// A console the user may not open (Linux: the dialout group) is named with the fix, not reported +// as "no console found". +func TestDeniedHint(t *testing.T) { + if !isPermissionDenied(errors.New("open /dev/ttyACM0: permission denied")) || isPermissionDenied(errors.New("busy")) || isPermissionDenied(nil) { + t.Fatal("isPermissionDenied") + } + if deniedHint(nil) != "" { + t.Fatal("no hint without a refused port") + } + h := deniedHint([]string{"/dev/ttyACM0"}) + for _, want := range []string{"/dev/ttyACM0", "permission denied", "dialout", "70-benchpod.rules"} { + if !strings.Contains(h, want) { + t.Errorf("hint %q lacks %q", h, want) + } + } +} diff --git a/internal/serialconsole/select.go b/internal/serialconsole/select.go index a45f22d..9877da2 100644 --- a/internal/serialconsole/select.go +++ b/internal/serialconsole/select.go @@ -58,6 +58,7 @@ func OpenBenchpodSelect(explicit, preferred, name string, probeTimeout time.Dura return nil, "", noPod(errors.New("no USB ports found; plug in the pod or pass --connection ")) } var found []foundPod + var denied []string closeAll := func(keep string) { for _, f := range found { if f.port != keep { @@ -74,6 +75,9 @@ func OpenBenchpodSelect(explicit, preferred, name string, probeTimeout time.Dura c, err := openPort(port) if err != nil { serialLogf(" %s: open failed: %v", port, err) + if isPermissionDenied(err) { + denied = append(denied, port) + } continue } ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) @@ -110,7 +114,7 @@ func OpenBenchpodSelect(explicit, preferred, name string, probeTimeout time.Dura } switch len(found) { case 0: - return nil, "", noPod(errors.New("no bench-pod console found on USB; pass --connection to force one")) + return nil, "", noPod(fmt.Errorf("no bench-pod console found on USB%s; pass --connection to force one", deniedHint(denied))) case 1: return found[0].c, found[0].port, nil } diff --git a/internal/serialconsole/serialconsole.go b/internal/serialconsole/serialconsole.go index 1ef0f64..8da34cc 100644 --- a/internal/serialconsole/serialconsole.go +++ b/internal/serialconsole/serialconsole.go @@ -236,6 +236,26 @@ type noPodError struct{ err error } func noPod(err error) error { return &noPodError{err: err} } +// isPermissionDenied reports whether opening a serial port failed for lack of access (Linux: the +// device belongs to the dialout group). +func isPermissionDenied(err error) bool { + var pe *serial.PortError + if errors.As(err, &pe) && pe.Code() == serial.PermissionDenied { + return true + } + return err != nil && strings.Contains(strings.ToLower(err.Error()), "permission denied") +} + +// deniedHint explains a pod console this user may not open, or "" when none was refused. +func deniedHint(denied []string) string { + if len(denied) == 0 { + return "" + } + return fmt.Sprintf("; %s could not be opened (permission denied): on Linux add yourself to the dialout group "+ + "(`sudo usermod -aG dialout $USER`, then log in again) or install the udev rule 70-benchpod.rules. "+ + "On firmware 3.9.0+ the network commands work over USB without the console", strings.Join(denied, ", ")) +} + func (e *noPodError) Error() string { return e.err.Error() } func (e *noPodError) Unwrap() error { return e.err } func (e *noPodError) Is(target error) bool { return target == tcpclient.ErrUnreachable } @@ -267,12 +287,15 @@ func OpenBenchpod(explicit, preferred string, probeTimeout time.Duration) (*Cons if len(cands) == 0 { return nil, "", noPod(errors.New("no USB ports found; plug in the pod or pass --connection ")) } - var tried []string + var tried, denied []string for _, name := range cands { serialLogf("probing %s for a bench-pod console...", name) port, err := serial.Open(name, &serial.Mode{BaudRate: baudRate}) if err != nil { serialLogf(" %s: open failed: %v", name, err) + if isPermissionDenied(err) { + denied = append(denied, name) + } continue } c := newConsole(port) @@ -288,8 +311,8 @@ func OpenBenchpod(explicit, preferred string, probeTimeout time.Duration) (*Cons _ = c.Close() tried = append(tried, name) } - return nil, "", noPod(fmt.Errorf("no bench-pod console found among %d probed USB port(s) [%s]; pass --connection to force one", - len(tried), strings.Join(tried, ", "))) + return nil, "", noPod(fmt.Errorf("no bench-pod console found among %d probed USB port(s) [%s]%s; pass --connection to force one", + len(tried), strings.Join(tried, ", "), deniedHint(denied))) } // serialGlobs are the /dev node patterns scanned (in addition to the enumerator) diff --git a/packaging/linux/70-benchpod.rules b/packaging/linux/70-benchpod.rules index c6e6cf9..0cb5815 100644 --- a/packaging/linux/70-benchpod.rules +++ b/packaging/linux/70-benchpod.rules @@ -1,6 +1,7 @@ -# BenchPod: keep ModemManager off the pod's USB console. +# BenchPod: keep ModemManager off the pod's USB console, and let the signed-in desktop user open it +# (uaccess) without joining the dialout group. # Install: sudo cp 70-benchpod.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules && sudo udevadm trigger # Firmware 3.9.0 and later (pid.codes VID 1209; PID 0001 is the test ID until ours is assigned). -ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0001", ENV{ID_MM_DEVICE_IGNORE}="1" +ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0001", ENV{ID_MM_DEVICE_IGNORE}="1", TAG+="uaccess" # Older firmware (ST's stock CDC ID). -ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ENV{ID_MM_DEVICE_IGNORE}="1" +ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ENV{ID_MM_DEVICE_IGNORE}="1", TAG+="uaccess" From 1f89d3412987ed2a563599323fa5d4860589404a Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 08:50:12 -0500 Subject: [PATCH 29/29] usb: policy show and install-blobs use the local USB link before the console Like the network commands, 'lan-policy'/'sig-policy' (show) and install-blobs now reach a 3.9.0 pod through this computer's end of its USB network link without opening the console, so they work for a Linux user outside the dialout group. Policy changes stay on the console. hw-e2e.sh gets --flash-args for hosts without the cloud wiring profile. Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/blobs.go | 25 ++++++++++++++++--------- cmd/benchpod-cli/blobs_target.go | 18 ++++++++++++++++++ cmd/benchpod-cli/policy.go | 8 ++++++++ scripts/hw-e2e.sh | 9 ++++++--- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/cmd/benchpod-cli/blobs.go b/cmd/benchpod-cli/blobs.go index 506228a..2b97c05 100644 --- a/cmd/benchpod-cli/blobs.go +++ b/cmd/benchpod-cli/blobs.go @@ -222,16 +222,23 @@ func newInstallBlobsCmd(g *globalFlags) *cobra.Command { if err != nil { return err } - console, path, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(blobsInstallTimeout)) - if err != nil { - return err - } + ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(blobsInstallTimeout)) 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 + // This computer's end of the pod's USB network link (firmware 3.9.0+) needs no console; + // else the console names the way in: the USB network when it is up, else the console. + target, closeTarget := g.localBlobTarget(ctx) + path := "the USB network" + if target == nil { + console, cpath, cctx, ccancel, err := g.openSerialConsole(device, g.effectiveTimeout(blobsInstallTimeout)) + if err != nil { + return err + } + defer ccancel() + defer console.Close() + ctx, path = cctx, cpath + if target, closeTarget, err = blobTargetFor(ctx, console, path); err != nil { + return err + } } defer closeTarget() src := blobSource{base: dir} diff --git a/cmd/benchpod-cli/blobs_target.go b/cmd/benchpod-cli/blobs_target.go index c403b84..71db30d 100644 --- a/cmd/benchpod-cli/blobs_target.go +++ b/cmd/benchpod-cli/blobs_target.go @@ -182,3 +182,21 @@ func blobTargetFor(ctx context.Context, console *serialconsole.Console, path str return nil, func() {}, errors.New("this pod's firmware (3.9.0 or later) takes blobs over the network, and neither its USB " + "network adapter (Wi-Fi configured on the pod turns it off: `benchpod clear-wifi`) nor its network address answers") } + +// localBlobTarget is the pod's network API on this computer's end of its USB network link +// (firmware 3.9.0+), found without opening the console; nil when there is no such link. +func (g *globalFlags) localBlobTarget(ctx context.Context) (blobTarget, func()) { + spec, err := g.resolveTarget() + if err != nil || !spec.IsSerial() || spec.Device != "" { + return nil, nil + } + addr := localUSBPod(spec.Pod) + if addr == "" { + return nil, nil + } + lan, err := openLAN(ctx, addr) + if err != nil { + return nil, nil + } + return lanBlobs{lan: lan, addr: addr}, func() { lan.s.Close() } +} diff --git a/cmd/benchpod-cli/policy.go b/cmd/benchpod-cli/policy.go index f6e3d4a..b9e2948 100644 --- a/cmd/benchpod-cli/policy.go +++ b/cmd/benchpod-cli/policy.go @@ -196,6 +196,14 @@ func runPolicy(g *globalFlags, p podPolicy, t policyTarget, set string, out, war return runPolicyCloud(g, p, t, set, out, warn) } if spec.IsSerial() { + // Showing is a read the pod's USB network link answers (firmware 3.9.0+), with no + // console: a Linux user outside the dialout group cannot open it. Changes stay on the + // console (physical presence). + if set == "" && spec.Device == "" { + if addr := localUSBPod(spec.Pod); addr != "" { + return runPolicyLAN(g, p, addr, out) + } + } return runPolicyUSB(g, p, spec.Device, set, out, warn) } if set != "" { diff --git a/scripts/hw-e2e.sh b/scripts/hw-e2e.sh index e8aee9f..4f1167f 100755 --- a/scripts/hw-e2e.sh +++ b/scripts/hw-e2e.sh @@ -9,6 +9,8 @@ # --target CFG OpenOCD target config for --flash (default target/stm32f4x.cfg) # --power N power the target from eFuse N (1 internal 5V, 2 external) for --flash; the # DUT stays powered afterwards +# --flash-args "A" extra flags for --flash, e.g. "--swclk 11 --swdio 12" when the computer is not +# signed in (the wiring profile on embeddedci.com names them otherwise) # --blobs also reinstall the gw1 blob (install-blobs --only gw1 --force) # --cloudcfg also set and clear a test cloud proxy and company CA on the pod # --reattach also run usb-reattach and check the pod answers again (USB connections) @@ -17,15 +19,16 @@ # number of failures (0 = all passed). It changes nothing it does not put back: the proxy and the # CA are cleared again, and gw1 is rewritten with the release's own image. Hold the bench lock. set -u -CONN=${1:?usage: scripts/hw-e2e.sh [--bin PATH] [--flash ELF] [--target CFG] [--power N] [--blobs] [--cloudcfg] [--reattach]} +CONN=${1:?usage: scripts/hw-e2e.sh [--bin PATH] [--flash ELF] [--target CFG] [--power N] [--flash-args "..."] [--blobs] [--cloudcfg] [--reattach]} shift -BIN="" FLASH="" TARGET="target/stm32f4x.cfg" POWER="" BLOBS=0 CLOUDCFG=0 REATTACH=0 +BIN="" FLASH="" TARGET="target/stm32f4x.cfg" POWER="" FLASHARGS="" BLOBS=0 CLOUDCFG=0 REATTACH=0 while [ $# -gt 0 ]; do case "$1" in --bin) BIN=$2; shift ;; --flash) FLASH=$2; shift ;; --target) TARGET=$2; shift ;; --power) POWER=$2; shift ;; + --flash-args) FLASHARGS=$2; shift ;; --blobs) BLOBS=1 ;; --cloudcfg) CLOUDCFG=1 ;; --reattach) REATTACH=1 ;; @@ -88,7 +91,7 @@ else fi if [ -n "$FLASH" ]; then - step "flash (SWD)" 'flash: done|Verified OK|verified' -- flash --file "$FLASH" --target "$TARGET" ${POWER:+--target-power "$POWER"} + step "flash (SWD)" 'flash: done|Verified OK|verified' -- flash --file "$FLASH" --target "$TARGET" ${POWER:+--target-power "$POWER"} $FLASHARGS else skip "flash" "pass --flash ELF" fi