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 e525155..cc9e931 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,27 @@ 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), 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`) The `flash` command shells out to **OpenOCD**, which must be installed @@ -101,8 +122,9 @@ 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. | +| `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`. | @@ -115,14 +137,41 @@ 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. + +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. +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: + +| | 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`, `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 | `status`, `la voltage`, `flash` (SWD), `lan-policy`, `sig-policy`, `cloud ca`, `cloud proxy`. | +| 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`; `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 +290,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) @@ -423,7 +477,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 @@ -634,10 +688,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. | @@ -653,9 +710,15 @@ 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. 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 @@ -898,7 +961,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. @@ -928,7 +992,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 +1010,64 @@ 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 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 +(`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. | +| `--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 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` + +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` + +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` @@ -1081,8 +1202,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.go b/cmd/benchpod-cli/blobs.go index 1e83be7..2b97c05 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.", @@ -221,26 +222,39 @@ 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() + // 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} 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..71db30d --- /dev/null +++ b/cmd/benchpod-cli/blobs_target.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "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) { + 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 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/blobs_target_test.go b/cmd/benchpod-cli/blobs_target_test.go new file mode 100644 index 0000000..f248661 --- /dev/null +++ b/cmd/benchpod-cli/blobs_target_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "bytes" + "context" + "errors" + "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", + } + 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()) + } + } +} + +// 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..fe7852a 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 { @@ -358,7 +363,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, spec.Pod); 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 +685,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, spec.Pod); 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/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) } 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_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/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..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 @@ -210,7 +216,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..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)" } @@ -132,9 +147,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/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" diff --git a/cmd/benchpod-cli/flash.go b/cmd/benchpod-cli/flash.go index b5b3d70..60804ff 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" @@ -144,6 +145,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 +354,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 @@ -397,6 +400,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.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") + } 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/flash_self.go b/cmd/benchpod-cli/flash_self.go index 8609759..e405193 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,30 @@ func checkImageFitsPod(ctx context.Context, dfuPath, file string) error { return nil } +// skipUSBNetForBlobs keeps installBlobsAfterFlash on the console (tests). +var skipUSBNetForBlobs = false + +// 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 +511,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 { @@ -509,14 +529,41 @@ 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 !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) { 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/helpers.go b/cmd/benchpod-cli/helpers.go index 5e59f3f..3207072 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 } @@ -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/lavoltage.go b/cmd/benchpod-cli/lavoltage.go index b351742..9a8dffd 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)) @@ -167,6 +171,14 @@ 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.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) + 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) } @@ -215,6 +227,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" 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/cmd/benchpod-cli/podclient.go b/cmd/benchpod-cli/podclient.go index 4e0761d..0eec714 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) @@ -166,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/podclient_test.go b/cmd/benchpod-cli/podclient_test.go index 639209c..afa2789 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,11 @@ 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)") + } + localUSBPod = func(string) string { return "" } code := m.Run() os.RemoveAll(dir) os.Exit(code) 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/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index 93771a9..4b9d789 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -18,6 +18,9 @@ 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) + 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 @@ -48,17 +51,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" + @@ -77,7 +83,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), 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)") @@ -97,6 +103,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/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 dd86931..b5b8b13 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) @@ -96,19 +100,20 @@ 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"` Holder string `json:"holder"` @@ -116,6 +121,75 @@ 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, "; ") +} + +// 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 { @@ -136,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 } @@ -153,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 { @@ -208,7 +291,15 @@ 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)) + } 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/usbbridge.go b/cmd/benchpod-cli/usbbridge.go new file mode 100644 index 0000000..43602d3 --- /dev/null +++ b/cmd/benchpod-cli/usbbridge.go @@ -0,0 +1,253 @@ +package main + +import ( + "bufio" + "context" + "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, watch 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" + + "--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 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).", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + 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 +} + +// 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 +} + +// 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) + } + // 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. +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_service.go b/cmd/benchpod-cli/usbbridge_service.go new file mode 100644 index 0000000..b1211f8 --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_service.go @@ -0,0 +1,375 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "html" + "net" + "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 over its USB network link when it points at this +// computer's end of that link (never the console). +func clearBridgeProxy(g *globalFlags) error { + 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() + link, err := pickUSBLink(ctx, links, g.usbPodName()) + if err != nil { + return err + } + raw, err := bridgePodAPI(ctx, net.JoinHostPort(link.pod, "8080"), map[string]any{"cmd": "cloud_proxy"}) + if err != nil { + return err + } + var cur struct { + Host string `json:"host"` + } + _ = json.Unmarshal(raw, &cur) + if cur.Host != link.host { + return nil // not ours + } + if err := bridgeSetProxy(link.pod, ""); 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") + } +} diff --git a/cmd/benchpod-cli/usbbridge_test.go b/cmd/benchpod-cli/usbbridge_test.go new file mode 100644 index 0000000..ac6b769 --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "bufio" + "io" + "net" + "strconv" + "strings" + "testing" + "time" +) + +// 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) + } +} diff --git a/cmd/benchpod-cli/usbbridge_watch.go b/cmd/benchpod-cli/usbbridge_watch.go new file mode 100644 index 0000000..3b3620e --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_watch.go @@ -0,0 +1,250 @@ +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 (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 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 + +// 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) +} + +// 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(pod, proxy string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + 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 +} + +// 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, watch bool) error { + ctx, stop := context.WithCancel(context.Background()) + defer stop() + defer installSignalHandler(ctx, stop)() + return watchBridge(ctx, g, port, keep, usbWatchPoll, !watch) +} + +// 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, 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 + case <-time.After(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, 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 + } + 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 { + if err := bridgeSetProxy(link.pod, proxyAddr); err != nil { + ln.Close() + note(fmt.Sprintf("set the pod's cloud proxy: %v", err)) + if !wait() { + break + } + continue + } + setByUs, setOn = proxyAddr, link.pod + } + lastWhy = "" + 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(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) + } + 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..caeac51 --- /dev/null +++ b/cmd/benchpod-cli/usbbridge_watch_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "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(_ string, 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, false); 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) + } +} + +// 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 new file mode 100644 index 0000000..a6fab50 --- /dev/null +++ b/cmd/benchpod-cli/usbnet.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "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 +// 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() +} + +// 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) { + 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`)") + } + return "", fmt.Errorf("the pod's console did not report its USB network: %w", err) + } + addr := net.JoinHostPort(u.PodIP, "8080") + 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 + } + // 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 + } + lan := net.JoinHostPort(u.LANIP, "8080") + 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) + } + fmt.Fprintf(usbNote, "usb: using the pod's network address %s: %v. Its external LAN policy applies there.\n", lan, why) + return lan, nil +} + +// preferUSBNetwork turns a USB spec into the pod's address on its USB network adapter when that +// is up (on 3.9.0+ with USB networking off, its Ethernet or Wi-Fi address when that answers), for a +// command that works over both; any other spec, or a USB pod without a way in over the network +// (older firmware, nothing answers), 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 +} + +// 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, 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 "" + } + 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. +func (g *globalFlags) networkTarget(cmd string) (ConnSpec, error) { + spec, err := g.resolveTarget() + if err != nil || !spec.IsSerial() { + return spec, err + } + 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) + g.usbErr = uerr + } + if errors.Is(uerr, tcpclient.ErrUnreachable) { + return spec, uerr + } + 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 new file mode 100644 index 0000000..6e29080 --- /dev/null +++ b/cmd/benchpod-cli/usbnet_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "errors" + "io" + "net" + "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, 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, pingLANPod, usbNote = oldNet, oldDial, oldPing, 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"} { + 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) + } + } +} + +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") + } +} + +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) + } +} + +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) + } +} + +// 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) + } +} + +// 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/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/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) + } +} 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/select.go b/internal/serialconsole/select.go new file mode 100644 index 0000000..9877da2 --- /dev/null +++ b/internal/serialconsole/select.go @@ -0,0 +1,141 @@ +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 + var denied []string + 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) + if isPermissionDenied(err) { + denied = append(denied, port) + } + 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(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 + } + 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 aa262ef..8da34cc 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 { @@ -233,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 } @@ -264,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) @@ -285,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) @@ -315,6 +341,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 { @@ -348,7 +381,7 @@ func candidatePorts() ([]string, error) { } return names[i] < names[j] }) - return names, nil + return names, vid, nil } func portPrio(vid string) int { @@ -547,6 +580,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/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..407cac4 --- /dev/null +++ b/internal/serialconsole/usbnet.go @@ -0,0 +1,96 @@ +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 + LANIP string // the status `ip` line: the pod's Ethernet (else Wi-Fi) address, "" when it has none +} + +// 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)") + +// 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, 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] { + 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..ef6bd50 --- /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", 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" + 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) + } +} diff --git a/packaging/linux/70-benchpod.rules b/packaging/linux/70-benchpod.rules new file mode 100644 index 0000000..0cb5815 --- /dev/null +++ b/packaging/linux/70-benchpod.rules @@ -0,0 +1,7 @@ +# 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", TAG+="uaccess" +# Older firmware (ST's stock CDC ID). +ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ENV{ID_MM_DEVICE_IGNORE}="1", TAG+="uaccess" diff --git a/scripts/hw-e2e.sh b/scripts/hw-e2e.sh new file mode 100755 index 0000000..4f1167f --- /dev/null +++ b/scripts/hw-e2e.sh @@ -0,0 +1,108 @@ +#!/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 +# --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) +# +# 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] [--flash-args "..."] [--blobs] [--cloudcfg] [--reattach]} +shift +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 ;; + *) 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"} $FLASHARGS +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