diff --git a/README.md b/README.md index d9d1a46..b11b556 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # benchpod-cli -`benchpod` is the EmbeddedCI **bench pod** command-line tool. It talks to a -bench pod either over its TCP/JSON API (port 8080) or over its USB console -(CDC-ACM), and drives the pod's hardware: signal generation, ADC capture, +`benchpod` is the EmbeddedCI **BenchPod** command-line tool. It talks to a +pod over its network TCP/JSON API (port 8080), over its USB console (CDC-ACM), or +through embeddedci.com (`--connection embeddedci:`), and drives the pod's hardware: signal generation, ADC capture, logic-analyzer pins, Wi-Fi configuration, firmware flashing over SWD, SPI NOR flash programming, and the pod's own firmware, policies and cloud link. Every command and flag is listed in the [command reference](#command-reference). @@ -71,7 +71,7 @@ below. ### dfu-util (for `flash-self`) -`flash-self` reflashes a **bench pod's own firmware** over USB DFU (STM32) and +`flash-self` reflashes a **pod's own firmware** over USB DFU (STM32) and shells out to **dfu-util**. The Homebrew cask declares it as a dependency, so `brew install --cask embeddedci-com/tap/benchpod` pulls it in automatically; otherwise `brew install dfu-util` (or your distro's package). See @@ -100,10 +100,10 @@ reach the pod; the transport is inferred from its value: | `--connection` value | Transport | |----------------------------------|--------------------------------------------------------| -| `192.168.1.5[:8080]` | TCP/JSON API (an address ⇒ Wi-Fi). | +| `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. | -| `embeddedci:` | The pod named `` on embeddedci.com (see below). | +| `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`. | The global flags are also settable via a `BENCHPOD_*` environment variable @@ -113,20 +113,95 @@ are flags only. 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 do not send tokens. +cloud features. Direct firmware commands on the network or USB do not send tokens. + +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`. | +| `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. | + +A command that cannot use the connection it is given says so and names what to use +instead. + +### Through embeddedci.com + +`embeddedci:` is the form the Python SDK and the MCP server take. The CLI looks +the name up in your account's pod list and sends each command to +`POST /api/benchpod/devices/{id}/command`, which relays it over the pod's own cloud +link and returns the pod's reply: the same single-reply JSON commands the network +API takes, so the output is the same. It needs: + +- A sign-in: `BENCHPOD_API_KEY` (an `eci_...` key, sent as `Authorization: ApiKey`) + when set, else the `benchpod login` session. +- The pod online on embeddedci.com, and no other session holding its lease (the web + app, a CI job or the SDK): a held lease exits with code 4 (busy), an offline pod + with code 5 (unreachable). +- For a change to persisted settings (`cloud proxy set`, `cloud ca clear`, the policy + `set`s): an organization owner or admin (API keys: the `benchpod:admin` scope). + +`BENCHPOD_API_BASE` points the CLI at another embeddedci-server (default +`https://www.embeddedci.com`), as it does for the SDK. + +Commands that stream data or need a direct link to the pod do not go through +embeddedci.com: `capture`, `stream`, `measure`, `test`, `flash`, `spi-flash`, +`cloud ca set`, `register`, the USB-only commands and `discover`. Over a cloud +connection they refuse with a message pointing to the web app, the Python SDK or +pytest plugin (which take `embeddedci:` for those too), or a network/USB +connection. The CLI has no UART command; use the web app's Terminal or the SDK. + +### One wiring profile + +The pod's wiring profile on embeddedci.com (the web app's Wiring tab; the SDK and +MCP read the same one) says which LA channel carries which signal. `flash` and +`spi-flash` take the pins you leave out from it: + +| Command | Flag | Profile field | +|-------------|--------------|---------------| +| `flash` | `--swclk` | `swd_swclk` | +| `flash` | `--swdio` | `swd_swdio` | +| `flash` | `--nreset` | `swd_nreset` | +| `flash` | `--target` | `swd_target` | +| `spi-flash` | `--sck`, `--mosi`, `--miso`, `--cs` | `spi_sclk`, `spi_mosi`, `spi_miso`, `spi_cs` | + +That happens when the connection is the pod's network address, this machine is +signed in (`benchpod login` or `BENCHPOD_API_KEY`) and the pod is on that account +(its `cloud_status` device id is in the account's pod list, as `status` and +`discover` check). Flags always win. The log says which values came from the +profile (`[benchpod] flash: SWCLK LA11, SWDIO LA12 from the pod's wiring profile on +embeddedci.com; ...`) or that the flags were used. Without a profile (over USB, not +signed in, another account) the command behaves as before: the pin flags are +required. If the lookup fails (it gives up after a few seconds), the CLI prints one +warning line and goes on with the flags. + +### Shared config + +`config.json` in `~/.config/benchpod-cli/` (or `$XDG_CONFIG_HOME/benchpod-cli/`) holds +the saved connection. It is shared: the Python SDK and the MCP server read its +`connection` key as their default pod when they are given none, so `benchpod +set-connection` (or `discover --save`, `setup`) picks the pod for all three. Only the +CLI writes it. + +```json +{ + "version": 1, + "connection": "embeddedci:benchpod-a1b2c3", + "last_serial": "/dev/cu.usbmodem1101" +} +``` -Over the USB console, `flash` (SWD), `status` and `la voltage` work; the other -TCP/JSON commands reject a USB connection with a clear message. The -`set-wifi` / `show-network` / `clear-wifi` and `bootsel` subcommands always use -the USB console regardless of `--connection` (a device path still selects -the port). `lan-policy`, `sig-policy`, `cloud ca` and `cloud proxy` work over -USB as well; `identity`, `identity wipe`, `install-blobs` and `dfu` work only over -USB. +| Key | Meaning | +|---------------|---------| +| `version` | Format version, `1`. Written on every save; a file without it is version 1. | +| `connection` | The default `--connection` value: an address, a device path, `usb` or `embeddedci:`. | +| `last_serial` | The USB device most recently auto-detected as a pod, probed first next time (CLI only). | +| `bench_pod_addr` | Legacy: an address saved by old CLI versions, read as `connection` when that is empty. | -`embeddedci:` (the form the Python SDK and the MCP server take) works where a -command can go through embeddedci.com: `lan-policy`, `sig-policy` and `deregister`, -where it is the same as `--device-name `. The other commands talk to the pod -itself and say so when given a cloud target. +Readers ignore keys they do not know. `token.json` next to it holds the +`benchpod login` session, which the MCP server also uses. ## CLI usage @@ -304,8 +379,11 @@ belongs to the old record. On a network whose proxy inspects TLS, the pod's cloud link fails: the proxy re-signs embeddedci.com with a company root the pod does not trust. Some networks also only reach the internet through an HTTP proxy. `benchpod cloud` sets both -over the USB console (`--connection usb`). The LAN (`--connection
`) can -show them; current firmware refuses changes from the LAN: +over the USB console (`--connection usb`) or through embeddedci.com +(`--connection embeddedci:`: `cloud proxy` show, set and clear, `cloud ca` show +and clear; the CA upload needs USB, and a change needs an organization owner or +admin). The LAN (`--connection
`) can show them; current firmware refuses +changes from the LAN: ``` $ benchpod cloud ca set acme-root.pem --connection usb @@ -373,7 +451,9 @@ speaks it. `spi-flash` reads, erases and programs a 25-series SPI NOR flash (W25Q, MX25, GD25, IS25, ...) wired to four LA pins, through the pod's SPI master. It needs gateware v45 or newer (`spi_master` in the `status` caps; the CLI checks first) -and the LA voltage set. Network (TCP) connection only for now. +and the LA voltage set. Network (TCP) connection only for now. Pins you leave out +come from the pod's wiring profile on embeddedci.com when you are signed in (see +[One wiring profile](#one-wiring-profile)). ```bash PINS="--sck 3 --mosi 4 --miso 5 --cs 6" @@ -448,11 +528,12 @@ the CLI version. These apply to every command. The CLI keeps its files in `~/.config/benchpod-cli/` (or `$XDG_CONFIG_HOME/benchpod-cli/`): `config.json` -for the saved connection and `token.json` for the embeddedci.com session. +for the saved connection (shared with the SDK and MCP, see [Shared config](#shared-config)) +and `token.json` for the embeddedci.com session. | Flag | Default | Purpose | |---------------------|----------------------------------|-------------------------------------------------------------------| -| `--connection` | (saved `set-connection` target) | Address, device path, `usb`, or `embeddedci:` where a command supports it; see [Connection](#connection). | +| `--connection` | (saved `set-connection` target) | Network address, device path, `usb`, or `embeddedci:` (through embeddedci.com); see [Connection](#connection). | | `--config-file` | `~/.config/benchpod-cli/config.json` | The CLI's config file, where `set-connection` saves the default. | | `--output-filename` | (stdout) | Write command output to this file instead of stdout. | | `--timeout` | `0` (per-command default) | Overall command deadline; `0` uses each command's own default. | @@ -469,7 +550,7 @@ stderr with a `[benchpod]` prefix. | `2` | Usage: a bad flag, argument or subcommand. | | `3` | Refused: the pod refused the command (a locked LAN, a missing role, a bad request). | | `4` | Busy: the pod or one of its engines is in use (a cloud job's lease, another session). | -| `5` | Unreachable: nothing answered on the pod's address, or no pod was found on USB. | +| `5` | Unreachable: nothing answered on the pod's address, no pod was found on USB, or the pod is offline on embeddedci.com. | A refusal is printed word for word as the pod sent it. For a `locked:` (LAN policy), `busy:` (cloud lease) or `forbidden:` (missing role) refusal the CLI adds what to do next. @@ -533,10 +614,12 @@ Default connection set to network/TCP 192.168.1.221:8080 (saved to ~/.config/ben | `--no-register` | off | Do not register the pod with embeddedci.com (it works over the LAN without an account). | | `--yes` | off | Never prompt: register without asking, and fail with a message naming the flag to pass when an answer is needed. | -### `benchpod set-connection ` +### `benchpod set-connection ` -Save the default connection (an address, a device path or `usb`) in the config -file, so later commands can leave out `--connection`. No flags. +Save the default connection (a network address, a device path, `usb` or +`embeddedci:`) in the config file, so later commands can leave out +`--connection`. The Python SDK and the MCP server read the same value (see +[Shared config](#shared-config)). No flags. ### `benchpod discover` @@ -556,11 +639,12 @@ account; a pod set up for you is on the account your activation email was sent t ### `benchpod ping` -Connectivity check over the network. No flags. +Connectivity check over the network or through embeddedci.com. No flags. ### `benchpod status` -A short summary of the pod over the network: firmware version (and whether a newer +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 network address and link, the cloud registration (and, when this machine is @@ -580,11 +664,11 @@ BenchPod at 192.168.1.220:8080 | Flag | Default | Purpose | |----------|---------|---------------------------------------------------------------| -| `--json` | off | Print the pod's raw `status` JSON instead of the summary (network only). | +| `--json` | off | Print the pod's raw `status` JSON instead of the summary (network and embeddedci.com). | ### `benchpod generate` -Start DAC waveform output (network). +Start DAC waveform output (network or embeddedci.com). | Flag | Default | Purpose | |---------------------|---------|-----------------------------------------------------------| @@ -648,7 +732,8 @@ A diagnostic sample pattern made by the pod's MCU, without the FPGA (network). ### `benchpod la` Logic-analyzer pin control: the bank voltage, pull-ups and step pulses. Pins are -`1`-`14` or `la1`-`la14`. Everything but `la voltage` needs the network. +`1`-`14` or `la1`-`la14`. All of them work over the network and through +embeddedci.com; `la voltage` also works over USB. ### `benchpod la voltage [VOLTAGE]` @@ -656,10 +741,10 @@ Show or set the LA bank's I/O voltage, which must match the DUT's: `1.8V` or `3. (also `1800mV`, `3.3` or `3300`). Leave it out to show the setting. The pod refuses LA capture, UART, SWD flash, pull-ups and I2C-sensor emulation until it is set; 1.8 V needs a v3 pod, and the pod refuses a change while an LA pin is in use. Works -over the network and over USB. No flags. +over the network, over USB and through embeddedci.com. No flags. The pod forgets the voltage on a restart. When this machine is signed in -(`benchpod login`) and the pod is registered to that account, setting it over the +(`benchpod login` or `BENCHPOD_API_KEY`) and the pod is registered to that account, setting it over the network or an `embeddedci:` connection also saves it to the pod's wiring profile on embeddedci.com (only `la_mv` changes), which the server applies on every connect. A failed save is a warning; the pod setting still stands. Over USB it is set on the @@ -689,16 +774,19 @@ Pulse an LA pin N times, for step/dir stepper drivers. The FPGA times the train. ### `benchpod flash` Flash an SWD target wired to the pod, through host-side OpenOCD and the pod's -CMSIS-DAP backend (network or USB). See [Flashing: CMSIS-DAP](#flashing-cmsis-dap). +CMSIS-DAP backend (network or USB; not through embeddedci.com). See +[Flashing: CMSIS-DAP](#flashing-cmsis-dap). Over the network, `--swclk`, `--swdio`, +`--nreset` and `--target` default to the pod's wiring profile (see +[One wiring profile](#one-wiring-profile)). | Flag | Default | Purpose | |----------------------------|---------|-------------------------------------------------------------------------| -| `--swclk` | (required) | LA pin for SWCLK, 1-14 (`1` or `la1`). | -| `--swdio` | (required) | LA pin for SWDIO, 1-14. | -| `--target` | (none) | OpenOCD target config, passed as `-f`, e.g. `target/stm32f1x.cfg`. | +| `--swclk` | (profile `swd_swclk`, else required) | LA pin for SWCLK, 1-14 (`1` or `la1`). | +| `--swdio` | (profile `swd_swdio`, else required) | LA pin for SWDIO, 1-14. | +| `--target` | (profile `swd_target`, else none) | OpenOCD target config, passed as `-f`, e.g. `target/stm32f1x.cfg`. | | `--file` | (none) | Firmware image to flash, used with `--target`. | | `--load-address` | (none) | Load address for a raw `.bin` image. | -| `--nreset` | off | The target's NRST is wired to the pod's reset pin (DUT header J1 pin 22); turns on connect-under-reset. | +| `--nreset` | (profile `swd_nreset`, else off) | The target's NRST is wired to the pod's reset pin (DUT header J1 pin 22); turns on connect-under-reset. `--nreset=false` overrides the profile. | | `--no-connect-under-reset` | off | Do not hold the target in reset while connecting. | | `--no-verify` | off | Do not verify after programming. | | `--no-reset` | off | Do not reset the target after programming. | @@ -712,15 +800,16 @@ Pass `--target` (with `--file`) or at least one `-c`/`--openocd-arg`. ### `benchpod spi-flash` -Read, erase and program a 25-series SPI NOR flash on four LA pins (network). See -[SPI flash](#spi-flash). These flags apply to every subcommand. +Read, erase and program a 25-series SPI NOR flash on four LA pins (network only). See +[SPI flash](#spi-flash). These flags apply to every subcommand; the pins you leave out +come from the pod's wiring profile (see [One wiring profile](#one-wiring-profile)). | Flag | Default | Purpose | |------------|-----------|----------------------------------------------------------------------| -| `--sck` | (required) | LA pin for SCK, 1-14. | -| `--mosi` | (required) | LA pin for MOSI, 1-14. | -| `--miso` | (required) | LA pin for MISO, 1-14. | -| `--cs` | (required) | LA pin for CS, 1-14. | +| `--sck` | (profile `spi_sclk`, else required) | LA pin for SCK, 1-14. | +| `--mosi` | (profile `spi_mosi`, else required) | LA pin for MOSI, 1-14. | +| `--miso` | (profile `spi_miso`, else required) | LA pin for MISO, 1-14. | +| `--cs` | (profile `spi_cs`, else required) | LA pin for CS, 1-14. | | `--hz` | `1000000` | SCK rate in Hz; the pod uses the nearest rate at or below it (190 kHz to 6 MHz). | | `--mode` | `0` | SPI mode, `0` or `3`. | | `--nreset` | off | Hold the DUT in reset through the pod's reset pin for the whole run. | @@ -862,7 +951,7 @@ pod's last connection error, when the pod does not connect within `--wait`. | Flag | Default | Purpose | |--------------------------|------------------------------|------------------------------------------------------------| -| `--device-name` | (the pod's own name) | Device name, URL-safe and unique in the organization, e.g. `bench-01`. | +| `--device-name` | (the pod's own name) | The pod's name on embeddedci.com, URL-safe and unique in the organization, e.g. `bench-01`. | | `--wait` | `30s` | How long to wait for the pod to connect; `0` does not wait. | | `--pod-host` | `api.embeddedci.com` | Host the pod connects to (`api.embeddedci.com` for embeddedci.com, else the `--server-url` host). | | `--insecure-skip-verify` | off | Provision the pod to skip TLS certificate checks (bring-up only). | @@ -873,12 +962,14 @@ pod's last connection error, when the pod does not connect within `--wait`. Detach the pod from your account (its data is kept) and clear its cloud configuration. By default the pod reached with `--connection` is identified by its -key; `--connection embeddedci:` is the same as `--device-name `. +key; `--connection embeddedci:` is the same as `--device-name `. It signs +in with `BENCHPOD_API_KEY` when that is set and `--token-file` is not given, else with +the `benchpod login` session; the policy commands do the same. | Flag | Default | Purpose | |---------------------|------------------------------|---------------------------------------------------------------| -| `--device-name` | (none) | Deregister the device with this name instead (the pod may be offline). | -| `--device-id` | (none) | Deregister the device with this id instead. | +| `--device-name` | (none) | Deregister the pod with this name instead (it may be offline). | +| `--device-id` | (none) | Deregister the pod with this id instead. | | `--keep-pod-config` | off | Leave the pod's cloud configuration in place. | | `--server-url` | `https://www.embeddedci.com` | embeddedci-server base URL. | | `--token-file` | `~/.config/benchpod-cli/token.json` | Token cache. | @@ -948,8 +1039,9 @@ registered again. ### `benchpod cloud` How the pod reaches embeddedci.com: a company CA and an HTTP proxy. See -[Cloud link](#cloud-link-company-ca-and-http-proxy). Changes need USB (or the -cloud link); the LAN can show them. +[Cloud link](#cloud-link-company-ca-and-http-proxy). Changes need USB or +`--connection embeddedci:` (an organization owner or admin; `ca set` needs +USB); the LAN can show them. ### `benchpod cloud ca` @@ -961,7 +1053,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. No flags. +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. ### `benchpod cloud ca clear` diff --git a/cmd/benchpod-cli/blobs.go b/cmd/benchpod-cli/blobs.go index a0d518d..1e83be7 100644 --- a/cmd/benchpod-cli/blobs.go +++ b/cmd/benchpod-cli/blobs.go @@ -217,7 +217,11 @@ func newInstallBlobsCmd(g *globalFlags) *cobra.Command { "firmware that checks it too; for now a missing or failing one never stops an install.", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - console, path, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(blobsInstallTimeout)) + device, err := g.usbDevice("install-blobs") + if err != nil { + return err + } + console, path, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(blobsInstallTimeout)) if err != nil { return err } diff --git a/cmd/benchpod-cli/cloudcfg.go b/cmd/benchpod-cli/cloudcfg.go index 4e3458a..b5cd7da 100644 --- a/cmd/benchpod-cli/cloudcfg.go +++ b/cmd/benchpod-cli/cloudcfg.go @@ -63,7 +63,9 @@ func newCloudCmd(g *globalFlags) *cobra.Command { Long: "Configure the pod's link to embeddedci.com on networks that need it.\n\n" + " ca a company root certificate, for networks whose proxy inspects TLS\n" + " proxy an HTTP proxy the pod tunnels its cloud connection through\n\n" + - "Both work over the pod's USB console (--connection usb). The LAN\n" + + "Both work over the pod's USB console (--connection usb) and through\n" + + "embeddedci.com (--connection embeddedci:; a change there needs an\n" + + "organization owner or admin, and `cloud ca set` needs USB). The LAN\n" + "(--connection
) can show them; current firmware refuses changes from\n" + "the LAN, so change them over USB. The pod reconnects to the cloud after a change.", Args: cobra.NoArgs, @@ -198,22 +200,44 @@ const plainLANPasswordWarning = "Warning: the pod's LAN API is plain TCP, so the // warnPlainLANPassword warns when the proxy password would go to the pod over the LAN. It stays // quiet when the target is USB or cannot be resolved (the command then fails with the reason). func warnPlainLANPassword(g *globalFlags, warn io.Writer) { - if spec, err := cloudCfgTarget(g, "cloud proxy set"); err == nil && spec.IsWifi() { + if spec, err := cloudCfgTarget(g, "cloud proxy set"); err == nil && spec.IsNetwork() { fmt.Fprintln(warn, plainLANPasswordWarning) } } // cloudCfgTarget resolves --connection for the cloud ca/proxy commands: a serial device ("" = -// auto-detect) or a LAN address. +// auto-detect), a LAN address or a pod on embeddedci.com. func cloudCfgTarget(g *globalFlags, what string) (spec ConnSpec, err error) { raw, err := g.rawConnection() if err != nil { return ConnSpec{}, err } if raw == "" { - return ConnSpec{}, fmt.Errorf("%s: no pod selected; pass --connection usb or --connection
", what) + return ConnSpec{}, fmt.Errorf("%s: no pod selected; pass --connection usb, --connection
or --connection embeddedci:", what) } - return classifyConnection(raw) + return parseTarget(raw) +} + +// cloudCfgPod runs one cloud_ca / cloud_proxy JSON command through embeddedci.com and decodes the +// reply into out (nil to skip). +func cloudCfgPod(g *globalFlags, spec ConnSpec, def time.Duration, req map[string]any, out any) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(def)) + defer cancel() + defer installSignalHandler(ctx, cancel)() + pod, err := openPodTarget(ctx, spec) + if err != nil { + return "", err + } + data, err := pod.client.Command(ctx, req) + if err != nil { + return pod.label, err + } + if out != nil { + if err := json.Unmarshal(data, out); err != nil { + return pod.label, fmt.Errorf("unexpected reply to %v: %s", req["cmd"], strings.TrimSpace(string(data))) + } + } + return pod.label, nil } // ── CA certificate checks ─────────────────────────────────────────────────── @@ -296,6 +320,12 @@ func printCACerts(out io.Writer, label string, certs []serialconsole.CACert, cha func runCloudCA(g *globalFlags, action, file string, out, warn io.Writer) error { what := "cloud ca " + action + if action == "set" { + // Say so before reading the file when the connection cannot carry the upload. + if spec, err := cloudCfgTarget(g, what); err == nil && spec.IsCloud() { + return cloudCASetRefusal(what) + } + } var local []caCert var upload []byte if action == "set" { @@ -322,9 +352,14 @@ func runCloudCA(g *globalFlags, action, file string, out, warn io.Writer) error } var label string var certs []serialconsole.CACert - if spec.IsSerial() { + switch { + case spec.IsCloud() && action == "set": + return cloudCASetRefusal(what) + case spec.IsCloud(): + label, certs, err = cloudCACloud(g, spec, action) + case spec.IsSerial(): label, certs, err = cloudCAUSB(g, spec.Device, action, upload, warn) - } else { + default: label = spec.Addr certs, err = cloudCALAN(g, spec.Addr, action, upload, warn) } @@ -428,6 +463,45 @@ func cloudCAUSB(g *globalFlags, device, action string, upload []byte, warn io.Wr return label, certs, err } +// ── embeddedci.com ────────────────────────────────────────────────────────── + +// cloudCASetRefusal: installing a CA is an upload, which the command channel does not carry. +func cloudCASetRefusal(what string) error { + return fmt.Errorf("%s does not work over embeddedci.com from the CLI: the certificate upload needs the pod's USB console. "+ + "Run it with --connection usb", what) +} + +// cloudCACloud shows or clears the company CA through embeddedci.com. The pod reconnects after a +// clear, so the reply to the clear is what it reports; a read-back could race the reconnect. +func cloudCACloud(g *globalFlags, spec ConnSpec, action string) (string, []serialconsole.CACert, error) { + req := map[string]any{"cmd": "cloud_ca"} + if action == "clear" { + req["clear"] = true + } + var rep caReply + label, err := cloudCfgPod(g, spec, 30*time.Second, req, &rep) + return label, rep.Certs, err +} + +// cloudProxyCloud shows, sets or clears the HTTP proxy through embeddedci.com (HTTPS, so a proxy +// password does not cross the network in the clear). Like the CA, a change makes the pod +// reconnect, so the change's own reply is what it reports. +func cloudProxyCloud(g *globalFlags, spec ConnSpec, action, set, user, password string) (string, serialconsole.ProxyConfig, error) { + req := map[string]any{"cmd": "cloud_proxy"} + switch action { + case "set": + req["set"] = set + if user != "" { + req["user"], req["password"] = user, password + } + case "clear": + req["clear"] = true + } + var rep proxyReply + label, err := cloudCfgPod(g, spec, 30*time.Second, req, &rep) + return label, rep.config(), err +} + // ── LAN ───────────────────────────────────────────────────────────────────── // lanSession runs JSON commands on one connection; error replies come back as *tcpclient.PodError. @@ -591,9 +665,12 @@ func runCloudProxy(g *globalFlags, action, addr, user, password string, out, war } var label string var p serialconsole.ProxyConfig - if spec.IsSerial() { + switch { + case spec.IsCloud(): + label, p, err = cloudProxyCloud(g, spec, action, addr, user, password) + case spec.IsSerial(): label, p, err = cloudProxyUSB(g, spec.Device, action, addr, user, password) - } else { + default: label = spec.Addr p, err = cloudProxyLAN(g, spec.Addr, action, addr, user, password) } diff --git a/cmd/benchpod-cli/commands_cloud_test.go b/cmd/benchpod-cli/commands_cloud_test.go index ed6cabf..52188dd 100644 --- a/cmd/benchpod-cli/commands_cloud_test.go +++ b/cmd/benchpod-cli/commands_cloud_test.go @@ -273,7 +273,7 @@ func TestCloudErrorHint(t *testing.T) { {cloudStatus{State: "backoff", LastError: s("tcp connect refused")}, "outbound HTTPS"}, {cloudStatus{State: "backoff", LastError: s("tls certificate not trusted (flags 0x8)")}, "certificate"}, {cloudStatus{State: "backoff", LastError: s("tls handshake failed")}, "intercepts HTTPS"}, - {cloudStatus{State: "backoff", LastError: s("server refused the device (HTTP 404)")}, "does not know this device"}, + {cloudStatus{State: "backoff", LastError: s("server refused the device (HTTP 404)")}, "does not know this pod"}, {cloudStatus{State: "backoff", LastError: s("server refused the device (HTTP 403)")}, "deregistered"}, {cloudStatus{State: "backoff", LastError: s("websocket upgrade failed (HTTP 403)")}, "rejected"}, {cloudStatus{State: "backoff", LastError: s("connection lost")}, "unreliable"}, diff --git a/cmd/benchpod-cli/commands_deregister.go b/cmd/benchpod-cli/commands_deregister.go index 70ceba4..3fb2d93 100644 --- a/cmd/benchpod-cli/commands_deregister.go +++ b/cmd/benchpod-cli/commands_deregister.go @@ -21,14 +21,14 @@ func newDeregisterCmd(g *globalFlags) *cobra.Command { var keepPodConfig bool cmd := &cobra.Command{ Use: "deregister", - Short: "Deregister the bench pod from the server (keeps its data) and stop it connecting", - Long: "Deregister the bench pod from the logged-in user's account.\n\n" + + Short: "Deregister the pod from the server (keeps its data) and stop it connecting", + Long: "Deregister the pod from the logged-in user's account.\n\n" + "The server keeps everything recorded for the pod — captures, waveforms, wiring —\n" + - "and only marks the device disabled, so it disappears from the web UI and its\n" + + "and only marks the pod disabled, so it disappears from the web UI and its\n" + "identity is freed for another account. Registering the SAME pod again for the\n" + - "same account revives the device with its data; registering it for a different\n" + - "account gives that account a fresh device and leaves this one's history alone.\n\n" + - "By default the pod is identified by asking the attached bench pod for its public\n" + + "same account revives the pod with its data; registering it for a different\n" + + "account gives that account a fresh pod record and leaves this one's history alone.\n\n" + + "By default the pod is identified by asking the pod for its public\n" + "key (so --connection must point at it) and is then told to stop connecting to the\n" + "cloud. Use --device-name/--device-id (or --connection embeddedci:) to\n" + "deregister a pod you cannot reach.", @@ -39,8 +39,8 @@ func newDeregisterCmd(g *globalFlags) *cobra.Command { } cmd.Flags().StringVar(&serverURL, "server-url", "https://www.embeddedci.com", "embeddedci-server base URL") cmd.Flags().StringVar(&tokenFile, "token-file", "", "path to token cache (default: ~/.config/benchpod-cli/token.json)") - cmd.Flags().StringVar(&deviceName, "device-name", "", "deregister the device with this name instead of the attached bench pod") - cmd.Flags().StringVar(&deviceID, "device-id", "", "deregister the device with this id instead of the attached bench pod") + cmd.Flags().StringVar(&deviceName, "device-name", "", "deregister the pod with this name instead of the one --connection reaches") + cmd.Flags().StringVar(&deviceID, "device-id", "", "deregister the pod with this id instead of the one --connection reaches") cmd.Flags().BoolVar(&keepPodConfig, "keep-pod-config", false, "leave the pod's cloud configuration in place (it will keep trying to connect and be refused)") return cmd @@ -78,7 +78,7 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st // --connection embeddedci: is --device-name . deviceName, usePod = spec.Name, false } else { - if err := spec.RequireWifi("deregister"); err != nil { + if err := spec.RequireNetwork("deregister"); err != nil { return err } client = &tcpclient.Client{Addr: spec.Addr} @@ -95,11 +95,10 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st defer installSignalHandler(ctx, cancel)() api := serverapi.New(serverURL) - tokens, err := ensureTokens(ctx, api, tokenPath) + cred, err := credentialFor(ctx, api, tokenFile, tokenPath) if err != nil { return fmt.Errorf("auth: %w", err) } - log.Printf("auth: signed in as %s", tokens.Who()) target := deviceID label := deviceID @@ -110,11 +109,11 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st pubKey, err = client.IdentityPublic(idCtx) idCancel() if err != nil { - return fmt.Errorf("fetch device public key: %w", err) + return fmt.Errorf("fetch the pod's public key: %w", err) } } listCtx, listCancel := context.WithTimeout(ctx, 30*time.Second) - devices, lErr := api.ListDevices(listCtx, tokens.AccessToken) + devices, lErr := api.ListDevices(listCtx, cred) listCancel() if lErr != nil { return fmt.Errorf("list devices: %w", lErr) @@ -127,15 +126,15 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st } degCtx, degCancel := context.WithTimeout(ctx, 30*time.Second) - device, err := api.DeregisterDevice(degCtx, tokens.AccessToken, target) + device, err := api.DeregisterDevice(degCtx, cred, target) degCancel() if err != nil { - return fmt.Errorf("deregister device: %w", err) + return fmt.Errorf("deregister the pod: %w", err) } if strings.TrimSpace(device.Name) != "" { label = device.Name } - log.Printf("device: deregistered name=%s id=%s (data retained)", label, device.ID) + log.Printf("pod: deregistered name=%s id=%s (data retained)", label, device.ID) // Wipe the pod-side cloud provisioning so the firmware stops reconnecting. `cloud_clear` // (not `cloud_set`) is the right tool: it drops the stored endpoint AND the now-dead @@ -147,14 +146,14 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st _, err = client.Command(setCtx, map[string]any{"cmd": "cloud_clear"}) setCancel() if err != nil { - log.Printf("warning: could not clear the bench pod's cloud configuration: %v", err) + log.Printf("warning: could not clear the pod's cloud configuration: %v", err) log.Printf("the pod will keep trying to connect and be refused; re-run with the pod reachable, or clear it by hand") } else { - log.Printf("provisioned: bench pod cloud configuration cleared") + log.Printf("provisioned: pod cloud configuration cleared") } } - fmt.Fprintf(os.Stderr, "Deregistered bench pod %q (device %s). Its data is kept; register it again to restore it.\n", + fmt.Fprintf(os.Stderr, "Deregistered pod %q (id %s). Its data is kept; register it again to restore it.\n", label, device.ID) return nil } @@ -171,12 +170,12 @@ func findDeviceToDeregister(devices []serverapi.DeviceResponse, publicKey, name } } return serverapi.DeviceResponse{}, errors.New( - "the attached bench pod is not registered to this account (no device matches its public key)") + "the pod is not registered to this account (no pod on it has its public key)") } for _, d := range devices { if d.Name == name { return d, nil } } - return serverapi.DeviceResponse{}, fmt.Errorf("no device named %q is registered to this account", name) + return serverapi.DeviceResponse{}, fmt.Errorf("no pod named %q is registered to this account", name) } diff --git a/cmd/benchpod-cli/commands_discover.go b/cmd/benchpod-cli/commands_discover.go index ac074eb..0e0cf14 100644 --- a/cmd/benchpod-cli/commands_discover.go +++ b/cmd/benchpod-cli/commands_discover.go @@ -117,6 +117,12 @@ func runDiscover(g *globalFlags, o discoverOpts) error { if !o.usb && !o.network { return errors.New("--no-usb and --no-network together leave nothing to look for") } + if raw := strings.TrimSpace(g.connection); raw != "" { + if spec, err := parseTarget(raw); err == nil && spec.IsCloud() { + return fmt.Errorf("discover looks for pods on USB and the LAN, not on embeddedci.com (--connection %s%s). "+ + "Ask that pod with `benchpod status --connection %s%s`, or see your pods in the web app", cloudConnPrefix, spec.Name, cloudConnPrefix, spec.Name) + } + } var ( serialPods []serialconsole.SerialPod diff --git a/cmd/benchpod-cli/commands_register.go b/cmd/benchpod-cli/commands_register.go index fe6a482..6b5e776 100644 --- a/cmd/benchpod-cli/commands_register.go +++ b/cmd/benchpod-cli/commands_register.go @@ -27,7 +27,7 @@ func newRegisterCmd(g *globalFlags) *cobra.Command { var wait time.Duration cmd := &cobra.Command{ Use: "register", - Short: "Register the bench pod and provision it to connect directly to the server", + Short: "Register the pod and provision it to connect directly to the server", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { return runRegister(g, registerOptions{ @@ -38,7 +38,7 @@ func newRegisterCmd(g *globalFlags) *cobra.Command { } cmd.Flags().StringVar(&serverURL, "server-url", "https://www.embeddedci.com", "embeddedci-server base URL") cmd.Flags().StringVar(&tokenFile, "token-file", "", "path to token cache (default: ~/.config/benchpod-cli/token.json)") - cmd.Flags().StringVar(&deviceName, "device-name", "", "device name, URL-safe (default: the pod's own name, e.g. benchpod-a1b2c3)") + cmd.Flags().StringVar(&deviceName, "device-name", "", "the pod's name on embeddedci.com, URL-safe (default: the pod's own name, e.g. benchpod-a1b2c3)") cmd.Flags().StringVar(&podHost, "pod-host", "", "host the pod connects to (default: api.embeddedci.com for embeddedci.com, else the --server-url host)") cmd.Flags().DurationVar(&wait, "wait", 30*time.Second, "how long to wait for the pod to connect to the server (0 = don't wait)") @@ -112,11 +112,11 @@ 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.resolveConnection() + spec, err := g.resolveTarget() if err != nil { return err } - if err := spec.RequireWifi("register"); err != nil { + if err := spec.RequireNetwork("register"); err != nil { return err } addr := spec.Addr @@ -143,7 +143,7 @@ func runRegister(g *globalFlags, opts registerOptions) error { pubKey, err := client.IdentityPublic(idCtx) idCancel() if err != nil { - return fmt.Errorf("fetch device public key: %w", err) + return fmt.Errorf("fetch the pod's public key: %w", err) } name := strings.TrimSpace(deviceName) @@ -160,9 +160,9 @@ func runRegister(g *globalFlags, opts registerOptions) error { device, err := api.RegisterDevice(regCtx, tokens.AccessToken, name, pubKey, nil) regCancel() if err != nil { - return fmt.Errorf("register device: %w", err) + return fmt.Errorf("register the pod: %w", err) } - log.Printf("device: registered name=%s id=%s", device.Name, device.ID) + log.Printf("pod: registered name=%s id=%s", device.Name, device.ID) if device.Name != name { // A re-register of an already-registered pod keeps its (possibly user-edited) name. fmt.Fprintf(os.Stderr, "This pod is already registered as %q. Rename it on the BenchPod page.\n", device.Name) @@ -182,12 +182,12 @@ func runRegister(g *globalFlags, opts registerOptions) error { }) setCancel() if err != nil { - return fmt.Errorf("provision bench pod cloud connection: %w", err) + return fmt.Errorf("provision pod cloud connection: %w", err) } - log.Printf("provisioned: bench pod will connect to %s:%d (tls=%v verify=%v) as device %s", host, port, tls, verify, device.ID) + log.Printf("provisioned: pod will connect to %s:%d (tls=%v verify=%v) as device_id %s", host, port, tls, verify, device.ID) if opts.wait <= 0 { - fmt.Fprintf(os.Stderr, "Registered bench pod %q (device %s).\n", device.Name, device.ID) + fmt.Fprintf(os.Stderr, "Registered pod %q (id %s).\n", device.Name, device.ID) return nil } @@ -196,7 +196,7 @@ func runRegister(g *globalFlags, opts registerOptions) error { return readCloudStatus(ctx, client) }, opts.wait, time.Second) if status.State == "connected" { - fmt.Fprintf(os.Stderr, "Registered bench pod %q and it is connected.\n", device.Name) + fmt.Fprintf(os.Stderr, "Registered pod %q and it is connected.\n", device.Name) fmt.Fprintf(os.Stderr, "Run tests against it: pytest --benchpod-connection=embeddedci:%s\n", device.Name) return nil } @@ -204,7 +204,7 @@ func runRegister(g *globalFlags, opts registerOptions) error { return ctx.Err() } fmt.Fprint(os.Stderr, describeNotConnected(device.Name, host, opts.wait, status, pollErr)) - return fmt.Errorf("bench pod did not connect within %s", opts.wait) + return fmt.Errorf("pod did not connect within %s", opts.wait) } // cloudStatus is the part of the firmware's `cloud_status` reply that register reads. @@ -259,7 +259,7 @@ func waitCloudConnected(ctx context.Context, poll func(context.Context) (cloudSt // what the pod last reported, and the likely fix. func describeNotConnected(name, host string, wait time.Duration, st cloudStatus, pollErr error) string { var b strings.Builder - fmt.Fprintf(&b, "Registered bench pod %q, but it did not connect to %s within %s.\n", name, host, wait) + fmt.Fprintf(&b, "Registered pod %q, but it did not connect to %s within %s.\n", name, host, wait) fmt.Fprintln(&b, "The registration stands; the pod keeps retrying on its own.") if st.State != "" { fmt.Fprintf(&b, " state: %s\n", st.State) @@ -297,9 +297,9 @@ func cloudErrorHint(st cloudStatus, lastError string) string { case strings.HasPrefix(lastError, "tls"): return "the secure connection failed. Check --server-url and that nothing on the network intercepts HTTPS." case strings.Contains(lastError, "refused the device (HTTP 404)"): - return "the server does not know this device. Check that --server-url is the server you registered with." + return "the server does not know this pod. Check that --server-url is the server you registered with." case strings.Contains(lastError, "refused the device (HTTP 403)"): - return "the device was deregistered. Run `benchpod register` again." + return "the pod was deregistered. Run `benchpod register` again." case strings.HasPrefix(lastError, "server refused"), strings.HasPrefix(lastError, "websocket"): return "the server rejected the connection. Run `benchpod register` again, or update the pod firmware." case strings.HasPrefix(lastError, "connection lost"), strings.HasPrefix(lastError, "no reply"): diff --git a/cmd/benchpod-cli/commands_serial.go b/cmd/benchpod-cli/commands_serial.go index 2465e50..e3f4efc 100644 --- a/cmd/benchpod-cli/commands_serial.go +++ b/cmd/benchpod-cli/commands_serial.go @@ -55,7 +55,11 @@ func runSetWifi(g *globalFlags, ssid, pw string, skipBlobs bool) (string, error) // modem, then AT+CWJAP (up to ~15s) and TCP bring-up. That can take // ~40s, so wait generously — the command returns as soon as the prompt // reappears, this is only the upper bound. Override with --timeout. - console, path, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(90*time.Second)) + device, err := g.usbDevice("set-wifi") + if err != nil { + return "", err + } + console, path, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(90*time.Second)) if err != nil { return "", err } @@ -153,7 +157,11 @@ func newShowNetworkCmd(g *globalFlags) *cobra.Command { "SSID at all. The SSID/state/RSSI fields describe the optional Wi-Fi interface.", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - console, _, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(15*time.Second)) + device, err := g.usbDevice("show-network") + if err != nil { + return err + } + console, _, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(15*time.Second)) if err != nil { return err } @@ -188,7 +196,11 @@ func newClearWifiCmd(g *globalFlags) *cobra.Command { Short: "Erase the stored Wi-Fi credentials (Wi-Fi goes down at once)", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - console, _, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(10*time.Second)) + device, err := g.usbDevice("clear-wifi") + if err != nil { + return err + } + console, _, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(10*time.Second)) if err != nil { return err } @@ -214,13 +226,17 @@ func newBootselCmd(g *globalFlags) *cobra.Command { Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { if !yes { - fmt.Fprint(os.Stderr, "This reboots the device into the UF2 bootloader; the USB console will disconnect. Type 'yes' to continue: ") + fmt.Fprint(os.Stderr, "This reboots the pod into the UF2 bootloader; the USB console will disconnect. Type 'yes' to continue: ") line, _ := bufio.NewReader(os.Stdin).ReadString('\n') if strings.TrimSpace(line) != "yes" { return errors.New("aborted") } } - console, _, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(10*time.Second)) + device, err := g.usbDevice("bootsel") + if err != nil { + return err + } + console, _, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(10*time.Second)) if err != nil { return err } @@ -230,7 +246,7 @@ func newBootselCmd(g *globalFlags) *cobra.Command { if err := console.Bootsel(ctx); err != nil { return fmt.Errorf("bootsel: %w", err) } - fmt.Fprintln(os.Stderr, "Device entering BOOTSEL (UF2 drive). USB console disconnected — this is expected.") + fmt.Fprintln(os.Stderr, "Pod entering BOOTSEL (UF2 drive). USB console disconnected; this is expected.") fmt.Fprintln(os.Stderr, "Drop firmware.uf2 onto the RP2350 drive (or use picotool) to flash.") return nil }, @@ -259,7 +275,11 @@ func newDfuCmd(g *globalFlags) *cobra.Command { return errors.New("aborted") } } - console, _, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(10*time.Second)) + device, err := g.usbDevice("dfu") + if err != nil { + return err + } + console, _, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(10*time.Second)) if err != nil { return err } @@ -269,7 +289,7 @@ func newDfuCmd(g *globalFlags) *cobra.Command { if err := console.Dfu(ctx); err != nil { return fmt.Errorf("dfu: %w", err) } - fmt.Fprintln(os.Stderr, "Device entering DFU (USB bootloader). USB console disconnected — this is expected.") + fmt.Fprintln(os.Stderr, "Device entering DFU (USB bootloader). USB console disconnected; this is expected.") fmt.Fprintln(os.Stderr, "Write firmware with `benchpod flash-self ` (or dfu-util) to flash.") return nil }, diff --git a/cmd/benchpod-cli/commands_tcp.go b/cmd/benchpod-cli/commands_tcp.go index 468087f..f1faff6 100644 --- a/cmd/benchpod-cli/commands_tcp.go +++ b/cmd/benchpod-cli/commands_tcp.go @@ -11,7 +11,7 @@ import ( ) // The commands in this file speak the firmware's TCP/JSON API. Most are -// wifi-only and use g.wifiClient(...), which resolves the connection and bails +// wifi-only and use g.networkClient(...), which resolves the connection and bails // with a clear message on a serial transport. `status` is the exception: the // firmware exposes a `status` console command too, so it runs over either // transport. @@ -24,13 +24,13 @@ func newPingCmd(g *globalFlags) *cobra.Command { Short: "Connectivity check", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - ctx, cancel, client, err := g.wifiClient("ping", 30*time.Second) + ctx, cancel, pod, err := g.podClient("ping", 30*time.Second) if err != nil { return err } defer cancel() - data, err := client.Command(ctx, map[string]any{"cmd": "ping"}) + data, err := pod.client.Command(ctx, map[string]any{"cmd": "ping"}) if err != nil { return fmt.Errorf("ping: %w", err) } @@ -73,7 +73,7 @@ func newGenerateCmd(g *globalFlags) *cobra.Command { if err != nil { return err } - ctx, cancel, client, err := g.wifiClient("generate", 30*time.Second) + ctx, cancel, pod, err := g.podClient("generate", 30*time.Second) if err != nil { return err } @@ -90,7 +90,7 @@ func newGenerateCmd(g *globalFlags) *cobra.Command { if sampleRate > 0 { req["sample_rate_mhz"] = sampleRate } - data, err := client.Command(ctx, req) + data, err := pod.client.Command(ctx, req) if err != nil { return fmt.Errorf("generate: %w", err) } @@ -146,7 +146,7 @@ func newSampleCmd(g *globalFlags, name, short string) *cobra.Command { if !validOutput(output) { return fmt.Errorf("--output must be json, csv, or ndjson") } - ctx, cancel, client, err := g.wifiClient(name, 30*time.Second) + ctx, cancel, client, err := g.networkClient(name, 30*time.Second) if err != nil { return err } @@ -194,7 +194,7 @@ func newMeasureCmd(g *globalFlags) *cobra.Command { if !validOutput(output) { return fmt.Errorf("--output must be json, csv, or ndjson") } - ctx, cancel, client, err := g.wifiClient("measure", 30*time.Second) + ctx, cancel, client, err := g.networkClient("measure", 30*time.Second) if err != nil { return err } @@ -261,7 +261,7 @@ func newTestCmd(g *globalFlags) *cobra.Command { req["value"] = value } - ctx, cancel, client, err := g.wifiClient("test", 30*time.Second) + ctx, cancel, client, err := g.networkClient("test", 30*time.Second) if err != nil { return err } @@ -311,13 +311,13 @@ func newLAPullupCmd(g *globalFlags) *cobra.Command { default: return fmt.Errorf("invalid state %q (use on or off)", args[1]) } - ctx, cancel, client, err := g.wifiClient("la pullup", 30*time.Second) + ctx, cancel, pod, err := g.podClient("la pullup", 30*time.Second) if err != nil { return err } defer cancel() - data, err := client.Command(ctx, map[string]any{"cmd": "la", "la": pin, "pullup": state}) + data, err := pod.client.Command(ctx, map[string]any{"cmd": "la", "la": pin, "pullup": state}) if err != nil { return fmt.Errorf("la pullup: %w", err) } @@ -346,13 +346,13 @@ func newLAStatusCmd(g *globalFlags) *cobra.Command { } req["la"] = pin } - ctx, cancel, client, err := g.wifiClient("la status", 30*time.Second) + ctx, cancel, pod, err := g.podClient("la status", 30*time.Second) if err != nil { return err } defer cancel() - data, err := client.Command(ctx, req) + data, err := pod.client.Command(ctx, req) if err != nil { return fmt.Errorf("la status: %w", err) } @@ -422,13 +422,13 @@ func newLAStepCmd(g *globalFlags) *cobra.Command { def = 30 * time.Second } - ctx, cancel, client, err := g.wifiClient("la step", def) + ctx, cancel, pod, err := g.podClient("la step", def) if err != nil { return err } defer cancel() - data, err := client.Command(ctx, req) + data, err := pod.client.Command(ctx, req) if err != nil { return fmt.Errorf("la step: %w", err) } diff --git a/cmd/benchpod-cli/connection.go b/cmd/benchpod-cli/connection.go index e9a2e01..a01097d 100644 --- a/cmd/benchpod-cli/connection.go +++ b/cmd/benchpod-cli/connection.go @@ -15,7 +15,7 @@ import ( type connKind int const ( - connWifi connKind = iota + connNetwork connKind = iota connSerial connCloud ) @@ -24,15 +24,15 @@ const ( // (or stored default) carries both where and how to connect; the transport is // inferred from the value's shape — there is no separate address flag. // -// 192.168.1.5[:port], host -> wifi/TCP (Addr set) -// /dev/tty..., COM3, \\.\COM3 -> serial (Device set) -// serial / usb -> serial, auto-detect (Device == "") -// embeddedci: -> cloud (Name set), only where a command can work over it +// 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 == "") +// 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 wifi transport - Device string // serial device path; "" means auto-detect (USB VID 2E8A) - Name string // the pod's device name on embeddedci.com, for the cloud form + Addr string // TCP host:port for the network transport + Device string // serial device path; "" means auto-detect + Name string // the pod's name on embeddedci.com, for the cloud form } // cloudConnPrefix is the SDK's and MCP's connection form for a pod on embeddedci.com. @@ -51,18 +51,16 @@ func isSerialDevice(raw string) bool { return comPattern.MatchString(raw) } -// classifyConnection turns a raw --connection value (or stored default) into a -// ConnSpec for a command that talks to the pod itself, over the network or USB. -// It errors on an empty value, on a bare transport keyword that carries no -// address, and on the cloud form, which those commands cannot use. +// classifyConnection turns a raw --connection value (or stored default) into a ConnSpec for a +// command that talks to the pod itself, over the network or USB. It errors on an empty value, on +// a bare transport keyword that carries no address, and on the cloud form, which those commands +// cannot use. func classifyConnection(raw string) (ConnSpec, error) { spec, err := parseTarget(raw) if err == nil && spec.IsCloud() { - // Parsed as host:port it would be dialed (host "embeddedci", port "") - // and retried until the timeout, so say what it is instead. - return ConnSpec{}, fmt.Errorf("--connection %s names a pod on embeddedci.com; the benchpod CLI talks to a pod over the network or USB. "+ - "Pass the pod's address (`benchpod discover` finds it) or `usb`. Drive a cloud pod from the web app, the Python SDK or the MCP server (they take %s)", - strings.TrimSpace(raw), cloudConnPrefix) + // Parsed as host:port it would be dialed (host "embeddedci", port "") and retried + // until the timeout, so say what it is instead. + return ConnSpec{}, cloudRefusal("this command", spec, true) } return spec, err } @@ -75,8 +73,8 @@ func parseTarget(raw string) (ConnSpec, error) { raw = strings.TrimSpace(raw) if raw == "" { return ConnSpec{}, errors.New("no connection set. Run `benchpod discover --save` to find the pod and save it as the default " + - "(or `benchpod setup` for the guided first-time setup), or pass --connection , " + - "or save one with `benchpod set-connection `") + "(or `benchpod setup` for the guided first-time setup), or pass --connection , " + + "or save one with `benchpod set-connection `") } switch strings.ToLower(raw) { // "usb" is the name this keyword goes by everywhere the CLI speaks to a @@ -92,28 +90,28 @@ func parseTarget(raw string) (ConnSpec, error) { if strings.HasPrefix(strings.ToLower(raw), cloudConnPrefix) { name := strings.TrimSpace(raw[len(cloudConnPrefix):]) if name == "" { - return ConnSpec{}, fmt.Errorf("--connection %s needs the pod's device name, e.g. %sbenchpod-a1b2c3", raw, cloudConnPrefix) + return ConnSpec{}, fmt.Errorf("--connection %s needs the pod's name on embeddedci.com, e.g. %sbenchpod-a1b2c3", raw, cloudConnPrefix) } return ConnSpec{Kind: connCloud, Name: name}, nil } if isSerialDevice(raw) { return ConnSpec{Kind: connSerial, Device: raw}, nil } - return ConnSpec{Kind: connWifi, Addr: benchpodconfig.EnsurePort(raw)}, nil + return ConnSpec{Kind: connNetwork, Addr: benchpodconfig.EnsurePort(raw)}, nil } -func (c ConnSpec) IsWifi() bool { return c.Kind == connWifi } -func (c ConnSpec) IsSerial() bool { return c.Kind == connSerial } -func (c ConnSpec) IsCloud() bool { return c.Kind == connCloud } +func (c ConnSpec) IsNetwork() bool { return c.Kind == connNetwork } +func (c ConnSpec) IsSerial() bool { return c.Kind == connSerial } +func (c ConnSpec) IsCloud() bool { return c.Kind == connCloud } // describeConn is a short human phrase for a resolved connection, used in // confirmation messages. func describeConn(c ConnSpec) string { - if c.IsWifi() { + if c.IsNetwork() { return "network/TCP " + c.Addr } if c.IsCloud() { - return "embeddedci.com device " + c.Name + return c.Name + " on embeddedci.com" } if c.Device != "" { return "usb " + c.Device @@ -121,29 +119,55 @@ func describeConn(c ConnSpec) string { return "usb (auto-detect)" } -// RequireWifi guards the TCP/JSON commands that the firmware's serial console -// does not implement: it errors unless the network transport is selected. +// RequireNetwork guards the commands that need the pod's own network port (TCP/JSON or a raw +// stream on it): it errors unless the network transport is selected. // -// The message names the way out rather than only the restriction, because a -// caller hitting this is usually part-way through first-time setup and does not -// yet know the pod's address — `discover` is what finds it. -func (c ConnSpec) RequireWifi(cmd string) error { - if c.IsWifi() { +// The message names the way out rather than only the restriction, because a caller hitting this +// over USB is usually part-way through first-time setup and does not yet know the pod's address; +// `discover` is what finds it. Over embeddedci.com it names what does work there. +func (c ConnSpec) RequireNetwork(cmd string) error { + if c.IsNetwork() { return nil } + 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) } +// cloudRefusal is the one message for a command that cannot go through embeddedci.com: it streams +// data or needs a direct link to the pod, which the CLI cannot get there. usb says whether the +// command also works over the pod's USB console. +func cloudRefusal(cmd string, c ConnSpec, usb bool) error { + direct := "pass the pod's network address (`benchpod discover` finds it)" + if usb { + direct = "pass the pod's network address (`benchpod discover` finds it) or `usb`" + } + return fmt.Errorf("%s does not work over embeddedci.com (--connection %s%s): it needs a direct link to the pod. "+ + "Use the web app or the Python SDK / pytest plugin (they take %s%s), or %s. Over embeddedci.com the CLI runs: %s", + cmd, cloudConnPrefix, c.Name, cloudConnPrefix, c.Name, direct, cloudCommandList) +} + +// cloudCommandList names the commands that work over embeddedci.com, for messages and help. +const cloudCommandList = "status, ping, la voltage, la pullup, la status, la step, generate, " + + "lan-policy, sig-policy, cloud ca show/clear, cloud proxy, deregister" + // ── set-connection ─────────────────────────────────────────────────────────── func newSetConnectionCmd(g *globalFlags) *cobra.Command { return &cobra.Command{ - Use: "set-connection ", + Use: "set-connection ", Aliases: []string{"set-bench-pod"}, - Short: `Store the default connection: a TCP address, a device path, or "usb"`, - Args: cobra.ExactArgs(1), + Short: `Store the default connection: a network address, a device path, "usb", or embeddedci:`, + Long: "Store the default connection in the config file, so later commands can leave out\n" + + "--connection. The value is what --connection takes: the pod's network address\n" + + "(192.168.1.5[:8080]), a USB device path, `usb` to auto-detect the pod on USB, or\n" + + "embeddedci: to go through embeddedci.com.\n\n" + + "The Python SDK and the MCP server read the same saved connection (see the README,\n" + + "\"Shared config\").", + Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { return runSetConnection(g, args[0]) }, @@ -156,7 +180,7 @@ func runSetConnection(g *globalFlags, arg string) error { return errors.New("connection target cannot be empty") } // Validate now (and report the inferred transport) rather than failing later. - spec, err := classifyConnection(target) + spec, err := parseTarget(target) if err != nil { return err } diff --git a/cmd/benchpod-cli/connection_test.go b/cmd/benchpod-cli/connection_test.go index 2203f6a..05d7a81 100644 --- a/cmd/benchpod-cli/connection_test.go +++ b/cmd/benchpod-cli/connection_test.go @@ -28,8 +28,8 @@ func TestClassifyConnection(t *testing.T) { t.Errorf("classifyConnection(%q): unexpected error %v", tt.in, err) continue } - if spec.IsWifi() != tt.wantWifi { - t.Errorf("classifyConnection(%q).IsWifi() = %v, want %v", tt.in, spec.IsWifi(), tt.wantWifi) + if spec.IsNetwork() != tt.wantWifi { + t.Errorf("classifyConnection(%q).IsNetwork() = %v, want %v", tt.in, spec.IsNetwork(), tt.wantWifi) } if spec.Addr != tt.addr { t.Errorf("classifyConnection(%q).Addr = %q, want %q", tt.in, spec.Addr, tt.addr) @@ -71,36 +71,47 @@ func TestParseTargetCloudForm(t *testing.T) { if err != nil || !spec.IsCloud() || spec.Name != name { t.Errorf("parseTarget(%q) = %+v, %v", in, spec, err) } - if got := describeConn(spec); got != "embeddedci.com device "+name { + if got := describeConn(spec); got != name+" on embeddedci.com" { t.Errorf("describeConn = %q", got) } } - if _, err := parseTarget("embeddedci:"); err == nil || !strings.Contains(err.Error(), "device name") { + if _, err := parseTarget("embeddedci:"); err == nil || !strings.Contains(err.Error(), "pod's name") { t.Errorf("empty name: %v", err) } - for in, want := range map[string]connKind{"192.168.1.5": connWifi, "usb": connSerial, "/dev/tty.usbmodem1": connSerial} { + for in, want := range map[string]connKind{"192.168.1.5": connNetwork, "usb": connSerial, "/dev/tty.usbmodem1": connSerial} { if spec, err := parseTarget(in); err != nil || spec.Kind != want { t.Errorf("parseTarget(%q) = %+v, %v", in, spec, err) } } } -func TestRequireWifi(t *testing.T) { - wifi, _ := classifyConnection("192.168.1.5") - if err := wifi.RequireWifi("ping"); err != nil { - t.Errorf("RequireWifi on wifi transport: unexpected error %v", err) +func TestRequireNetwork(t *testing.T) { + network, _ := classifyConnection("192.168.1.5") + if err := network.RequireNetwork("ping"); err != nil { + t.Errorf("RequireNetwork on the network transport: unexpected error %v", err) + } + // Over embeddedci.com it names what works there and the ways out. + cloud, _ := parseTarget("embeddedci:bench-1") + err := cloud.RequireNetwork("capture") + for _, want := range []string{"capture", "embeddedci:bench-1", "web app", "SDK", "benchpod discover", "status, ping, la voltage"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("RequireNetwork(cloud) = %v, want it to mention %q", err, want) + } + } + if err != nil && strings.Contains(err.Error(), "`usb`") { + t.Errorf("RequireNetwork(cloud) offers USB to a network-only command: %v", err) } // "serial" is still accepted as an input spelling, so both forms must land // on the same USB transport and the same guidance. for _, conn := range []string{"usb", "serial", "/dev/tty.usbmodem1101"} { spec, _ := classifyConnection(conn) - err := spec.RequireWifi("ping") + err := spec.RequireNetwork("ping") if err == nil { - t.Fatalf("RequireWifi(%q) = nil, want error", conn) + t.Fatalf("RequireNetwork(%q) = nil, want error", conn) } for _, want := range []string{"ping", "USB", "benchpod discover"} { if !strings.Contains(err.Error(), want) { - t.Errorf("RequireWifi(%q) error = %q, want it to mention %q", conn, err, want) + t.Errorf("RequireNetwork(%q) error = %q, want it to mention %q", conn, err, want) } } } diff --git a/cmd/benchpod-cli/errors.go b/cmd/benchpod-cli/errors.go index 9d21029..2c038ab 100644 --- a/cmd/benchpod-cli/errors.go +++ b/cmd/benchpod-cli/errors.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + "github.com/embeddedci-com/benchpod-cli/internal/cloudpod" "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" "github.com/spf13/cobra" ) @@ -124,6 +125,11 @@ func (e *shownRefusal) Unwrap() error { return e.pe } // the client's error (%w) still gets the hint. A JSON reply (no console command) came over the // LAN. func withRefusalHint(err error) error { + // embeddedci.com's own failures (a held lease, a missing role) say what to do already. + var ce *cloudpod.Error + if errors.As(err, &ce) { + return err + } pe, ok := tcpclient.AsPodError(err) if !ok { return err @@ -150,7 +156,7 @@ const ( exitUsage = 2 // a bad flag, argument or subcommand exitRefused = 3 // the pod refused the command (locked LAN, missing role, bad request) exitBusy = 4 // the pod or one of its engines is in use (a cloud lease, another session) - exitUnreachable = 5 // the pod did not answer on its address, or no pod on USB + exitUnreachable = 5 // the pod did not answer on its address, no pod on USB, or offline on embeddedci.com ) // exitCodesHelp is the exit-code table for the root command's help. @@ -160,7 +166,8 @@ const exitCodesHelp = "Exit codes:\n" + " 2 usage: a bad flag, argument or subcommand\n" + " 3 refused: the pod refused the command (a locked LAN, a missing role, a bad request)\n" + " 4 busy: the pod or one of its engines is in use (a cloud job's lease, another session)\n" + - " 5 unreachable: nothing answered on the pod's address, or no pod found on USB" + " 5 unreachable: nothing answered on the pod's address, no pod found on USB, or the\n" + + " pod is offline on embeddedci.com" // usageError marks a command-line mistake (exit code 2). type usageError struct{ err error } diff --git a/cmd/benchpod-cli/errors_test.go b/cmd/benchpod-cli/errors_test.go index ddf14c5..8cdbd72 100644 --- a/cmd/benchpod-cli/errors_test.go +++ b/cmd/benchpod-cli/errors_test.go @@ -58,7 +58,7 @@ func TestWithRefusalHintAddsTheHintOnce(t *testing.T) { if got := withRefusalHint(usb); got != error(usb) { t.Fatalf("got %v", got) } - plain := errors.New("connect to bench pod at 10.0.0.1:8080: i/o timeout") + plain := errors.New("connect to pod at 10.0.0.1:8080: i/o timeout") if got := withRefusalHint(plain); got != plain { t.Fatalf("got %v", got) } diff --git a/cmd/benchpod-cli/flash.go b/cmd/benchpod-cli/flash.go index 9782b91..366d963 100644 --- a/cmd/benchpod-cli/flash.go +++ b/cmd/benchpod-cli/flash.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log" "os" "strings" "time" @@ -14,8 +15,8 @@ import ( "github.com/spf13/cobra" ) -// flashTimeout bounds a whole flash session; flashing over the WiFi/AT bridge -// (or the USB serial console) is slow, so this is generous compared to the 30s +// flashTimeout bounds a whole flash session; flashing over the network +// (or the USB console) is slow, so this is generous compared to the 30s // default command deadline. Overridable with --timeout. const flashTimeout = 5 * time.Minute @@ -65,6 +66,9 @@ type flashFlags struct { extraConfigs []string extraArgs []string + // nresetSet says --nreset was given (either way), so the wiring profile does not override it. + nresetSet bool + // hasNRST is filled in from the pod during runFlash (not a flag): whether // this pod has the dedicated target-reset pin. Used by the failure hint. hasNRST bool @@ -77,8 +81,8 @@ type flashFlags struct { // // The pod runs a CMSIS-DAP processor locally and OpenOCD's cmsis-dap TCP backend // ships whole DAP transfers (DAP_Transfer / DAP_TransferBlock), collapsing -// thousands of per-bit round-trips into one per DAP command. --connection wifi -// uses the TCP dap_start handshake (tcpclient.DAPStart); --connection serial / a +// thousands of per-bit round-trips into one per DAP command. A network address +// uses the TCP dap_start handshake (tcpclient.DAPStart); --connection usb / a // device path uses the console dap-start handshake (serialconsole.DAPStart). Both // need a recent OpenOCD with the cmsis_dap_tcp backend. func newFlashCmd(g *globalFlags) *cobra.Command { @@ -86,16 +90,25 @@ func newFlashCmd(g *globalFlags) *cobra.Command { cmd := &cobra.Command{ Use: "flash", Short: "Flash an SWD target via OpenOCD's CMSIS-DAP backend (network or USB)", - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { + Long: "Flash an SWD target wired to the pod's LA pins: the pod becomes a CMSIS-DAP\n" + + "probe and a local OpenOCD programs the target through it. Works over the network\n" + + "and over USB; not over embeddedci.com (use the web app or the Python SDK there).\n\n" + + "--swclk, --swdio, --nreset and --target default to swd_swclk, swd_swdio,\n" + + "swd_nreset and swd_target in the pod's wiring profile on embeddedci.com when this\n" + + "machine is signed in (`benchpod login` or BENCHPOD_API_KEY), the pod is on that\n" + + "account and the connection is a network address. Flags always win; the log says\n" + + "where each value came from. Without a profile, --swclk and --swdio are required.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + f.nresetSet = cmd.Flags().Changed("nreset") return runFlash(g, f) }, } fl := cmd.Flags() - fl.StringVar(&f.swclk, "swclk", "", "LA pin for SWCLK, 1-14, e.g. 1 or la1 (required)") - fl.StringVar(&f.swdio, "swdio", "", "LA pin for SWDIO, 1-14, e.g. 2 or la2 (required)") - fl.BoolVar(&f.nreset, "nreset", false, "the target's NRST is wired to the pod's reset pin ("+nrstPinLocation+"); enables connect-under-reset") - fl.StringVar(&f.target, "target", "", "OpenOCD target config (passed as -f), e.g. target/stm32f1x.cfg") + fl.StringVar(&f.swclk, "swclk", "", "LA pin for SWCLK, 1-14, e.g. 1 or la1 (default: swd_swclk from the pod's wiring profile)") + fl.StringVar(&f.swdio, "swdio", "", "LA pin for SWDIO, 1-14, e.g. 2 or la2 (default: swd_swdio from the pod's wiring profile)") + fl.BoolVar(&f.nreset, "nreset", false, "the target's NRST is wired to the pod's reset pin ("+nrstPinLocation+"); enables connect-under-reset (default: swd_nreset from the pod's wiring profile)") + fl.StringVar(&f.target, "target", "", "OpenOCD target config (passed as -f), e.g. target/stm32f1x.cfg (default: swd_target from the pod's wiring profile)") fl.StringVar(&f.file, "file", "", "firmware image to flash (used with --target)") fl.StringVar(&f.loadAddr, "load-address", "", "load address for a raw .bin image (appended to the program command)") fl.BoolVar(&f.noVerify, "no-verify", false, "do not verify after programming") @@ -110,8 +123,18 @@ func newFlashCmd(g *globalFlags) *cobra.Command { } func runFlash(g *globalFlags, f *flashFlags) error { + spec, err := g.resolveTarget() + if err != nil { + return err + } + if spec.IsCloud() { + return cloudRefusal("flash", spec, true) + } + if spec.IsNetwork() { + f.fillFromWiring(g) + } if strings.TrimSpace(f.swclk) == "" || strings.TrimSpace(f.swdio) == "" { - return fmt.Errorf("--swclk and --swdio are required") + return fmt.Errorf("--swclk and --swdio are required (or set swd_swclk and swd_swdio in the pod's wiring profile on embeddedci.com)") } swclk, err := parseLAPin(f.swclk) if err != nil { @@ -210,6 +233,40 @@ func runFlash(g *globalFlags, f *flashFlags) error { return nil } +// fillFromWiring takes the SWD settings left out of the flags from the pod's wiring profile on +// embeddedci.com, and logs where each one came from. +func (f *flashFlags) fillFromWiring(g *globalFlags) { + needed := f.swclk == "" || f.swdio == "" || !f.nresetSet || f.target == "" + var w *podWiring + if needed { + w = loadPodWiring(g, os.Stderr) + } + if w == nil { + log.Printf("flash: SWD settings from the flags") + return + } + var from []string + if pinFromWiring(&f.swclk, w.SwdSwclk) { + from = append(from, "SWCLK LA"+f.swclk) + } + if pinFromWiring(&f.swdio, w.SwdSwdio) { + from = append(from, "SWDIO LA"+f.swdio) + } + if !f.nresetSet && w.SwdNreset != nil { + f.nreset = *w.SwdNreset + from = append(from, fmt.Sprintf("nreset %v", f.nreset)) + } + if f.target == "" && strings.TrimSpace(w.SwdTarget) != "" { + f.target = strings.TrimSpace(w.SwdTarget) + from = append(from, "target "+f.target) + } + if len(from) > 0 { + log.Printf("flash: %s from %s; the other settings from the flags", strings.Join(from, ", "), wiringSource) + } else { + log.Printf("flash: SWD settings from the flags") + } +} + // printTargetUnreachableHint explains a failed DAP connect: the pod's probe is // working but no target answered on SWD. The advice is ordered by likelihood and // references the exact pins/flags the user passed. @@ -234,15 +291,15 @@ func printTargetUnreachableHint(f *flashFlags) { } // openDAP optionally powers the target, then arms the pod's CMSIS-DAP probe over -// the selected transport (dap_start over wifi/TCP, the dap-start console command -// over serial) and returns the length-framed DAP stream to bridge to OpenOCD. +// the selected transport (dap_start over the network, the dap-start console command +// over USB) and returns the length-framed DAP stream to bridge to OpenOCD. // powerEfuse 0 leaves target power untouched; 1/2 enables that eFuse first. func openDAP(ctx context.Context, g *globalFlags, swclk, swdio int, powerEfuse int) (io.ReadWriteCloser, bool, error) { spec, err := g.resolveConnection() if err != nil { return nil, false, err } - if spec.IsWifi() { + if spec.IsNetwork() { client := &tcpclient.Client{Addr: spec.Addr} // Ask the pod whether it has the dedicated reset pin BEFORE arming the // probe — once the connection is in DAP mode it no longer speaks JSON. @@ -276,7 +333,7 @@ func openDAP(ctx context.Context, g *globalFlags, swclk, swdio int, powerEfuse i if err != nil { return nil, false, err } - // Same probe as the wifi path, on the console this time — and for the same + // Same probe as the network path, on the console this time — and for the same // reason it has to happen before dap-start: the console stops taking text // commands once the probe is armed. The port is already open, so this costs // one `status` round-trip and no extra connection. diff --git a/cmd/benchpod-cli/flash_self.go b/cmd/benchpod-cli/flash_self.go index dfc26eb..8609759 100644 --- a/cmd/benchpod-cli/flash_self.go +++ b/cmd/benchpod-cli/flash_self.go @@ -103,8 +103,8 @@ func newFlashSelfCmd(g *globalFlags) *cobra.Command { f := &flashSelfFlags{} cmd := &cobra.Command{ Use: "flash-self [firmware.bin | URL]", - Short: "Flash the bench pod's own firmware over USB DFU (STM32, no SWD probe)", - Long: "Flash the bench pod's OWN firmware over USB using its ROM DFU bootloader\n" + + Short: "Flash the pod's own firmware over USB DFU (STM32, no SWD probe)", + Long: "Flash the pod's OWN firmware over USB using its ROM DFU bootloader\n" + "(via dfu-util) — the STM32 counterpart of the RP2350 drag-and-drop UF2 flow.\n" + "This is NOT the same as `flash`, which programs a target wired to the pod.\n\n" + "With no argument the latest prebuilt firmware is fetched automatically from\n" + @@ -118,6 +118,9 @@ func newFlashSelfCmd(g *globalFlags) *cobra.Command { "it in automatically).", Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { + if _, err := g.usbDevice("flash-self"); err != nil { + return err + } bin := strings.TrimSpace(f.dfuUtil) if bin == "" { bin = "dfu-util" @@ -349,7 +352,7 @@ func ensureDfuReady(ctx context.Context, g *globalFlags, f *flashSelfFlags, dfuP // printDfuInstructions tells a first-time user how to get the pod into DFU mode. func printDfuInstructions() { fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "The bench pod isn't in DFU mode yet. To enter it:") + fmt.Fprintln(os.Stderr, "The pod isn't in DFU mode yet. To enter it:") fmt.Fprintln(os.Stderr, " • blank board / first flash: hold the BOOT0 button while you press RESET") fmt.Fprintln(os.Stderr, " (or power-cycle the board), then release BOOT0.") fmt.Fprintln(os.Stderr, " • a pod already running firmware: run `benchpod dfu` in another terminal,") diff --git a/cmd/benchpod-cli/helpers.go b/cmd/benchpod-cli/helpers.go index 605b152..5e59f3f 100644 --- a/cmd/benchpod-cli/helpers.go +++ b/cmd/benchpod-cli/helpers.go @@ -50,8 +50,8 @@ func (g *globalFlags) rawConnection() (string, error) { } // resolveConnection classifies the effective connection (flag or stored default) -// into a ConnSpec. Used by the firmware commands and flash; errors when no -// connection is set or the value is unusable. +// into a ConnSpec for a command that only works over the network or USB; errors when +// no connection is set, the value is unusable, or it names a pod on embeddedci.com. func (g *globalFlags) resolveConnection() (ConnSpec, error) { raw, err := g.rawConnection() if err != nil { @@ -71,11 +71,11 @@ func (g *globalFlags) resolveTarget() (ConnSpec, error) { return parseTarget(raw) } -// serialDevice resolves the serial device for the always-serial commands +// serialDevice resolves the serial device for the always-USB commands // (set/show/clear-wifi, bootsel). It honors an explicit device path from the // connection (flag or stored default) and otherwise auto-detects (""). It never -// errors: provisioning must work before any default exists, and a stored wifi -// address simply falls through to USB auto-detection. +// errors: provisioning must work before any default exists, and a stored network +// address or embeddedci: name simply falls through to USB auto-detection. func (g *globalFlags) serialDevice() string { raw, err := g.rawConnection() if err != nil || raw == "" { @@ -87,19 +87,33 @@ func (g *globalFlags) serialDevice() string { return "" } -// ── shared firmware-command setup (wifi/TCP path) ─────────────────────────── +// usbDevice is serialDevice for a command that only ever uses the pod's USB console, refusing an +// explicit (--connection or BENCHPOD_CONNECTION) embeddedci: target with a clear message instead +// of quietly probing USB. A saved embeddedci: default still falls through to USB auto-detection, +// like a saved network address does. +func (g *globalFlags) usbDevice(cmd string) (string, error) { + if raw := strings.TrimSpace(g.connection); raw != "" { + if spec, err := parseTarget(raw); err == nil && spec.IsCloud() { + return "", fmt.Errorf("%s works over the pod's USB console only, not over embeddedci.com (--connection %s%s). "+ + "Plug the pod in over USB and pass --connection usb (or leave --connection out)", cmd, cloudConnPrefix, spec.Name) + } + } + return g.serialDevice(), nil +} + +// ── shared firmware-command setup (network/TCP path) ──────────────────────── -// wifiClient resolves the connection, requires the wifi transport (returning the -// standard "not available over USB" error for the named command otherwise), -// and returns a ready tcpclient plus a deadline context (overridable by -// --timeout) with signal handling. It replaces the old RequireWifi + setupClient -// pair at every TCP command's call site. -func (g *globalFlags) wifiClient(cmdName string, def time.Duration) (context.Context, context.CancelFunc, *tcpclient.Client, error) { - spec, err := g.resolveConnection() +// networkClient resolves the connection, requires the network transport (returning the standard +// "not available over USB" or "not over embeddedci.com" error for the named command otherwise), +// and returns a ready tcpclient plus a deadline context (overridable by --timeout) with signal +// 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() if err != nil { return nil, func() {}, nil, err } - if err := spec.RequireWifi(cmdName); err != nil { + if err := spec.RequireNetwork(cmdName); err != nil { return nil, func() {}, nil, err } ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(def)) diff --git a/cmd/benchpod-cli/identity.go b/cmd/benchpod-cli/identity.go index d745997..093d363 100644 --- a/cmd/benchpod-cli/identity.go +++ b/cmd/benchpod-cli/identity.go @@ -60,8 +60,8 @@ func newIdentityCmd(g *globalFlags) *cobra.Command { } root := &cobra.Command{ Use: "identity [show]", - Short: "Show the pod's device identity, or wipe it (USB console only)", - Long: "Show the pod's device identity (its Ed25519 key, the source of its name\n" + + Short: "Show the pod's identity, or wipe it (USB console only)", + Long: "Show the pod's identity (its Ed25519 key, the source of its name\n" + "benchpod-a1b2c3) over the USB console.\n\n" + "A pod whose identity record the firmware does not recognize stays offline rather\n" + "than replace a key it may be registered with; `benchpod identity show` then says\n" + @@ -71,7 +71,7 @@ func newIdentityCmd(g *globalFlags) *cobra.Command { } root.AddCommand(&cobra.Command{ Use: "show", - Short: "Show the pod's device identity", + Short: "Show the pod's identity", Args: cobra.NoArgs, RunE: show, }) @@ -79,8 +79,8 @@ func newIdentityCmd(g *globalFlags) *cobra.Command { var yes bool wipe := &cobra.Command{ Use: "wipe", - Short: "Erase the pod's device key and make a new one (USB console only)", - Long: "Erase the pod's device identity and generate a fresh key, over the pod's USB\n" + + Short: "Erase the pod's identity key and make a new one (USB console only)", + Long: "Erase the pod's identity and generate a fresh key, over the pod's USB\n" + "console (--connection usb or a serial device path). The pod refuses this over the\n" + "LAN and the cloud: it needs someone at the pod.\n\n" + "Use it to recover a pod that reports an unknown identity record, or to retire a\n" + @@ -113,7 +113,7 @@ func identityDevice(g *globalFlags, what string) (string, error) { if raw == "" { return "", fmt.Errorf("%s: needs the pod's USB console; pass --connection usb (or a serial device path)", what) } - spec, err := classifyConnection(raw) + spec, err := parseTarget(raw) if err != nil { return "", err } @@ -185,7 +185,7 @@ func runIdentityWipe(g *globalFlags, yes bool, in io.Reader, out io.Writer) erro } fmt.Fprintf(out, "Identity of the pod on %s: %s\n", path, describeIdentity(st)) if !yes { - fmt.Fprintln(out, "This erases the pod's device key for good and makes a new one. To embeddedci.com") + fmt.Fprintln(out, "This erases the pod's identity key for good and makes a new one. To embeddedci.com") fmt.Fprintln(out, "the pod is then a new pod that has to be registered again.") fmt.Fprint(out, "Wipe the identity? [y/N] ") line, _ := bufio.NewReader(in).ReadString('\n') diff --git a/cmd/benchpod-cli/lavoltage.go b/cmd/benchpod-cli/lavoltage.go index 6f8eca9..b351742 100644 --- a/cmd/benchpod-cli/lavoltage.go +++ b/cmd/benchpod-cli/lavoltage.go @@ -33,7 +33,8 @@ func newLAVoltageCmd(g *globalFlags) *cobra.Command { "I2C-sensor emulation until it is set. 1.8V needs a v3 pod, and the pod refuses a\n" + "change while any LA pin is in use.\n\n" + "VOLTAGE is 1.8V or 3.3V (also 1800mV, 3.3, or 3300). Omit it to show the\n" + - "current setting. Works over the network and over USB (--connection usb).\n\n" + + "current setting. Works over the network, over USB (--connection usb) and through\n" + + "embeddedci.com (--connection embeddedci:).\n\n" + "The pod forgets the voltage on a restart. When this machine is signed in\n" + "(`benchpod login`) and the pod is registered to that account, setting it over\n" + "the network or embeddedci.com also saves it to the pod's wiring profile on\n" + @@ -116,28 +117,19 @@ func runLAVoltage(g *globalFlags, mv int, out, warn io.Writer) error { // account, so its wiring profile can be written; "" otherwise (also over USB, and whenever it // cannot tell). It never prompts or fails. A variable so tests can replace it. var accountDeviceForConnection = func(g *globalFlags) string { - if who, _ := currentSession("", ""); who == "" { - return "" - } - spec, err := g.resolveConnection() + spec, err := g.resolveTarget() if err != nil || spec.IsSerial() { return "" } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - raw, err := (&tcpclient.Client{Addr: spec.Addr}).Command(ctx, map[string]any{"cmd": "cloud_status"}) + api := serverapi.New(cloudServerURL()) + cred, err := cloudCredential(ctx, api) if err != nil { return "" } - var c cloudState - if json.Unmarshal(raw, &c) != nil || !c.Configured { - return "" - } - id := strings.TrimSpace(c.DeviceID) - if id == "" { - return "" - } - if _, ok := accountDevices()[id]; !ok { + id, err := cloudDeviceID(ctx, api, cred, spec) + if err != nil { return "" } return id @@ -147,25 +139,22 @@ var accountDeviceForConnection = func(g *globalFlags) string { // on embeddedci.com (read-modify-write: every other field is kept). changed is false when the // profile already had it. A variable so tests can replace it. var saveLaMVToProfile = func(deviceID string, mv int) (changed bool, err error) { - tokenPath, err := resolveTokenPath("") - if err != nil { - return false, err - } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - api := serverapi.New(defaultServerURL) - tokens, err := ensureTokens(ctx, api, tokenPath) + api := serverapi.New(cloudServerURL()) + cred, err := cloudCredential(ctx, api) if err != nil { return false, fmt.Errorf("sign in: %w", err) } - return api.SetDeviceWiringLaMV(ctx, tokens.AccessToken, deviceID, mv) + return api.SetDeviceWiringLaMV(ctx, cred, deviceID, mv) } -// laVoltageDo shows (mv 0) or sets the LA voltage over the effective connection, network or -// USB, and returns a label for the pod plus its reply. Shared by la voltage, status and setup. +// laVoltageDo shows (mv 0) or sets the LA voltage over the effective connection (network, USB +// or embeddedci.com) and returns a label for the pod plus its reply. Shared by la voltage, status +// and setup. func laVoltageDo(g *globalFlags, mv int) (string, laVoltageReply, error) { what := "la voltage" - spec, err := g.resolveConnection() + spec, err := g.resolveTarget() if err != nil { return "", laVoltageReply{}, err } @@ -183,18 +172,20 @@ func laVoltageDo(g *globalFlags, mv int) (string, laVoltageReply, error) { } return "the pod on " + path, rep, nil } - ctx, cancel, client, err := g.wifiClient(what, 15*time.Second) + ctx, cancel, pod, err := g.podClient(what, 15*time.Second) if err != nil { return "", rep, err } defer cancel() - if rep, err = laVoltageLAN(ctx, client, mv); err != nil { + if rep, err = laVoltageLAN(ctx, pod.client, mv); err != nil { return "", rep, laVoltageError(what, err) } - return spec.Addr, rep, nil + return pod.label, rep, nil } -func laVoltageLAN(ctx context.Context, client *tcpclient.Client, mv int) (laVoltageReply, error) { +// laVoltageLAN runs la_voltage as a JSON command, on the pod's network port or through +// embeddedci.com. +func laVoltageLAN(ctx context.Context, client podCommander, mv int) (laVoltageReply, error) { req := map[string]any{"cmd": "la_voltage"} if mv != 0 { req["mv"] = mv diff --git a/cmd/benchpod-cli/main.go b/cmd/benchpod-cli/main.go index a20c6c1..3e59a6d 100644 --- a/cmd/benchpod-cli/main.go +++ b/cmd/benchpod-cli/main.go @@ -1,21 +1,20 @@ -// Command benchpod-cli is the EmbeddedCI bench pod CLI. It talks to a bench pod -// either over its TCP/JSON API (port 8080) or over its USB CDC-ACM serial -// console. The global --connection flag is the single place that says where and -// how to connect, and the transport is inferred from its value: +// Command benchpod-cli is the EmbeddedCI BenchPod CLI. It talks to a pod over its +// network TCP/JSON API (port 8080), over its USB console, or through embeddedci.com. +// The global --connection flag is the single place that says where and how to +// connect, and the transport is inferred from its value: // -// --connection 192.168.1.5[:8080] TCP/JSON API (an address ⇒ wifi). -// --connection /dev/tty... | COM3 USB serial console, explicit device. -// --connection usb USB serial console, auto-detected. +// --connection 192.168.1.5[:8080] network TCP/JSON API. +// --connection /dev/tty... | COM3 USB console, explicit device. +// --connection usb USB console, auto-detected ("serial" is a legacy alias). +// --connection embeddedci: through embeddedci.com (single-reply JSON commands). // (omitted) the default saved by `benchpod set-connection`. // // The firmware itself is unauthenticated; `login` authenticates with -// embeddedci-server (device-login flow) for register, deregister and the cloud -// policy commands. Direct firmware commands do not send tokens. +// embeddedci-server (device-login flow) for register, deregister, the cloud +// policy commands and embeddedci: connections (BENCHPOD_API_KEY also works for +// those). Direct firmware commands on the network or USB do not send tokens. // -// Over USB, status, la voltage, flash (SWD) and the policy/cloud-link settings -// work; set-wifi, show-network, clear-wifi, identity, install-blobs, bootsel and -// dfu always use the USB console. The other commands need the network. The root -// command's help is the user-facing version of this. +// The root command's help says which command works over which connection. package main import "os" diff --git a/cmd/benchpod-cli/podclient.go b/cmd/benchpod-cli/podclient.go new file mode 100644 index 0000000..4e0761d --- /dev/null +++ b/cmd/benchpod-cli/podclient.go @@ -0,0 +1,248 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/cloudpod" + "github.com/embeddedci-com/benchpod-cli/internal/serverapi" + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" +) + +// ── one pod, two ways in ──────────────────────────────────────────────────── +// +// A single-reply JSON command ({"cmd":"status"}) reaches the pod either on its LAN port +// (tcpclient.Client) or through embeddedci.com (cloudpod.Client, POST .../command). Both have the +// same Command method, so the commands below are written once against podCommander and the +// connection decides the way in. + +// podCommander sends one JSON command to the pod and returns its reply data. A refusal by the pod +// is a *tcpclient.PodError either way. +type podCommander interface { + Command(ctx context.Context, req map[string]any) (json.RawMessage, error) +} + +var ( + _ podCommander = (*tcpclient.Client)(nil) + _ podCommander = (*cloudpod.Client)(nil) +) + +// podTarget is a resolved connection for single-reply commands. +type podTarget struct { + spec ConnSpec + client podCommander + label string // how messages name the pod: "192.168.1.5:8080" or "benchpod-a1b2c3 on embeddedci.com" + deviceID string // the pod's id on embeddedci.com when the connection goes through it, else "" +} + +// 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. +func (g *globalFlags) podClient(cmdName string, def time.Duration) (context.Context, context.CancelFunc, *podTarget, error) { + spec, err := g.resolveTarget() + 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) + if err != nil { + cancel() + return nil, func() {}, nil, fmt.Errorf("%s: %w", cmdName, err) + } + return ctx, cancel, t, nil +} + +// openPodTarget builds the client for a network or cloud ConnSpec. +func openPodTarget(ctx context.Context, spec ConnSpec) (*podTarget, error) { + if spec.IsCloud() { + c, err := resolveCloudPod(ctx, spec.Name) + if err != nil { + return nil, err + } + return &podTarget{spec: spec, client: c, label: c.Label(), deviceID: c.DeviceID}, nil + } + return &podTarget{spec: spec, client: &tcpclient.Client{Addr: spec.Addr}, label: spec.Addr}, nil +} + +// cloudServerURL is the embeddedci.com base URL the cloud connection uses: BENCHPOD_API_BASE (the +// same variable the Python SDK reads), else www.embeddedci.com. +func cloudServerURL() string { + if v := strings.TrimSpace(os.Getenv("BENCHPOD_API_BASE")); v != "" { + return strings.TrimRight(v, "/") + } + return defaultServerURL +} + +// errNoCloudCredential is a missing sign-in for a cloud call. +var errNoCloudCredential = errors.New("embeddedci.com needs a sign-in: run `benchpod login` (or set BENCHPOD_API_KEY)") + +// cloudCredential returns the credential for embeddedci.com: BENCHPOD_API_KEY when set (sent as +// "ApiKey eci_..."), else the `benchpod login` session, refreshed when needed. It never prompts. +func cloudCredential(ctx context.Context, api *serverapi.Client) (string, error) { + if key := strings.TrimSpace(os.Getenv("BENCHPOD_API_KEY")); key != "" { + return "ApiKey " + key, nil + } + tokenPath, err := resolveTokenPath("") + if err != nil { + return "", err + } + if _, err := os.Stat(tokenPath); err != nil { + return "", errNoCloudCredential + } + tokens, err := ensureTokens(ctx, api, tokenPath) + if err != nil { + return "", err + } + return tokens.AccessToken, nil +} + +// credentialFor is the credential for a command with its own --token-file flag: an explicit +// --token-file means that session; otherwise BENCHPOD_API_KEY when set, else the session at +// tokenPath. It logs who is signed in. +func credentialFor(ctx context.Context, api *serverapi.Client, tokenFlag, tokenPath string) (string, error) { + if key := strings.TrimSpace(os.Getenv("BENCHPOD_API_KEY")); key != "" && strings.TrimSpace(tokenFlag) == "" { + log.Printf("auth: BENCHPOD_API_KEY") + return "ApiKey " + key, nil + } + tokens, err := ensureTokens(ctx, api, tokenPath) + if err != nil { + return "", err + } + log.Printf("auth: signed in as %s", tokens.Who()) + return tokens.AccessToken, nil +} + +// resolveCloudPod finds the pod named name on the account and returns a client for it. +func resolveCloudPod(ctx context.Context, name string) (*cloudpod.Client, error) { + api := serverapi.New(cloudServerURL()) + cred, err := cloudCredential(ctx, api) + if err != nil { + return nil, err + } + return cloudpod.Resolve(ctx, api, cred, name) +} + +// ── one wiring profile ────────────────────────────────────────────────────── +// +// The pod's wiring profile lives on embeddedci.com (the web app's Wiring tab, the SDK and MCP +// read it). Commands that take pins default the ones left out from it when this machine can read +// it: signed in (or BENCHPOD_API_KEY) and the pod on that account. Flags always win; without a +// profile the command behaves as before (the flags are required). + +// podWiring is the part of the wiring profile the CLI defaults flags from. Pins are LA channel +// numbers; nil is "not wired". +type podWiring struct { + SwdSwclk *int `json:"swd_swclk"` + SwdSwdio *int `json:"swd_swdio"` + SwdNreset *bool `json:"swd_nreset"` + SwdTarget string `json:"swd_target"` + SpiSclk *int `json:"spi_sclk"` + SpiMosi *int `json:"spi_mosi"` + SpiMiso *int `json:"spi_miso"` + SpiCs *int `json:"spi_cs"` +} + +// wiringSource names where defaulted pins came from, in log lines. +const wiringSource = "the pod's wiring profile on embeddedci.com" + +// wiringLookupTimeout bounds the whole profile lookup, so a slow server never holds up a command +// that has its flags anyway. +const wiringLookupTimeout = 6 * time.Second + +// loadPodWiring returns the wiring profile of the pod behind g's connection, or nil when there is +// none to use: over USB, without a sign-in, for a pod not on the account, or when the lookup +// 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() { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), wiringLookupTimeout) + defer cancel() + api := serverapi.New(cloudServerURL()) + cred, err := cloudCredential(ctx, api) + if err != nil { + if !errors.Is(err, errNoCloudCredential) { + fmt.Fprintf(warn, "Warning: could not read the pod's wiring profile from embeddedci.com (%v); using the flags only\n", err) + } + return nil + } + id, err := cloudDeviceID(ctx, api, cred, spec) + if err != nil { + fmt.Fprintf(warn, "Warning: could not read the pod's wiring profile from embeddedci.com (%v); using the flags only\n", err) + return nil + } + if id == "" { + log.Printf("wiring: the pod is not on the signed-in account; using the flags only") + return nil + } + raw, err := api.GetDeviceWiringProfile(ctx, cred, id) + var apiErr *serverapi.APIError + if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound { + raw, err = api.GetDeviceWiring(ctx, cred, id) // a server without the profile route + } + if err != nil { + fmt.Fprintf(warn, "Warning: could not read the pod's wiring profile from embeddedci.com (%v); using the flags only\n", err) + return nil + } + b, _ := json.Marshal(raw) + var w podWiring + if err := json.Unmarshal(b, &w); err != nil { + fmt.Fprintf(warn, "Warning: the pod's wiring profile on embeddedci.com does not parse (%v); using the flags only\n", err) + return nil + } + return &w +} + +// cloudDeviceID is the pod's id on embeddedci.com when it is on the credential's account, else +// "". A cloud connection names it; a network one asks the pod (cloud_status) and checks the +// account's device list. +func cloudDeviceID(ctx context.Context, api *serverapi.Client, cred string, spec ConnSpec) (string, error) { + if spec.IsCloud() { + c, err := cloudpod.Resolve(ctx, api, cred, spec.Name) + if err != nil { + return "", err + } + return c.DeviceID, nil + } + raw, err := (&tcpclient.Client{Addr: spec.Addr, DialTimeout: 2 * time.Second}).Command(ctx, map[string]any{"cmd": "cloud_status"}) + if err != nil { + return "", nil // the command itself reports an unreachable pod + } + var c cloudState + if json.Unmarshal(raw, &c) != nil || !c.Configured || strings.TrimSpace(c.DeviceID) == "" { + return "", nil + } + devices, err := api.ListDevices(ctx, cred) + if err != nil { + return "", err + } + for _, d := range devices { + if d.ID == strings.TrimSpace(c.DeviceID) { + return d.ID, nil + } + } + return "", nil +} + +// pinFromWiring fills *flagVal (an LA pin flag, "" = not given) from the profile pin when the +// flag was left out. It reports whether it did. +func pinFromWiring(flagVal *string, pin *int) bool { + if strings.TrimSpace(*flagVal) != "" || pin == nil || *pin < 1 || *pin > laPinCount { + return false + } + *flagVal = fmt.Sprint(*pin) + return true +} diff --git a/cmd/benchpod-cli/podclient_test.go b/cmd/benchpod-cli/podclient_test.go new file mode 100644 index 0000000..639209c --- /dev/null +++ b/cmd/benchpod-cli/podclient_test.go @@ -0,0 +1,401 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/embeddedci-com/benchpod-cli/internal/benchpodconfig" +) + +// TestMain keeps every test off the real embeddedci.com session and config: the CLI's files go +// to a temporary XDG_CONFIG_HOME and no API key or server override leaks in from the shell. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "benchpod-cli-test-") + if err != nil { + panic(err) + } + os.Setenv("XDG_CONFIG_HOME", dir) + for _, k := range []string{"BENCHPOD_API_KEY", "BENCHPOD_API_BASE", "BENCHPOD_CONNECTION", "BENCHPOD_CONFIG_FILE"} { + os.Unsetenv(k) + } + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +// fakeCloud is embeddedci.com for one pod, "bench-1" (id "id-1"): the device list, the command +// route (answered by reply) and the wiring routes. +type fakeCloud struct { + mu sync.Mutex + auth []string + commands []map[string]any + reply func(cmd map[string]any) (status int, body string) + profile string // the effective profile; "" = no profile route (404) + stored string // GET .../wiring +} + +func (f *fakeCloud) start(t *testing.T) string { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.auth = append(f.auth, r.Header.Get("Authorization")) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/benchpod/devices": + fmt.Fprint(w, `{"devices":[{"id":"id-1","name":"bench-1"}]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/benchpod/devices/id-1/command": + var body struct { + Command map[string]any `json:"command"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + f.commands = append(f.commands, body.Command) + status, out := f.reply(body.Command) + w.WriteHeader(status) + fmt.Fprint(w, out) + case r.Method == http.MethodGet && r.URL.Path == "/api/benchpod/devices/id-1/wiring/profile" && f.profile != "": + fmt.Fprintf(w, `{"version":1,"profile":%s}`, f.profile) + case r.Method == http.MethodGet && r.URL.Path == "/api/benchpod/devices/id-1/wiring" && f.stored != "": + fmt.Fprint(w, f.stored) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(ts.Close) + t.Setenv("BENCHPOD_API_BASE", ts.URL) + t.Setenv("BENCHPOD_API_KEY", "eci_test") + return ts.URL +} + +func (f *fakeCloud) sent() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, c := range f.commands { + b, _ := json.Marshal(c) + out = append(out, string(b)) + } + return out +} + +// podReplies answers the command route like a pod with these replies per cmd. +func podReplies(replies map[string]string) func(map[string]any) (int, string) { + return func(cmd map[string]any) (int, string) { + name, _ := cmd["cmd"].(string) + if r, ok := replies[name]; ok { + return http.StatusOK, `{"status":"ok","data":` + r + `}` + } + return http.StatusOK, `{"status":"error","error":"unknown cmd"}` + } +} + +// runOut runs the CLI with args, sending command output to a file, and returns the exit code +// and that output. +func runOut(t *testing.T, args ...string) (int, string) { + t.Helper() + out := filepath.Join(t.TempDir(), "out.txt") + code := run(append(args, "--output-filename", out)) + b, _ := os.ReadFile(out) + return code, string(b) +} + +func TestCloudPingStatusAndLAVoltage(t *testing.T) { + old := latestFirmwareRelease + latestFirmwareRelease = func() string { return "" } + t.Cleanup(func() { latestFirmwareRelease = old }) + + f := &fakeCloud{reply: podReplies(map[string]string{ + "ping": `"pong"`, + "status": `{"version":"3.8.0","ip":"192.168.1.220","net":"eth","la_vccio_mv":3300,"gateware":48}`, + "cloud_status": `{"state":"connected","configured":true,"device_id":"id-1"}`, + "target_status": `{"efuse1":{"enabled":1,"fault":0},"efuse2":{"enabled":0,"fault":0}}`, + "la_voltage": `{"mv":3300,"readback_mv":3300}`, + "la": `{"pullups":0}`, + })} + f.start(t) + conn := "embeddedci:bench-1" + + if code, out := runOut(t, "ping", "--connection", conn); code != exitOK || strings.TrimSpace(out) != "pong" { + t.Fatalf("ping: exit %d, %q", code, out) + } + code, out := runOut(t, "status", "--connection", conn) + if code != exitOK { + t.Fatalf("status: exit %d", code) + } + for _, want := range []string{"BenchPod bench-1 on embeddedci.com", "3.8.0", "3.3 V", "internal 5 V on", "on your account as bench-1"} { + if !strings.Contains(out, want) { + t.Errorf("status output lacks %q:\n%s", want, out) + } + } + if code, out := runOut(t, "la", "voltage", "--connection", conn); code != exitOK || !strings.Contains(out, "LA voltage of bench-1 on embeddedci.com: 3.3 V") { + t.Errorf("la voltage: exit %d, %q", code, out) + } + if code, _ := runOut(t, "la", "status", "--connection", conn); code != exitOK { + t.Errorf("la status: exit %d", code) + } + // The saved default and BENCHPOD_CONNECTION take the cloud form too. + t.Setenv("BENCHPOD_CONNECTION", conn) + if code, out := runOut(t, "ping"); code != exitOK || strings.TrimSpace(out) != "pong" { + t.Errorf("ping via BENCHPOD_CONNECTION: exit %d, %q", code, out) + } + for _, a := range f.auth { + if a != "ApiKey eci_test" { + t.Errorf("auth header %q, want the API key", a) + } + } + sent := strings.Join(f.sent(), " ") + for _, want := range []string{`{"cmd":"ping"}`, `{"cmd":"status"}`, `{"cmd":"cloud_status"}`, `{"cmd":"la_voltage"}`} { + if !strings.Contains(sent, want) { + t.Errorf("commands sent %s, want %s", sent, want) + } + } +} + +func TestSetConnectionSavesTheCloudForm(t *testing.T) { + cfg := filepath.Join(t.TempDir(), "config.json") + if code := run([]string{"set-connection", "embeddedci:bench-1", "--config-file", cfg}); code != exitOK { + t.Fatalf("exit %d", code) + } + c, err := benchpodconfig.Load(cfg) + if err != nil || c.Connection != "embeddedci:bench-1" || c.Version != benchpodconfig.Version { + t.Fatalf("saved %+v, %v", c, err) + } +} + +func TestCloudExitCodes(t *testing.T) { + for _, c := range []struct { + status int + body string + want int + }{ + {409, `{"error":"lease held","code":"lease_held"}`, exitBusy}, + {503, `{"error":"offline","code":"device_offline"}`, exitUnreachable}, + {403, `{"error":"no","code":"forbidden_tier"}`, exitRefused}, + {200, `{"status":"error","error":"locked: nope"}`, exitRefused}, + {504, `{"error":"timeout","code":"device_timeout"}`, exitError}, + } { + f := &fakeCloud{reply: func(map[string]any) (int, string) { return c.status, c.body }} + f.start(t) + if code, _ := runOut(t, "ping", "--connection", "embeddedci:bench-1"); code != c.want { + t.Errorf("%d %s: exit %d, want %d", c.status, c.body, code, c.want) + } + } +} + +func TestCloudNeedsASignIn(t *testing.T) { + t.Setenv("BENCHPOD_API_KEY", "") + _, _, _, err := (&globalFlags{connection: "embeddedci:bench-1"}).podClient("ping", 0) + if err == nil || !strings.Contains(err.Error(), "benchpod login") || !strings.Contains(err.Error(), "BENCHPOD_API_KEY") { + t.Fatalf("err = %v", err) + } +} + +// Commands that need the pod's own port, a stream or USB refuse a cloud connection up front, and +// say what to use instead. None of them may reach the server. +func TestCloudRefusals(t *testing.T) { + f := &fakeCloud{reply: func(map[string]any) (int, string) { return 500, "{}" }} + f.start(t) + conn := "embeddedci:bench-1" + for _, args := range [][]string{ + {"capture"}, {"stream"}, {"test"}, {"measure", "--waveform", "sine"}, + {"flash", "--swclk", "1", "--swdio", "2", "--target", "target/x.cfg"}, + {"spi-flash", "id", "--sck", "1", "--mosi", "2", "--miso", "3", "--cs", "4"}, + {"register"}, + {"cloud", "ca", "set", "testdata-missing.pem"}, + } { + g := &globalFlags{connection: conn} + var err error + switch args[0] { + case "flash": + err = runFlash(g, &flashFlags{swclk: "1", swdio: "2", target: "target/x.cfg"}) + case "cloud": + err = runCloudCA(g, "set", "", nil, &bytes.Buffer{}) + default: + code := run(append(args, "--connection", conn)) + if code == exitOK { + t.Errorf("%v: exit 0 over embeddedci.com", args) + } + _, _, _, err = g.networkClient(args[0], 0) + } + if err == nil || !strings.Contains(err.Error(), "embeddedci.com") { + t.Errorf("%v: err = %v, want a refusal naming embeddedci.com", args, err) + } + } + for _, cmd := range []string{"set-wifi", "show-network", "clear-wifi", "install-blobs", "bootsel", "dfu", "flash-self"} { + _, err := (&globalFlags{connection: conn}).usbDevice(cmd) + if err == nil || !strings.Contains(err.Error(), "USB console only") || !strings.Contains(err.Error(), cmd) { + t.Errorf("%s: err = %v", cmd, err) + } + } + // A saved embeddedci: default does not stop the USB commands: they auto-detect, as with a + // saved network address. + cfg := filepath.Join(t.TempDir(), "config.json") + _ = benchpodconfig.Save(cfg, &benchpodconfig.Config{Connection: conn}) + if dev, err := (&globalFlags{configFile: cfg}).usbDevice("set-wifi"); err != nil || dev != "" { + t.Errorf("saved cloud default: %q, %v", dev, err) + } + if err := runDiscover(&globalFlags{connection: conn}, discoverOpts{usb: true}); err == nil || !strings.Contains(err.Error(), "benchpod status --connection embeddedci:bench-1") { + t.Errorf("discover: %v", err) + } + if len(f.sent()) != 0 { + t.Errorf("refused commands reached the server: %v", f.sent()) + } +} + +func TestCloudProxyAndCA(t *testing.T) { + f := &fakeCloud{reply: podReplies(map[string]string{ + "cloud_proxy": `{"host":"proxy.corp","port":3128,"auth":true}`, + "cloud_ca": `{"present":false,"certs":[]}`, + })} + f.start(t) + g := &globalFlags{connection: "embeddedci:bench-1"} + var out, warn bytes.Buffer + if err := runCloudProxy(g, "set", "proxy.corp:3128", "alice", "s3cret", &out, &warn); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "HTTP proxy of bench-1 on embeddedci.com is now proxy.corp:3128 (with a user and password)") { + t.Errorf("out = %q", out.String()) + } + if strings.Contains(warn.String(), "unencrypted") { + t.Errorf("warned about a plain LAN password over HTTPS: %q", warn.String()) + } + out.Reset() + if err := runCloudCA(g, "clear", "", &out, &warn); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Company CA of bench-1 on embeddedci.com is now none") { + t.Errorf("out = %q", out.String()) + } + sent := strings.Join(f.sent(), " ") + if !strings.Contains(sent, `"set":"proxy.corp:3128"`) || !strings.Contains(sent, `{"clear":true,"cmd":"cloud_ca"}`) { + t.Errorf("sent %s", sent) + } +} + +// ── wiring profile ────────────────────────────────────────────────────────── + +func TestWiringProfileOverCloud(t *testing.T) { + f := &fakeCloud{profile: `{"swd_swclk":11,"swd_swdio":12,"swd_nreset":true,"swd_target":"target/stm32f4x.cfg","spi_sclk":3,"spi_mosi":4,"spi_miso":5,"spi_cs":null}`} + f.start(t) + var warn bytes.Buffer + w := loadPodWiring(&globalFlags{connection: "embeddedci:bench-1"}, &warn) + if w == nil || *w.SwdSwclk != 11 || *w.SwdSwdio != 12 || !*w.SwdNreset || w.SwdTarget != "target/stm32f4x.cfg" || w.SpiCs != nil { + t.Fatalf("wiring %+v (warn %q)", w, warn.String()) + } + + // A server without the profile route: the stored map. + f2 := &fakeCloud{stored: `{"swd_swclk":7}`} + f2.start(t) + w = loadPodWiring(&globalFlags{connection: "embeddedci:bench-1"}, &warn) + if w == nil || *w.SwdSwclk != 7 || w.SwdSwdio != nil { + t.Fatalf("stored fallback %+v", w) + } + if warn.Len() != 0 { + t.Errorf("warned: %q", warn.String()) + } +} + +// Over the network the pod's cloud_status names its id, which must be on the account. +func TestWiringProfileOverNetwork(t *testing.T) { + f := &fakeCloud{profile: `{"swd_swclk":11,"swd_swdio":12,"swd_nreset":false}`} + f.start(t) + for id, want := range map[string]bool{"id-1": true, "id-other": false} { + addr := fakeCloudStatusPod(t, id) + var warn bytes.Buffer + w := loadPodWiring(&globalFlags{connection: addr}, &warn) + if (w != nil) != want { + t.Errorf("device %s: wiring %+v, want found=%v", id, w, want) + } + if warn.Len() != 0 { + t.Errorf("device %s: warned %q", id, warn.String()) + } + } + // Not signed in: no lookup, no warning. + t.Setenv("BENCHPOD_API_KEY", "") + var warn bytes.Buffer + if w := loadPodWiring(&globalFlags{connection: fakeCloudStatusPod(t, "id-1")}, &warn); w != nil || warn.Len() != 0 { + t.Errorf("signed out: %+v, %q", w, warn.String()) + } + // USB: never. + if w := loadPodWiring(&globalFlags{connection: "usb"}, &warn); w != nil { + t.Errorf("usb: %+v", w) + } +} + +func TestWiringProfileFailureWarnsOnce(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + t.Setenv("BENCHPOD_API_BASE", ts.URL) + t.Setenv("BENCHPOD_API_KEY", "eci_test") + var warn bytes.Buffer + if w := loadPodWiring(&globalFlags{connection: "embeddedci:bench-1"}, &warn); w != nil { + t.Fatalf("wiring %+v", w) + } + if n := strings.Count(warn.String(), "\n"); n != 1 || !strings.Contains(warn.String(), "using the flags only") { + t.Errorf("warn = %q", warn.String()) + } +} + +func TestFlashTakesSWDFromWiringFlagsWin(t *testing.T) { + old := loadPodWiring + t.Cleanup(func() { loadPodWiring = old }) + pin := func(n int) *int { return &n } + yes := true + loadPodWiring = func(g *globalFlags, _ io.Writer) *podWiring { + return &podWiring{SwdSwclk: pin(11), SwdSwdio: pin(12), SwdNreset: &yes, SwdTarget: "target/stm32f4x.cfg"} + } + + f := &flashFlags{} + f.fillFromWiring(&globalFlags{}) + if f.swclk != "11" || f.swdio != "12" || !f.nreset || f.target != "target/stm32f4x.cfg" { + t.Errorf("from wiring: %+v", f) + } + f = &flashFlags{swclk: "1", nreset: false, nresetSet: true, target: "target/stm32f1x.cfg"} + f.fillFromWiring(&globalFlags{}) + if f.swclk != "1" || f.swdio != "12" || f.nreset || f.target != "target/stm32f1x.cfg" { + t.Errorf("flags must win: %+v", f) + } + + s := &spiFlags{mosi: "9"} + loadPodWiring = func(g *globalFlags, _ io.Writer) *podWiring { + return &podWiring{SpiSclk: pin(3), SpiMosi: pin(4), SpiMiso: pin(5)} + } + s.fillFromWiring(&globalFlags{connection: "192.168.1.5"}) + if s.sck != "3" || s.mosi != "9" || s.miso != "5" || s.cs != "" { + t.Errorf("spi: %+v", s) + } +} + +// fakeCloudStatusPod is a LAN pod that answers cloud_status with this device id. +func fakeCloudStatusPod(t *testing.T, id string) string { + 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 { + conn, err := ln.Accept() + if err != nil { + return + } + _, _ = bufio.NewReader(conn).ReadString('\n') + fmt.Fprintf(conn, `{"status":"ok","data":{"state":"connected","configured":true,"device_id":%q}}`+"\n", id) + conn.Close() + } + }() + return ln.Addr().String() +} diff --git a/cmd/benchpod-cli/policy.go b/cmd/benchpod-cli/policy.go index a4bd4c4..f6e3d4a 100644 --- a/cmd/benchpod-cli/policy.go +++ b/cmd/benchpod-cli/policy.go @@ -240,14 +240,14 @@ func runPolicyCloud(g *globalFlags, p podPolicy, t policyTarget, set string, out defer installSignalHandler(ctx, cancel)() api := serverapi.New(t.serverURL) - tokens, err := ensureTokens(ctx, api, tokenPath) + cred, err := credentialFor(ctx, api, t.tokenFile, tokenPath) if err != nil { return fmt.Errorf("auth: %w", err) } id, label := t.deviceID, t.deviceID if id == "" { - devices, err := api.ListDevices(ctx, tokens.AccessToken) + devices, err := api.ListDevices(ctx, cred) if err != nil { return fmt.Errorf("list devices: %w", err) } @@ -258,7 +258,7 @@ func runPolicyCloud(g *globalFlags, p podPolicy, t policyTarget, set string, out id, label = dev.ID, dev.Name } - cur, err := api.GetPodPolicy(ctx, tokens.AccessToken, id, p.cmd) + cur, err := api.GetPodPolicy(ctx, cred, id, p.cmd) if err != nil { return cloudPolicyError(p, "", err) } @@ -270,7 +270,7 @@ func runPolicyCloud(g *globalFlags, p podPolicy, t policyTarget, set string, out return nil } - res, err := api.SetPodPolicy(ctx, tokens.AccessToken, id, p.cmd, set) + res, err := api.SetPodPolicy(ctx, cred, id, p.cmd, set) if err != nil { return cloudPolicyError(p, set, err) } diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index c6fea6c..93771a9 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -12,7 +12,7 @@ import ( // globalFlags holds the persistent (global) flags shared by every subcommand. // They are bound through Viper so BENCHPOD_* environment variables also work -// (e.g. BENCHPOD_CONNECTION=serial), with precedence flag > env > default. +// (e.g. BENCHPOD_CONNECTION=usb), with precedence flag > env > default. type globalFlags struct { connection string configFile string @@ -42,21 +42,26 @@ func newRootCmd() *cobra.Command { root := &cobra.Command{ Use: "benchpod", Version: version, - Short: "EmbeddedCI bench pod CLI", - Long: "EmbeddedCI bench pod CLI.\n\n" + + Short: "EmbeddedCI BenchPod CLI", + Long: "EmbeddedCI BenchPod CLI.\n\n" + "New pod? Run `benchpod setup`: it finds the pod, gets it on the network, sets\n" + "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 network (TCP/JSON) API; a\n" + - "device path (/dev/tty..., COM3) or `usb` uses the pod's USB console. Omit it to\n" + - "use the default saved by `benchpod discover --save` or `benchpod set-connection`.\n" + - "`embeddedci:` names a pod on embeddedci.com: lan-policy, sig-policy and\n" + - "deregister take it.\n\n" + - "Over the network or USB: status, la voltage, flash (SWD), lan-policy,\n" + - "sig-policy, cloud ca and cloud proxy. Always over the USB console: set-wifi,\n" + - "show-network, clear-wifi, identity, install-blobs, bootsel and dfu; flash-self\n" + - "reflashes the pod over USB DFU. Every other pod command needs the network and\n" + - "refuses a USB connection.\n\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" + + "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" + + "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" + + "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" + exitCodesHelp, SilenceUsage: true, SilenceErrors: true, @@ -72,7 +77,7 @@ func newRootCmd() *cobra.Command { pf := root.PersistentFlags() pf.StringVar(&g.connection, "connection", "", - `how to reach the pod: an address (192.168.1.5[:8080]), a device path (/dev/tty..., COM3), "usb" to auto-detect the pod over USB, or embeddedci: for a pod on embeddedci.com where a command supports it (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, or embeddedci: to go through embeddedci.com (default: the saved set-connection target)`) pf.StringVar(&g.configFile, "config-file", "", "path to config file") pf.StringVar(&g.outputFilename, "output-filename", "", "write command output to this file instead of stdout") pf.DurationVar(&g.timeout, "timeout", 0, "overall command deadline (0 = per-command default)") diff --git a/cmd/benchpod-cli/setup.go b/cmd/benchpod-cli/setup.go index eaabc6e..24372c5 100644 --- a/cmd/benchpod-cli/setup.go +++ b/cmd/benchpod-cli/setup.go @@ -515,7 +515,7 @@ func (e *realSetupEnv) find() ([]setupPod, error) { if err != nil { return nil, err } - if spec.IsWifi() { + if spec.IsNetwork() { dp := discoveredPod{addr: spec.Addr} checkPod(&dp) if !dp.reachable { diff --git a/cmd/benchpod-cli/spiflash.go b/cmd/benchpod-cli/spiflash.go index 57f7e8b..3a13397 100644 --- a/cmd/benchpod-cli/spiflash.go +++ b/cmd/benchpod-cli/spiflash.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log" "os" "strconv" "strings" @@ -31,7 +32,11 @@ func newSPIFlashCmd(g *globalFlags) *cobra.Command { Short: "Read, erase and program an SPI NOR flash on four LA pins (network)", Long: "Read, erase and program a 25-series SPI NOR flash (W25Q, MX25, GD25, IS25, ...)\n" + "wired to four LA pins, through the pod's SPI master. Needs gateware with the\n" + - "spi_master capability and the LA voltage set. 3-byte addresses: the first 16 MB.\n\n" + + "spi_master capability and the LA voltage set. 3-byte addresses: the first 16 MB.\n" + + "Network connection only: it does not work over USB or embeddedci.com.\n\n" + + "Pins left out come from the pod's wiring profile on embeddedci.com (spi_sclk,\n" + + "spi_mosi, spi_miso, spi_cs) when this machine is signed in (`benchpod login` or\n" + + "BENCHPOD_API_KEY) and the pod is on that account. Flags always win.\n\n" + "Pass --nreset when the DUT's reset is wired to the pod's reset pin (" + nrstPinLocation + "):\n" + "the DUT is held in reset for the whole run so its own controller stays off the bus,\n" + "and released at the end. The SPI pins are always released at the end, also on\n" + @@ -42,10 +47,10 @@ func newSPIFlashCmd(g *globalFlags) *cobra.Command { " benchpod spi-flash erase --chip --sck 3 --mosi 4 --miso 5 --cs 6", } pf := cmd.PersistentFlags() - pf.StringVar(&f.sck, "sck", "", "LA pin for SCK, 1-14 (required)") - pf.StringVar(&f.mosi, "mosi", "", "LA pin for MOSI, 1-14 (required)") - pf.StringVar(&f.miso, "miso", "", "LA pin for MISO, 1-14 (required)") - pf.StringVar(&f.cs, "cs", "", "LA pin for CS, 1-14 (required)") + pf.StringVar(&f.sck, "sck", "", "LA pin for SCK, 1-14 (default: spi_sclk from the pod's wiring profile)") + pf.StringVar(&f.mosi, "mosi", "", "LA pin for MOSI, 1-14 (default: spi_mosi from the pod's wiring profile)") + pf.StringVar(&f.miso, "miso", "", "LA pin for MISO, 1-14 (default: spi_miso from the pod's wiring profile)") + pf.StringVar(&f.cs, "cs", "", "LA pin for CS, 1-14 (default: spi_cs from the pod's wiring profile)") pf.IntVar(&f.hz, "hz", 1000000, "SCK rate in Hz; the pod uses the nearest rate at or below it (190 kHz to 6 MHz)") pf.IntVar(&f.mode, "mode", 0, "SPI mode, 0 or 3") pf.BoolVar(&f.nreset, "nreset", false, "hold the DUT in reset through the pod's reset pin while flashing") @@ -59,6 +64,35 @@ func newSPIFlashCmd(g *globalFlags) *cobra.Command { return cmd } +// fillFromWiring takes the SPI pins left out of the flags from the pod's wiring profile on +// embeddedci.com (spi_sclk, spi_mosi, spi_miso, spi_cs), when there is one to read. Flags win. +func (f *spiFlags) fillFromWiring(g *globalFlags) { + if f.sck != "" && f.mosi != "" && f.miso != "" && f.cs != "" { + log.Printf("spi-flash: pins from the flags") + return + } + if spec, err := g.resolveTarget(); err != nil || !spec.IsNetwork() { + return // the connection error comes from the command itself + } + w := loadPodWiring(g, os.Stderr) + if w == nil { + return + } + var from []string + for _, p := range []struct { + name string + flag *string + pin *int + }{{"SCK", &f.sck, w.SpiSclk}, {"MOSI", &f.mosi, w.SpiMosi}, {"MISO", &f.miso, w.SpiMiso}, {"CS", &f.cs, w.SpiCs}} { + if pinFromWiring(p.flag, p.pin) { + from = append(from, p.name+" LA"+*p.flag) + } + } + if len(from) > 0 { + log.Printf("spi-flash: %s from %s", strings.Join(from, ", "), wiringSource) + } +} + // config validates the pin flags into a spi_start request. func (f *spiFlags) config() (spiflash.Config, error) { var pins [4]int @@ -66,7 +100,7 @@ func (f *spiFlags) config() (spiflash.Config, error) { vals := [4]string{f.sck, f.mosi, f.miso, f.cs} for i, v := range vals { if strings.TrimSpace(v) == "" { - return spiflash.Config{}, fmt.Errorf("--sck, --mosi, --miso and --cs are required") + return spiflash.Config{}, fmt.Errorf("--sck, --mosi, --miso and --cs are required (or set spi_sclk, spi_mosi, spi_miso and spi_cs in the pod's wiring profile on embeddedci.com)") } n, err := parseLAPin(v) if err != nil { @@ -93,11 +127,12 @@ func (f *spiFlags) config() (spiflash.Config, error) { // cleanup (spi_stop, reset release) uses fresh ones so it works after Ctrl-C. func runSPI(g *globalFlags, f *spiFlags, name string, def time.Duration, fn func(ctx context.Context, p *spiflash.Pod, s spiflash.Started) error) error { + f.fillFromWiring(g) cfg, err := f.config() if err != nil { return err } - ctx, cancel, client, err := g.wifiClient("spi-flash", def) + ctx, cancel, client, err := g.networkClient("spi-flash", def) if err != nil { return err } diff --git a/cmd/benchpod-cli/status.go b/cmd/benchpod-cli/status.go index 1c65e0d..dd86931 100644 --- a/cmd/benchpod-cli/status.go +++ b/cmd/benchpod-cli/status.go @@ -11,8 +11,6 @@ import ( "time" "github.com/spf13/cobra" - - "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" ) // ── status ─────────────────────────────────────────────────────────────────── @@ -26,11 +24,12 @@ func newStatusCmd(g *globalFlags) *cobra.Command { "newer firmware release exists), the board I/O (LA) voltage, target power, the\n" + "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 only). Over USB\n" + - "(--connection usb) this prints the console's `status` text.", + "--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" + + "--connection embeddedci: it asks the pod through embeddedci.com.", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - spec, err := g.resolveConnection() + spec, err := g.resolveTarget() if err != nil { return err } @@ -38,13 +37,13 @@ func newStatusCmd(g *globalFlags) *cobra.Command { return runStatusSerial(g, spec.Device) } - ctx, cancel, client, err := g.wifiClient("status", 30*time.Second) + ctx, cancel, pod, err := g.podClient("status", 30*time.Second) if err != nil { return err } defer cancel() - data, err := client.Command(ctx, map[string]any{"cmd": "status"}) + data, err := pod.client.Command(ctx, map[string]any{"cmd": "status"}) if err != nil { return fmt.Errorf("status: %w", err) } @@ -62,8 +61,8 @@ func newStatusCmd(g *globalFlags) *cobra.Command { printJSON(out, data) // not the shape we know: show it as it is return nil } - sum := statusSummary{addr: spec.Addr, st: st} - gatherStatusExtras(ctx, client, &sum) + sum := statusSummary{addr: pod.label, st: st, cloudID: pod.deviceID} + gatherStatusExtras(ctx, pod.client, &sum) printStatusSummary(out, sum) return nil }, @@ -132,7 +131,8 @@ type targetPower struct { // statusSummary is everything the summary prints; the extras are optional (nil/"" = unknown). type statusSummary struct { - addr string + addr string // the pod's address, or " on embeddedci.com" + cloudID string // the pod's id on embeddedci.com when status went through it st podStatus cloud *cloudState power *targetPower @@ -142,7 +142,7 @@ type statusSummary struct { // gatherStatusExtras asks the pod for its registration and target power, and (cheaply, never // failing the command) whether it is on the signed-in account and the latest firmware release. -func gatherStatusExtras(ctx context.Context, client *tcpclient.Client, sum *statusSummary) { +func gatherStatusExtras(ctx context.Context, client podCommander, sum *statusSummary) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) if raw, err := client.Command(cctx, map[string]any{"cmd": "cloud_status"}); err == nil { var c cloudState @@ -160,7 +160,12 @@ func gatherStatusExtras(ctx context.Context, client *tcpclient.Client, sum *stat } } cancel() - if sum.cloud != nil && sum.cloud.Configured && strings.TrimSpace(sum.cloud.DeviceID) != "" { + switch { + case sum.cloudID != "" && sum.cloud != nil: + // Reached through embeddedci.com by name: the pod is on the account that asked. + name := strings.TrimSuffix(sum.addr, " on embeddedci.com") + sum.account = map[string]string{sum.cloudID: name, strings.TrimSpace(sum.cloud.DeviceID): name} + case sum.cloud != nil && sum.cloud.Configured && strings.TrimSpace(sum.cloud.DeviceID) != "": sum.account = accountDevices() } sum.latest = latestFirmwareRelease() @@ -171,7 +176,11 @@ func printStatusSummary(out io.Writer, s statusSummary) { st := s.st row := func(k, v string) { fmt.Fprintf(out, " %-13s %s\n", k, v) } - fmt.Fprintf(out, "BenchPod at %s\n", s.addr) + if s.cloudID != "" { + fmt.Fprintf(out, "BenchPod %s\n", s.addr) + } else { + fmt.Fprintf(out, "BenchPod at %s\n", s.addr) + } fw := valueOrDash(st.Version) if s.latest != "" && st.Version != "" { if versionLess(st.Version, s.latest) { diff --git a/internal/benchpodconfig/config.go b/internal/benchpodconfig/config.go index 89ab982..e2d7aeb 100644 --- a/internal/benchpodconfig/config.go +++ b/internal/benchpodconfig/config.go @@ -14,15 +14,23 @@ import ( // DefaultPort is the bench pod firmware's TCP port. const DefaultPort = "8080" -// Config holds CLI-level settings that persist between invocations. +// Version is the config file's format version, written to "version" on every save. Readers +// (the CLI, the Python SDK and the MCP server) treat a missing version as 1 and ignore keys they +// do not know; a change that old readers would misread bumps it. +const Version = 1 + +// Config holds CLI-level settings that persist between invocations. The file is shared: the +// Python SDK and the MCP server read Connection from it as their default pod (only the CLI +// writes it). // -// Connection is the default connection target (a TCP address, a serial device -// path, or the keyword "usb"); the transport is inferred from its shape by -// the CLI. BenchPodAddr is the legacy field that only ever held a TCP address; +// Connection is the default connection target (a network address, a serial device +// path, the keyword "usb", or embeddedci: for a pod on embeddedci.com); the +// transport is inferred from its shape. BenchPodAddr is the legacy field that only ever held a TCP address; // it is kept for backward-compatible reads and migrated into Connection by Load. // LastSerial caches the serial device path most recently auto-detected as a bench // pod, so the next auto-detect probes it first (it can save several port probes). type Config struct { + Version int `json:"version,omitempty"` Connection string `json:"connection,omitempty"` BenchPodAddr string `json:"bench_pod_addr,omitempty"` LastSerial string `json:"last_serial,omitempty"` @@ -78,7 +86,9 @@ func Save(path string, c *Config) error { if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) } - data, err := json.MarshalIndent(c, "", " ") + saved := *c + saved.Version = Version + data, err := json.MarshalIndent(&saved, "", " ") if err != nil { return fmt.Errorf("marshal config: %w", err) } diff --git a/internal/cloudpod/cloudpod.go b/internal/cloudpod/cloudpod.go new file mode 100644 index 0000000..54623bd --- /dev/null +++ b/internal/cloudpod/cloudpod.go @@ -0,0 +1,165 @@ +// Package cloudpod sends the pod's single-reply JSON commands through embeddedci.com instead of +// its LAN port: POST /api/benchpod/devices/{id}/command carries the same {"cmd": ...} map the pod +// takes over TCP, and the server relays it over the pod's own cloud link. Client has the same +// Command method as tcpclient.Client, so a command written against that method runs over either. +// +// Only single-reply commands go this way. Chunked replies (capture, stream, measure, test) and +// raw byte streams (SWD, SPI sessions, uploads) do not: the server refuses them on this channel. +package cloudpod + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serverapi" + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" +) + +// maxTimeout is the server's cap on how long it waits for the pod. +const maxTimeout = 120 * time.Second + +// Client is one pod on embeddedci.com. +type Client struct { + API *serverapi.Client + Credential string // a `benchpod login` access token, or "ApiKey eci_..." (see serverapi.Authorization) + DeviceID string // the pod's id on embeddedci.com + Name string // the pod's name on embeddedci.com, for messages +} + +// Resolve finds the pod named name (or with that id) among the devices the credential can see. +func Resolve(ctx context.Context, api *serverapi.Client, credential, name string) (*Client, error) { + name = strings.TrimSpace(name) + devices, err := api.ListDevices(ctx, credential) + if err != nil { + return nil, fmt.Errorf("list your pods on embeddedci.com: %w", authHint(err)) + } + for _, d := range devices { + if d.Name == name { + return &Client{API: api, Credential: credential, DeviceID: d.ID, Name: d.Name}, nil + } + } + for _, d := range devices { + if d.ID == name { + return &Client{API: api, Credential: credential, DeviceID: d.ID, Name: d.Name}, nil + } + } + names := make([]string, 0, len(devices)) + for _, d := range devices { + names = append(names, d.Name) + } + if len(names) == 0 { + return nil, fmt.Errorf("no pod named %q on embeddedci.com: this account has no pods", name) + } + return nil, fmt.Errorf("no pod named %q on embeddedci.com (this account has: %s)", name, strings.Join(names, ", ")) +} + +// Command sends req to the pod and returns its reply data. A refusal by the pod comes back as a +// *tcpclient.PodError, exactly like over the LAN; a failure on the way (offline, lease, timeout, +// role) as an *Error. +func (c *Client) Command(ctx context.Context, req map[string]any) (json.RawMessage, error) { + timeout := time.Duration(0) + if dl, ok := ctx.Deadline(); ok { + // Leave the HTTP round trip a moment, so the server's answer arrives before ctx expires. + timeout = time.Until(dl) - time.Second + if timeout < time.Second { + timeout = time.Second + } + if timeout > maxTimeout { + timeout = maxTimeout + } + } + resp, err := c.API.DeviceCommand(ctx, c.Credential, c.DeviceID, req, timeout) + if err != nil { + var apiErr *serverapi.APIError + if errors.As(err, &apiErr) { + return nil, &Error{Name: c.Name, Status: apiErr.Status, Code: apiErr.Code, Message: apiErr.Message} + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + return nil, fmt.Errorf("embeddedci.com: %w", err) + } + if strings.TrimSpace(resp.Status) == "error" { + msg := strings.TrimSpace(resp.Error) + if msg == "" { + msg = "pod returned an error" + } + return nil, &tcpclient.PodError{Reason: msg} + } + return resp.Data, nil +} + +// Label is how messages name the pod: "benchpod-a1b2c3 on embeddedci.com". +func (c *Client) Label() string { return c.Name + " on embeddedci.com" } + +// Error is embeddedci.com failing to get a command to the pod or its reply back. It is not a +// refusal by the pod. errors.Is(err, tcpclient.ErrUnreachable) holds when the pod is offline, and +// a held lease unwraps to a busy *tcpclient.PodError, so exit codes match the LAN's. +type Error struct { + Name string // the pod + Status int // HTTP status + Code string // server code: lease_held, device_offline, ... + Message string // server message +} + +func (e *Error) Error() string { + switch { + case e.Code == "lease_held": + return fmt.Sprintf("%s is in use: another session (the web app, a CI job or the SDK) holds its lease on embeddedci.com; try again when it ends", e.Name) + case e.Code == "device_busy": + return fmt.Sprintf("%s is busy with another operation (a flash, capture, terminal or firmware update); try again when it ends", e.Name) + case e.Code == "device_offline": + return fmt.Sprintf("%s is offline: it is not connected to embeddedci.com (check its network, or reach it on the LAN or over USB)", e.Name) + case e.Code == "device_reconnecting": + return fmt.Sprintf("%s is reconnecting to embeddedci.com; try again in a few seconds", e.Name) + case e.Code == "device_timeout" || e.Status == http.StatusGatewayTimeout: + return fmt.Sprintf("%s did not answer through embeddedci.com in time", e.Name) + case e.Code == "not_capable": + return fmt.Sprintf("%s cannot take commands through embeddedci.com: its firmware is too old; update it (web app or `benchpod flash-self`)", e.Name) + case e.Code == "forbidden_tier": + return "this change needs an organization owner or admin on embeddedci.com (API keys: the benchpod:admin scope)" + case e.Status == http.StatusUnauthorized: + return "embeddedci.com refused the sign-in; run `benchpod login` again (or check BENCHPOD_API_KEY)" + case e.Status == http.StatusForbidden || e.Status == http.StatusNotFound: + return fmt.Sprintf("%s is not on this account on embeddedci.com (%s)", e.Name, valueOr(e.Message, http.StatusText(e.Status))) + } + return fmt.Sprintf("embeddedci.com: %d %s", e.Status, valueOr(e.Message, http.StatusText(e.Status))) +} + +// Is makes an offline pod match tcpclient.ErrUnreachable. +func (e *Error) Is(target error) bool { + return target == tcpclient.ErrUnreachable && (e.Code == "device_offline" || e.Code == "device_reconnecting") +} + +// Unwrap gives a held lease or a busy pod as a busy refusal and a missing role as a forbidden +// one, so the CLI's exit code says busy (4) or refused (3) as it does on the LAN. +func (e *Error) Unwrap() error { + switch e.Code { + case "lease_held", "device_busy": + return &tcpclient.PodError{Cmd: "embeddedci.com", Reason: "busy"} + case "forbidden_tier": + return &tcpclient.PodError{Cmd: "embeddedci.com", Reason: "forbidden: needs an organization owner or admin"} + } + return nil +} + +// authHint adds what to do to a refused sign-in. +func authHint(err error) error { + var apiErr *serverapi.APIError + if errors.As(err, &apiErr) && apiErr.Status == http.StatusUnauthorized { + return fmt.Errorf("%w (run `benchpod login` again, or check BENCHPOD_API_KEY)", err) + } + return err +} + +func valueOr(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +} diff --git a/internal/cloudpod/cloudpod_test.go b/internal/cloudpod/cloudpod_test.go new file mode 100644 index 0000000..61d52cc --- /dev/null +++ b/internal/cloudpod/cloudpod_test.go @@ -0,0 +1,153 @@ +package cloudpod + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serverapi" + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" +) + +// fakeServer answers the device list and the command route like embeddedci-server. +type fakeServer struct { + auth string + command map[string]any + timeout float64 + reply func(w http.ResponseWriter) +} + +func (f *fakeServer) start(t *testing.T) *serverapi.Client { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.auth = r.Header.Get("Authorization") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/benchpod/devices": + _ = json.NewEncoder(w).Encode(map[string]any{"devices": []map[string]any{ + {"id": "id-a", "name": "bench-a"}, {"id": "id-b", "name": "bench-b"}}}) + case r.Method == http.MethodPost && r.URL.Path == "/api/benchpod/devices/id-b/command": + var body struct { + Command map[string]any `json:"command"` + TimeoutMs float64 `json:"timeout_ms"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + f.command, f.timeout = body.Command, body.TimeoutMs + f.reply(w) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(ts.Close) + return serverapi.New(ts.URL) +} + +func jsonError(w http.ResponseWriter, status int, msg, code string) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg, "code": code}) +} + +func TestResolveAndCommand(t *testing.T) { + f := &fakeServer{reply: func(w http.ResponseWriter) { + _, _ = w.Write([]byte(`{"request_id":"r","device_id":"id-b","status":"ok","data":{"version":"3.8.0"}}`)) + }} + api := f.start(t) + c, err := Resolve(context.Background(), api, "ApiKey eci_test", "bench-b") + if err != nil { + t.Fatal(err) + } + if c.DeviceID != "id-b" || c.Label() != "bench-b on embeddedci.com" { + t.Fatalf("resolved %+v", c) + } + if f.auth != "ApiKey eci_test" { + t.Errorf("auth = %q, want the API key scheme", f.auth) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + data, err := c.Command(ctx, map[string]any{"cmd": "status"}) + if err != nil { + t.Fatal(err) + } + if string(data) != `{"version":"3.8.0"}` || f.command["cmd"] != "status" { + t.Errorf("data %s, sent %v", data, f.command) + } + if f.timeout < 15000 || f.timeout > 20000 { + t.Errorf("timeout_ms = %v, want just under the context's 20 s", f.timeout) + } + + // A bearer token (the `benchpod login` session) goes as Bearer. + c.Credential = "jwt-1" + if _, err := c.Command(ctx, map[string]any{"cmd": "ping"}); err != nil { + t.Fatal(err) + } + if f.auth != "Bearer jwt-1" { + t.Errorf("auth = %q", f.auth) + } +} + +func TestResolveUnknownName(t *testing.T) { + api := (&fakeServer{}).start(t) + _, err := Resolve(context.Background(), api, "tok", "nope") + if err == nil || !strings.Contains(err.Error(), `no pod named "nope"`) || !strings.Contains(err.Error(), "bench-a, bench-b") { + t.Fatalf("err = %v", err) + } + // The id works too. + if c, err := Resolve(context.Background(), api, "tok", "id-a"); err != nil || c.Name != "bench-a" { + t.Fatalf("by id: %+v %v", c, err) + } +} + +func TestPodRefusalIsAPodError(t *testing.T) { + f := &fakeServer{reply: func(w http.ResponseWriter) { + _, _ = w.Write([]byte(`{"status":"error","error":"la voltage not set; set it first"}`)) + }} + c := &Client{API: f.start(t), Credential: "tok", DeviceID: "id-b", Name: "bench-b"} + _, err := c.Command(context.Background(), map[string]any{"cmd": "la"}) + pe, ok := tcpclient.AsPodError(err) + if !ok || pe.Reason != "la voltage not set; set it first" || pe.Cmd != "" { + t.Fatalf("err = %#v", err) + } +} + +func TestTransportFailures(t *testing.T) { + for _, c := range []struct { + status int + code string + want string + busy bool + unreachable bool + refused bool + }{ + {409, "lease_held", "holds its lease", true, false, false}, + {409, "device_busy", "busy with another operation", true, false, false}, + {503, "device_offline", "is offline", false, true, false}, + {503, "device_reconnecting", "try again in a few seconds", false, true, false}, + {504, "device_timeout", "did not answer", false, false, false}, + {400, "not_capable", "firmware is too old", false, false, false}, + {403, "forbidden_tier", "owner or admin", false, false, true}, + {401, "", "benchpod login", false, false, false}, + } { + f := &fakeServer{reply: func(w http.ResponseWriter) { jsonError(w, c.status, "server says no", c.code) }} + cl := &Client{API: f.start(t), Credential: "tok", DeviceID: "id-b", Name: "bench-b"} + _, err := cl.Command(context.Background(), map[string]any{"cmd": "status"}) + var ce *Error + if !errors.As(err, &ce) || !strings.Contains(err.Error(), c.want) { + t.Errorf("%d %s: err = %v, want %q", c.status, c.code, err, c.want) + continue + } + pe, isPE := tcpclient.AsPodError(err) + if got := isPE && pe.Kind() == tcpclient.Busy; got != c.busy { + t.Errorf("%s: busy = %v", c.code, got) + } + if got := isPE && pe.Kind() == tcpclient.Forbidden; got != c.refused { + t.Errorf("%s: forbidden = %v", c.code, got) + } + if got := errors.Is(err, tcpclient.ErrUnreachable); got != c.unreachable { + t.Errorf("%s: unreachable = %v", c.code, got) + } + } +} diff --git a/internal/fwrefusals/fwrefusals.go b/internal/fwrefusals/fwrefusals.go index 855d093..3f003be 100644 --- a/internal/fwrefusals/fwrefusals.go +++ b/internal/fwrefusals/fwrefusals.go @@ -29,7 +29,7 @@ type Refusal struct { } type doc struct { - Refusals []Refusal `json:"refusals"` + Refusals []Refusal `json:"refusals"` Tiers map[string]string `json:"tiers"` } diff --git a/internal/serialconsole/serialconsole.go b/internal/serialconsole/serialconsole.go index 1914173..aa262ef 100644 --- a/internal/serialconsole/serialconsole.go +++ b/internal/serialconsole/serialconsole.go @@ -262,7 +262,7 @@ func OpenBenchpod(explicit, preferred string, probeTimeout time.Duration) (*Cons } cands = preferFirst(strings.TrimSpace(preferred), cands) if len(cands) == 0 { - return nil, "", noPod(errors.New("no USB ports found; plug in the bench pod or pass --connection ")) + return nil, "", noPod(errors.New("no USB ports found; plug in the pod or pass --connection ")) } var tried []string for _, name := range cands { @@ -278,10 +278,10 @@ func OpenBenchpod(explicit, preferred string, probeTimeout time.Duration) (*Cons ok := c.IsBenchpod(ctx) cancel() if ok { - serialLogf(" %s: identified as a bench pod", name) + serialLogf(" %s: identified as a pod", name) return c, name, nil } - serialLogf(" %s: not a bench pod (no %q in status)", name, benchpodMarker) + serialLogf(" %s: not a pod (no %q in status)", name, benchpodMarker) _ = c.Close() tried = append(tried, name) } diff --git a/internal/serverapi/client.go b/internal/serverapi/client.go index 38d511b..bd322ad 100644 --- a/internal/serverapi/client.go +++ b/internal/serverapi/client.go @@ -187,7 +187,7 @@ func (c *Client) RegisterDevice(ctx context.Context, accessToken, name, publicKe } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Authorization", Authorization(accessToken)) httpc := c.HTTP if httpc == nil { @@ -359,6 +359,7 @@ type APIError struct { Path string Status int Message string + Code string // the server's machine-readable `code` (lease_held, device_offline, ...), when sent Body string } @@ -450,7 +451,7 @@ func (c *Client) doAuthedJSON(ctx context.Context, method, path, accessToken str req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Authorization", Authorization(accessToken)) httpc := c.HTTP if httpc == nil { @@ -469,10 +470,12 @@ func (c *Client) doAuthedJSON(ctx context.Context, method, path, accessToken str var errBody struct { Error string `json:"error"` Detail string `json:"detail"` + Code string `json:"code"` } _ = json.Unmarshal(data, &errBody) return &APIError{Method: method, Path: path, Status: resp.StatusCode, - Message: strings.TrimSpace(errBody.Error), Body: strings.TrimSpace(string(data))} + Message: strings.TrimSpace(errBody.Error), Code: strings.TrimSpace(errBody.Code), + Body: strings.TrimSpace(string(data))} } if out == nil || len(bytes.TrimSpace(data)) == 0 { return nil @@ -541,6 +544,27 @@ func (c *Client) SetDeviceWiringLaMV(ctx context.Context, accessToken, deviceID return true, nil } +// GetDeviceWiringProfile calls GET /api/benchpod/devices/{id}/wiring/profile and returns the +// pod's effective wiring profile: the server's defaults overlaid with the stored map, so every +// field is present (a pin that is not wired is null). A server without the profile route +// answers 404 (*APIError); the caller can fall back to GetDeviceWiring. +func (c *Client) GetDeviceWiringProfile(ctx context.Context, accessToken, deviceID string) (map[string]json.RawMessage, error) { + path, err := wiringPath(deviceID) + if err != nil { + return nil, err + } + var out struct { + Profile map[string]json.RawMessage `json:"profile"` + } + if err := c.doAuthedJSON(ctx, http.MethodGet, path+"/profile", accessToken, nil, &out); err != nil { + return nil, err + } + if out.Profile == nil { + out.Profile = map[string]json.RawMessage{} + } + return out.Profile, nil +} + func wiringPath(deviceID string) (string, error) { id := strings.TrimSpace(deviceID) if id == "" { @@ -548,3 +572,48 @@ func wiringPath(deviceID string) (string, error) { } return "/api/benchpod/devices/" + url.PathEscape(id) + "/wiring", nil } + +// Authorization is the Authorization header value for a credential: an API key passed as +// "ApiKey eci_..." is sent as it is, anything else is a bearer token (a `benchpod login` access +// token). Every authenticated call goes through it, so the endpoints take either. +func Authorization(credential string) string { + credential = strings.TrimSpace(credential) + if strings.HasPrefix(credential, "ApiKey ") { + return credential + } + return "Bearer " + credential +} + +// CommandResponse is the answer of POST /api/benchpod/devices/{id}/command. Status is "ok" (Data +// holds the pod's reply data) or "error" (Error holds the pod's message): a refusal by the pod is +// a valid answer (HTTP 200), unlike a transport failure, which comes back as an *APIError. +type CommandResponse struct { + RequestID string `json:"request_id"` + DeviceID string `json:"device_id"` + Status string `json:"status"` + Data json.RawMessage `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// DeviceCommand calls POST /api/benchpod/devices/{id}/command, which sends one single-reply JSON +// command ({"cmd":"status", ...}, exactly what the pod takes on its LAN port) to the pod over its +// cloud link and returns the pod's reply. timeout is how long the server waits for the pod (0 = +// the server's default, 30 s; the server caps it at 120 s). Failures before the pod answered come +// back as *APIError with the server's code in Code: lease_held, device_offline, device_timeout, +// not_capable, forbidden_tier, ... +func (c *Client) DeviceCommand(ctx context.Context, accessToken, deviceID string, command map[string]any, timeout time.Duration) (*CommandResponse, error) { + id := strings.TrimSpace(deviceID) + if id == "" { + return nil, errors.New("serverapi: device id is empty") + } + body := struct { + Command map[string]any `json:"command"` + TimeoutMs int `json:"timeout_ms,omitempty"` + }{Command: command, TimeoutMs: int(timeout / time.Millisecond)} + var out CommandResponse + if err := c.doAuthedJSON(ctx, http.MethodPost, "/api/benchpod/devices/"+url.PathEscape(id)+"/command", + accessToken, body, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/internal/tcpclient/client.go b/internal/tcpclient/client.go index e6648f5..3c8dc70 100644 --- a/internal/tcpclient/client.go +++ b/internal/tcpclient/client.go @@ -162,7 +162,7 @@ func decodeB64Samples(s string) ([]int, error) { func (c *Client) dialAndSend(ctx context.Context, req map[string]any) (net.Conn, *bufio.Reader, error) { addr := strings.TrimSpace(c.Addr) if addr == "" { - return nil, nil, fmt.Errorf("bench pod address is empty; run `benchpod set-connection ` first") + return nil, nil, fmt.Errorf("pod address is empty; run `benchpod set-connection ` first") } budget := c.DialTimeout @@ -267,7 +267,7 @@ func readReply(reader *bufio.Reader) (*reply, error) { // EOF (with or without a partial, newline-less line buffered) means the pod // closed before sending a complete response line. if errors.Is(err, io.EOF) { - return nil, errors.New("bench pod closed connection without a complete response") + return nil, errors.New("pod closed connection without a complete response") } return nil, fmt.Errorf("read response: %w", err) } diff --git a/internal/tcpclient/errors.go b/internal/tcpclient/errors.go index be39a45..72f30aa 100644 --- a/internal/tcpclient/errors.go +++ b/internal/tcpclient/errors.go @@ -71,7 +71,7 @@ func AsPodError(err error) (*PodError, bool) { // ErrUnreachable matches (errors.Is) a failure to reach the pod at all: nothing answered on // its address within the connect budget, or (serialconsole) no pod on USB. -var ErrUnreachable = errors.New("bench pod unreachable") +var ErrUnreachable = errors.New("pod unreachable") // dialError is a failed connect. Its text is the one the CLI always printed; it also matches // ErrUnreachable and unwraps to the underlying dial error. @@ -80,7 +80,7 @@ type dialError struct { err error } -func (e *dialError) Error() string { return "connect to bench pod at " + e.addr + ": " + e.err.Error() } +func (e *dialError) Error() string { return "connect to pod at " + e.addr + ": " + e.err.Error() } func (e *dialError) Unwrap() error { return e.err } func (e *dialError) Is(target error) bool { // An interrupted connect (Ctrl+C) is not an unreachable pod. @@ -90,7 +90,7 @@ func (e *dialError) Is(target error) bool { func firmwareMessage(msg string) string { msg = strings.TrimSpace(msg) if msg == "" { - return "bench pod returned an error" + return "pod returned an error" } return msg } diff --git a/internal/tcpclient/refusals_test.go b/internal/tcpclient/refusals_test.go index 671ef6b..bd9ea29 100644 --- a/internal/tcpclient/refusals_test.go +++ b/internal/tcpclient/refusals_test.go @@ -94,7 +94,7 @@ func TestAFailedConnectIsUnreachable(t *testing.T) { addr := ln.Addr().String() ln.Close() _, err = (&Client{Addr: addr, DialTimeout: 300 * time.Millisecond}).Command(context.Background(), map[string]any{"cmd": "ping"}) - if !errors.Is(err, ErrUnreachable) || !strings.HasPrefix(err.Error(), "connect to bench pod at "+addr+": ") { + if !errors.Is(err, ErrUnreachable) || !strings.HasPrefix(err.Error(), "connect to pod at "+addr+": ") { t.Fatalf("err = %v", err) } // Ctrl+C while connecting is not an unreachable pod. diff --git a/internal/tcpclient/session.go b/internal/tcpclient/session.go index 7fd355d..c9e12d9 100644 --- a/internal/tcpclient/session.go +++ b/internal/tcpclient/session.go @@ -32,7 +32,7 @@ type Session struct { func (c *Client) Open(ctx context.Context) (*Session, error) { addr := strings.TrimSpace(c.Addr) if addr == "" { - return nil, fmt.Errorf("bench pod address is empty; run `benchpod set-connection ` first") + return nil, fmt.Errorf("pod address is empty; run `benchpod set-connection ` first") } budget := c.DialTimeout if budget <= 0 { diff --git a/internal/tcpclient/swd.go b/internal/tcpclient/swd.go index 37403da..84bc084 100644 --- a/internal/tcpclient/swd.go +++ b/internal/tcpclient/swd.go @@ -32,7 +32,7 @@ func (c *Client) TargetPower(ctx context.Context, efuse int, on bool) error { func (c *Client) startRawMode(ctx context.Context, cmd string, swclk, swdio int, extra map[string]any) (net.Conn, error) { addr := strings.TrimSpace(c.Addr) if addr == "" { - return nil, fmt.Errorf("bench pod address is empty; run `benchpod set-connection ` first") + return nil, fmt.Errorf("pod address is empty; run `benchpod set-connection ` first") } budget := c.DialTimeout