diff --git a/README.md b/README.md index a1b8e56..ce011ef 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ 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). +New pod? `benchpod setup` walks the +[getting-started](https://www.embeddedci.com/docs/benchpod-getting-started) steps +for you: it finds the pod, gets it on the network, sets the board I/O voltage, +registers it with embeddedci.com and saves the connection, skipping whatever is +already done. + ## Installation Pre-built binaries for **macOS, Linux and Windows** (amd64 + arm64) are attached @@ -98,7 +104,7 @@ reach the pod; the transport is inferred from its value: | `/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). | -| *(omitted)* | the default saved by `benchpod set-connection`. | +| *(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 (`BENCHPOD_CONNECTION=usb`, `BENCHPOD_TIMEOUT=2m`, `BENCHPOD_CONFIG_FILE`, @@ -125,6 +131,10 @@ itself and say so when given a cloud target. ## CLI usage ```sh +# First time: guided setup (find, network, I/O voltage, register, save): +benchpod setup +benchpod setup --la-voltage 3.3V --yes # non-interactive + # Save a default connection so later commands can omit --connection: benchpod set-connection 192.168.1.5 benchpod set-connection usb @@ -135,7 +145,8 @@ benchpod discover --save # save it as the default when exactly one is found # Reachability / state: benchpod ping -benchpod status +benchpod status # readable summary +benchpod status --json # the pod's raw status reply # Hardware (network): benchpod la voltage 3.3V # the DUT's I/O voltage; LA operations need it (network or USB) @@ -462,6 +473,59 @@ stderr with a `[benchpod]` prefix. 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. +So it does for `la voltage not set` (run `benchpod la voltage 3.3V` or 1.8V, or set it on +the web app's Wiring tab) and for a `pin conflict: LA4 is in use by ...`, where the raw +JSON command the firmware suggests is replaced by where to free the pin (the web app, +the SDK or the MCP session that holds it). + +### `benchpod setup` + +Guided first-time setup. It walks the getting-started steps in order and skips each +one that is already done: + +1. **Find the pod** over USB and on the LAN (like `discover`). With `--connection` + it sets up the pod at that address or USB device instead. It stops when it finds + none or more than one. +2. **Network**: a pod with no address gets one. Choose Ethernet (setup waits up to + two minutes for its DHCP lease) or Wi-Fi (SSID and password, like `set-wifi`). +3. **Board I/O voltage**: 1.8 V or 3.3 V, the DUT's logic level (like `la voltage`). + One already set is kept unless `--la-voltage` is given. +4. **embeddedci.com**: an unregistered pod is registered to your account, signing in + first (`login`) when there is no session. A pod registered to your account is + named; one registered to another account is explained (sign in with the address + your activation email was sent to, `benchpod login --force`) and left alone. +5. **Save the connection**: the pod's address becomes the default (like `discover --save`). + +It ends with the next steps: SWD wiring (SWCLK on LA11, SWDIO on LA12, GND), a +`benchpod flash` line, the `pytest` line and the getting-started guide. + +```text +$ benchpod setup --la-voltage 3.3V + +1/5 Find the pod + Looking over USB and on the LAN (mDNS)... + Found a BenchPod: USB /dev/cu.usbmodem1101, network 192.168.1.221:8080, firmware v3.8.0. + +2/5 Network + Done: the pod is on the network at 192.168.1.221:8080. + +3/5 Board I/O voltage + Set to 3.3 V. + +4/5 embeddedci.com + Done: registered to your account (you@example.com) as benchpod-baea06. + +5/5 Save the connection +Default connection set to network/TCP 192.168.1.221:8080 (saved to ~/.config/benchpod-cli/config.json). +``` + +| Flag | Default | Purpose | +|--------------------|---------|-------------------------------------------------------------------------| +| `--la-voltage` | (ask) | Board I/O voltage to set: `1.8V` or `3.3V`. Without it, a voltage already set is kept and an unset one is asked for. | +| `--ssid` | (ask) | Join this Wi-Fi network when the pod has no network address (instead of asking Ethernet or Wi-Fi). | +| `--password-stdin` | off | Read the Wi-Fi password from the first line of stdin (with `--ssid`). Without it the password is prompted for. | +| `--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 ` @@ -490,8 +554,27 @@ Connectivity check over the network. No flags. ### `benchpod status` -Firmware, gateware, capabilities and network details: JSON over the network, the -console's `status` text over USB. No flags. +A short summary of the pod over the network: 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 +signed in, whether the pod is on your account), and a cloud job's lease when one +holds the pod. Over USB it prints the console's `status` text. + +```text +BenchPod at 192.168.1.220:8080 + Firmware 3.8.0 (latest) + Gateware 48 + Board stm32h563 v3 + I/O voltage 3.3 V + Target power off + Network 192.168.1.220 (ready), Wi-Fi connected + Cloud registered, connected, on your account as benchpod-baea06 +``` + +| Flag | Default | Purpose | +|----------|---------|---------------------------------------------------------------| +| `--json` | off | Print the pod's raw `status` JSON instead of the summary (network only). | ### `benchpod generate` @@ -738,8 +821,10 @@ Erase the stored Wi-Fi credentials (USB). Wi-Fi goes down at once, no reboot nee ### `benchpod login` -Authenticate with embeddedci.com (device-login flow). A no-op when a usable -session exists. +Authenticate with embeddedci.com (device-login flow) and print the account's email +address: `Logged in as you@example.com`. A no-op when a usable session exists +(`Already logged in as you@example.com`). Against a server or a token file that +predates the email, the user id is shown instead. | Flag | Default | Purpose | |----------------|------------------------------|----------------------------------------------------------| diff --git a/cmd/benchpod-cli/commands_cloud.go b/cmd/benchpod-cli/commands_cloud.go index 75b705f..e443309 100644 --- a/cmd/benchpod-cli/commands_cloud.go +++ b/cmd/benchpod-cli/commands_cloud.go @@ -31,7 +31,7 @@ func newLoginCmd(g *globalFlags) *cobra.Command { RunE: func(_ *cobra.Command, _ []string) error { if !force { if who, path := currentSession(serverURL, tokenFile); who != "" { - fmt.Fprintf(os.Stderr, "Already logged in as user %s (%s).\n", who, path) + fmt.Fprintf(os.Stderr, "Already logged in as %s (%s).\n", who, path) fmt.Fprintln(os.Stderr, "Run `benchpod login --force` to sign in again, or `benchpod logout` to sign out.") return nil } @@ -46,11 +46,11 @@ func newLoginCmd(g *globalFlags) *cobra.Command { return cmd } -// currentSession reports the signed-in user id and token path when a session is +// currentSession reports the signed-in account (email, else user id) and token path when a session is // usable, or "" when there is none. A session with an expired access token but a // live refresh token still counts: ensureTokens would renew it silently, so // sending the user through the browser again would be pointless. -func currentSession(serverURL, tokenFile string) (userID, path string) { +func currentSession(serverURL, tokenFile string) (who, path string) { tokenPath, err := resolveTokenPath(tokenFile) if err != nil { return "", "" @@ -63,7 +63,7 @@ func currentSession(serverURL, tokenFile string) (userID, path string) { if tokens.AccessExpired(now) && tokens.RefreshExpired(now) { return "", "" } - who := strings.TrimSpace(tokens.UserID) + who = strings.TrimSpace(tokens.Who()) if who == "" { who = "(unknown)" } @@ -96,7 +96,7 @@ func newLogoutCmd(g *globalFlags) *cobra.Command { return fmt.Errorf("remove %s: %w", tokenPath, err) } if who != "" { - fmt.Fprintf(os.Stderr, "Logged out user %s (removed %s).\n", who, tokenPath) + fmt.Fprintf(os.Stderr, "Logged out %s (removed %s).\n", who, tokenPath) } else { fmt.Fprintf(os.Stderr, "Logged out (removed %s).\n", tokenPath) } @@ -162,11 +162,11 @@ func runLogin(serverURL, tokenFile string, noOpen bool) error { case err != nil: return fmt.Errorf("poll device login: %w", err) case outcome == serverapi.PollSuccess: - tokens, sErr := saveTokensFromResponse(tokenPath, resp, "", "") + tokens, sErr := saveTokensFromResponse(tokenPath, resp, nil) if sErr != nil { return fmt.Errorf("save tokens: %w", sErr) } - fmt.Fprintf(os.Stderr, "Logged in as user %s. Tokens saved to %s.\n", tokens.UserID, tokenPath) + fmt.Fprintf(os.Stderr, "Logged in as %s. Tokens saved to %s.\n", tokens.Who(), tokenPath) return nil case outcome == serverapi.PollExpired: return errors.New("login timed out or code already used; run `benchpod login` again") diff --git a/cmd/benchpod-cli/commands_deregister.go b/cmd/benchpod-cli/commands_deregister.go index 16eff9d..70ceba4 100644 --- a/cmd/benchpod-cli/commands_deregister.go +++ b/cmd/benchpod-cli/commands_deregister.go @@ -99,7 +99,7 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st if err != nil { return fmt.Errorf("auth: %w", err) } - log.Printf("auth: signed in as user %s", tokens.UserID) + log.Printf("auth: signed in as %s", tokens.Who()) target := deviceID label := deviceID diff --git a/cmd/benchpod-cli/commands_discover.go b/cmd/benchpod-cli/commands_discover.go index 9676c68..ac074eb 100644 --- a/cmd/benchpod-cli/commands_discover.go +++ b/cmd/benchpod-cli/commands_discover.go @@ -186,16 +186,19 @@ func checkPod(p *discoveredPod) { } // correlate marks each network pod that is the same physical device as one of -// the pods found over USB, matched on the address the pod reports for itself. -// Without this a single pod plugged in and on the LAN reads as two. +// the pods found over USB, matched on the address the pod reports for itself or, +// for a registered pod, on its cloud device id. The id catches a pod on Ethernet +// and Wi-Fi at once, whose USB console reports one address while mDNS answers on +// the other. Without this a single pod plugged in and on the LAN reads as two. func correlate(netPods []discoveredPod, serialPods []serialconsole.SerialPod) { for i := range netPods { host, _, err := net.SplitHostPort(netPods[i].addr) if err != nil { host = netPods[i].addr } + id := strings.TrimSpace(netPods[i].cloud.DeviceID) for _, sp := range serialPods { - if sp.Addressed() && sp.IP == host { + if (sp.Addressed() && sp.IP == host) || (id != "" && strings.TrimSpace(sp.DeviceID) == id) { netPods[i].sameAs = sp.Device break } @@ -532,6 +535,13 @@ func signedInAccountDevices(serialPods []serialconsole.SerialPod, netPods []disc if !registered { return nil } + return accountDevices() +} + +// accountDevices lists the devices on the account this machine is signed in to, by id, or nil +// when there is no usable session or the server could not be asked. It never prompts. A variable +// so tests can replace it. +var accountDevices = func() map[string]string { tokenPath, err := resolveTokenPath("") if err != nil { return nil diff --git a/cmd/benchpod-cli/commands_discover_test.go b/cmd/benchpod-cli/commands_discover_test.go index 68a4a43..0f66f7f 100644 --- a/cmd/benchpod-cli/commands_discover_test.go +++ b/cmd/benchpod-cli/commands_discover_test.go @@ -30,6 +30,15 @@ func TestCorrelateMatchesSamePodOnBothTransports(t *testing.T) { } } +func TestCorrelateMatchesADualHomedPodByDeviceID(t *testing.T) { + // On Ethernet and Wi-Fi at once: USB reports one address, mDNS answers on the other. + netPods := []discoveredPod{{addr: "192.168.1.221:8080", cloud: cloudState{known: true, Configured: true, DeviceID: "dev-1"}}} + correlate(netPods, []serialconsole.SerialPod{{Device: "/dev/ttyACM0", IP: "192.168.1.220", DeviceID: "dev-1"}}) + if netPods[0].sameAs != "/dev/ttyACM0" { + t.Fatalf("got %q", netPods[0].sameAs) + } +} + func TestCorrelateIgnoresUnaddressedSerialPod(t *testing.T) { // A pod with no lease reports 0.0.0.0; it must never match a network entry, // which would otherwise collapse two distinct pods into one. diff --git a/cmd/benchpod-cli/commands_register.go b/cmd/benchpod-cli/commands_register.go index e11f731..fe6a482 100644 --- a/cmd/benchpod-cli/commands_register.go +++ b/cmd/benchpod-cli/commands_register.go @@ -134,7 +134,7 @@ func runRegister(g *globalFlags, opts registerOptions) error { if err != nil { return fmt.Errorf("auth: %w", err) } - log.Printf("auth: signed in as user %s", tokens.UserID) + log.Printf("auth: signed in as %s", tokens.Who()) client := &tcpclient.Client{Addr: addr} diff --git a/cmd/benchpod-cli/commands_serial.go b/cmd/benchpod-cli/commands_serial.go index c497c55..2465e50 100644 --- a/cmd/benchpod-cli/commands_serial.go +++ b/cmd/benchpod-cli/commands_serial.go @@ -36,55 +36,8 @@ func newSetWifiCmd(g *globalFlags) *cobra.Command { if err != nil { return fmt.Errorf("set-wifi: %w", err) } - // wifi-set runs the full reconnect ladder on the device: probe the - // internal modem (with an EN-reset retry), then the external GPIO40/41 - // 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)) - if err != nil { - return err - } - defer cancel() - defer console.Close() - - // Wi-Fi runs on the ESP32-C3, whose image the pod keeps in its W25Q: a pod without - // it (fresh board, no network) can only get it over this cable. - if !skipBlobs { - if err := ensureEspBlob(console); err != nil { - return fmt.Errorf("set-wifi: %w", err) - } - } - - res, err := console.WifiSet(ctx, ssid, pw) - // Echo the firmware's WiFi/TCP/ESP32 bring-up lines (incl. the - // "[wifi] connected ip=" result) so the IP is visible — even - // when the join didn't confirm. - for _, line := range serialconsole.BringupLines(res.Raw) { - fmt.Fprintf(os.Stderr, " %s\n", line) - } - if err != nil { - return fmt.Errorf("set-wifi: %w", err) - } - fmt.Fprintf(os.Stderr, "Wrote WiFi credentials for SSID %q via %s.\n", ssid, path) - - // Read the authoritative state back with wifi-show: the reconnect's - // "[wifi] connected ip=" line can be lost in the device's non-blocking - // USB console output, so don't rely on it alone to report the result. - st, _ := console.WifiShow(ctx) - ip := st.IP - if ip == "" { - ip = res.IP - } - state := strings.ToLower(st.State) - joined := res.Joined || ip != "" && ip != "(none)" || - strings.Contains(state, "ready") || strings.Contains(state, "connected") - if joined { - fmt.Fprintf(os.Stderr, "Joined %q, ip=%s\n", ssid, valueOrDash(ip)) - } else { - fmt.Fprintln(os.Stderr, "Credentials saved; join not confirmed (run `benchpod show-network`).") - } - return nil + _, err = runSetWifi(g, ssid, pw, skipBlobs) + return err }, } cmd.Flags().StringVar(&ssid, "ssid", "", "WiFi SSID (required)") @@ -94,6 +47,63 @@ func newSetWifiCmd(g *globalFlags) *cobra.Command { return cmd } +// runSetWifi saves Wi-Fi credentials over the USB console and joins, returning the pod's +// address when the join is confirmed ("" when it is not). Shared by set-wifi and setup. +func runSetWifi(g *globalFlags, ssid, pw string, skipBlobs bool) (string, error) { + // wifi-set runs the full reconnect ladder on the device: probe the + // internal modem (with an EN-reset retry), then the external GPIO40/41 + // 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)) + if err != nil { + return "", err + } + defer cancel() + defer console.Close() + + // Wi-Fi runs on the ESP32-C3, whose image the pod keeps in its W25Q: a pod without + // it (fresh board, no network) can only get it over this cable. + if !skipBlobs { + if err := ensureEspBlob(console); err != nil { + return "", fmt.Errorf("set-wifi: %w", err) + } + } + + res, err := console.WifiSet(ctx, ssid, pw) + // Echo the firmware's WiFi/TCP/ESP32 bring-up lines (incl. the + // "[wifi] connected ip=" result) so the IP is visible — even + // when the join didn't confirm. + for _, line := range serialconsole.BringupLines(res.Raw) { + fmt.Fprintf(os.Stderr, " %s\n", line) + } + if err != nil { + return "", fmt.Errorf("set-wifi: %w", err) + } + fmt.Fprintf(os.Stderr, "Wrote WiFi credentials for SSID %q via %s.\n", ssid, path) + + // Read the authoritative state back with wifi-show: the reconnect's + // "[wifi] connected ip=" line can be lost in the device's non-blocking + // USB console output, so don't rely on it alone to report the result. + st, _ := console.WifiShow(ctx) + ip := st.IP + if ip == "" { + ip = res.IP + } + state := strings.ToLower(st.State) + joined := res.Joined || ip != "" && ip != "(none)" || + strings.Contains(state, "ready") || strings.Contains(state, "connected") + if !joined { + fmt.Fprintln(os.Stderr, "Credentials saved; join not confirmed (run `benchpod show-network`).") + return "", nil + } + fmt.Fprintf(os.Stderr, "Joined %q, ip=%s\n", ssid, valueOrDash(ip)) + if ip == "(none)" || ip == "0.0.0.0" { + ip = "" + } + return ip, nil +} + // ensureEspBlob installs the blobs from the pod's firmware release when its ESP32-C3 slot is // empty or holds a different image, so set-wifi works on a pod that has never had a network. func ensureEspBlob(console *serialconsole.Console) error { diff --git a/cmd/benchpod-cli/commands_tcp.go b/cmd/benchpod-cli/commands_tcp.go index e21b24c..468087f 100644 --- a/cmd/benchpod-cli/commands_tcp.go +++ b/cmd/benchpod-cli/commands_tcp.go @@ -50,66 +50,6 @@ func newPingCmd(g *globalFlags) *cobra.Command { } } -// ── status ─────────────────────────────────────────────────────────────────── - -func newStatusCmd(g *globalFlags) *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Firmware / network info (network: JSON over TCP; USB: `status` console text)", - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - spec, err := g.resolveConnection() - if err != nil { - return err - } - if spec.IsSerial() { - return runStatusSerial(g, spec.Device) - } - - ctx, cancel, client, err := g.wifiClient("status", 30*time.Second) - if err != nil { - return err - } - defer cancel() - - data, err := client.Command(ctx, map[string]any{"cmd": "status"}) - if err != nil { - return fmt.Errorf("status: %w", err) - } - out, closeOut, err := resolveOutput(g.outputFilename) - if err != nil { - return fmt.Errorf("status: open output: %w", err) - } - defer closeOut() - printJSON(out, data) - return nil - }, - } -} - -// runStatusSerial runs the firmware's `status` command over the USB serial -// console and prints its (plain-text) output. -func runStatusSerial(g *globalFlags, device string) error { - console, _, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(15*time.Second)) - if err != nil { - return err - } - defer cancel() - defer console.Close() - - text, err := console.Status(ctx) - if err != nil { - return fmt.Errorf("status: %w", err) - } - out, closeOut, err := resolveOutput(g.outputFilename) - if err != nil { - return fmt.Errorf("status: open output: %w", err) - } - defer closeOut() - fmt.Fprintln(out, text) - return nil -} - // ── generate ───────────────────────────────────────────────────────────────── func newGenerateCmd(g *globalFlags) *cobra.Command { diff --git a/cmd/benchpod-cli/connection.go b/cmd/benchpod-cli/connection.go index 9331ef7..e9a2e01 100644 --- a/cmd/benchpod-cli/connection.go +++ b/cmd/benchpod-cli/connection.go @@ -74,7 +74,9 @@ func classifyConnection(raw string) (ConnSpec, error) { func parseTarget(raw string) (ConnSpec, error) { raw = strings.TrimSpace(raw) if raw == "" { - return ConnSpec{}, fmt.Errorf("no connection set; pass --connection or run `benchpod set-connection `") + 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 `") } switch strings.ToLower(raw) { // "usb" is the name this keyword goes by everywhere the CLI speaks to a diff --git a/cmd/benchpod-cli/connection_test.go b/cmd/benchpod-cli/connection_test.go index 123a0b0..2203f6a 100644 --- a/cmd/benchpod-cli/connection_test.go +++ b/cmd/benchpod-cli/connection_test.go @@ -140,3 +140,15 @@ func TestDescribeConnSpeaksUSBNotSerial(t *testing.T) { t.Errorf("describeConn(addr) = %q", got) } } + +func TestNoConnectionSuggestsDiscoverFirst(t *testing.T) { + _, err := parseTarget("") + if err == nil { + t.Fatal("no error") + } + msg := err.Error() + d, c := strings.Index(msg, "benchpod discover --save"), strings.Index(msg, "--connection") + if d < 0 || c < 0 || d > c || !strings.Contains(msg, "set-connection") { + t.Fatalf("got %q", msg) + } +} diff --git a/cmd/benchpod-cli/errors.go b/cmd/benchpod-cli/errors.go index 8b741d1..9d21029 100644 --- a/cmd/benchpod-cli/errors.go +++ b/cmd/benchpod-cli/errors.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "regexp" "strings" "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" @@ -22,6 +23,52 @@ const ( forbiddenHint = "API keys need the benchpod:admin scope" ) +// laVoltageUnsetHint answers the firmware's "la voltage not set; set it with la_voltage (mv 1800 +// or 3300) first", which names the raw JSON command rather than what a CLI user runs. +const laVoltageUnsetHint = "set the DUT's I/O voltage first: `benchpod la voltage 3.3V` (or 1.8V), " + + "or on the web app's Wiring tab" + +// pinConflictRE matches the firmware's "pin conflict: LA%u is in use by %s; %s" (la_pins.c). +var pinConflictRE = regexp.MustCompile(`^pin conflict: (LA\d+) is in use by (\S+); (.*)$`) + +// pinReleaseHints maps the firmware's release hints (la_pins.c release_hint) to what a CLI user +// does about them. The benchpod CLI has no command that holds a pin past its own run, so a pin in +// use is held by another client of the pod; the hint names where to free it. A hint missing from +// this map (a new owner) is shown as the firmware wrote it. +var pinReleaseHints = []struct { + prefix string // the firmware hint starts with this + hint string // %s = the pin (LA4) +}{ + {`release it with {"cmd":"gpio"`, "another session (the web app, a test or an MCP session) holds %s as a GPIO; " + + "release it there (gpio_release)"}, + {"stop the uart proxy first", "a UART session (the web app's Terminal, a test or an MCP session) " + + "is open on %s; close it there"}, + {"end the SWD session first", "an SWD session (a flash or an OpenOCD run) is using %s; wait for it to end"}, + {"stop the sensor emulation first", "I2C-sensor emulation is running on %s; stop it where it was " + + "started (the web app, the SDK, or MCP disable_i2c_sensor)"}, + {"stop the SPI session first", "an SPI session is open on %s; end it where it was started " + + "(the web app, the SDK or an MCP session)"}, + {"stop the GPS receiver first", "GPS emulation is running on %s; stop it where it was started " + + "(the web app, the SDK, or MCP disable_gps)"}, +} + +// translateRefusal rewords a refusal whose firmware text names a raw JSON command: it returns the +// text to show and the hint to add ("" for none). Other refusals come back unchanged with no hint. +func translateRefusal(msg string) (shown, hint string) { + msg = strings.TrimSpace(msg) + if strings.HasPrefix(msg, "la voltage not set") { + return msg, laVoltageUnsetHint + } + if m := pinConflictRE.FindStringSubmatch(msg); m != nil { + for _, h := range pinReleaseHints { + if strings.HasPrefix(m[3], h.prefix) { + return "pin conflict: " + m[1] + " is in use by " + m[2], fmt.Sprintf(h.hint, m[1]) + } + } + } + return msg, "" +} + // explainRefusal returns the hint for a policy refusal, or "" when the message says it all. // overLAN is whether the refusal came over the LAN JSON API: only the LAN refuses with locked: // or a cloud lease, so elsewhere those texts stand alone. @@ -42,7 +89,8 @@ func explainRefusal(msg string, overLAN bool) string { case tcpclient.Forbidden: return forbiddenHint } - return "" + _, hint := translateRefusal(msg) + return hint } // refusedError is the one line for a refusal from a command: ": the pod refused: ", @@ -53,7 +101,8 @@ func refusedError(what string, pe *tcpclient.PodError, overLAN bool) error { // refusedWithHint is refusedError with the hint given ("" for none). func refusedWithHint(what string, pe *tcpclient.PodError, hint string) error { - msg := what + ": the pod refused: " + pe.Reason + shown, _ := translateRefusal(pe.Reason) + msg := what + ": the pod refused: " + shown if hint != "" { msg += " (" + hint + ")" } @@ -83,6 +132,11 @@ func withRefusalHint(err error) error { if hint == "" || strings.Contains(err.Error(), hint) { return err } + if shown, _ := translateRefusal(pe.Reason); shown != strings.TrimSpace(pe.Reason) { + // Drop the raw JSON the firmware suggests; the hint says it in CLI terms. + msg := strings.Replace(err.Error(), strings.TrimSpace(pe.Reason), shown, 1) + return &shownRefusal{msg: msg + " (" + hint + ")", pe: pe} + } return fmt.Errorf("%w (%s)", err, hint) } diff --git a/cmd/benchpod-cli/helpers.go b/cmd/benchpod-cli/helpers.go index d6f3f55..605b152 100644 --- a/cmd/benchpod-cli/helpers.go +++ b/cmd/benchpod-cli/helpers.go @@ -322,26 +322,33 @@ func ensureTokens(ctx context.Context, api *serverapi.Client, tokenPath string) if err != nil { return nil, fmt.Errorf("refresh token failed (%v); run `benchpod login` again", err) } - saved, err := saveTokensFromResponse(tokenPath, resp, existing.SessionID, existing.UserID) + saved, err := saveTokensFromResponse(tokenPath, resp, existing) if err != nil { return nil, err } return saved, nil } -func saveTokensFromResponse(path string, resp *serverapi.TokenResponse, fallbackSessionID, fallbackUserID string) (*authstore.Tokens, error) { +// saveTokensFromResponse stores a token response. Identity fields the response +// omits (an older server sends no email) are kept from prev, the tokens being +// refreshed, when there are any. +func saveTokensFromResponse(path string, resp *serverapi.TokenResponse, prev *authstore.Tokens) (*authstore.Tokens, error) { if resp == nil { return nil, errors.New("nil response") } - now := time.Now() - sessionID := strings.TrimSpace(resp.SessionID) - if sessionID == "" { - sessionID = fallbackSessionID + if prev == nil { + prev = &authstore.Tokens{} } - userID := strings.TrimSpace(resp.UserID) - if userID == "" { - userID = fallbackUserID + now := time.Now() + pick := func(v, fallback string) string { + if v = strings.TrimSpace(v); v != "" { + return v + } + return fallback } + sessionID := pick(resp.SessionID, prev.SessionID) + userID := pick(resp.UserID, prev.UserID) + email := pick(resp.Email, prev.Email) t := &authstore.Tokens{ AccessToken: resp.AccessToken, RefreshToken: resp.RefreshToken, @@ -349,6 +356,7 @@ func saveTokensFromResponse(path string, resp *serverapi.TokenResponse, fallback RefreshExpiresAt: now.Add(time.Duration(resp.RefreshExpiresIn) * time.Second), SessionID: sessionID, UserID: userID, + Email: email, } if err := authstore.Save(path, t); err != nil { return nil, fmt.Errorf("save tokens to %s: %w", path, err) diff --git a/cmd/benchpod-cli/lavoltage.go b/cmd/benchpod-cli/lavoltage.go index 05e82be..c0cbe82 100644 --- a/cmd/benchpod-cli/lavoltage.go +++ b/cmd/benchpod-cli/lavoltage.go @@ -75,50 +75,56 @@ type laVoltageReply struct { } func runLAVoltage(g *globalFlags, mv int, out, warn io.Writer) error { + label, rep, err := laVoltageDo(g, mv) + if err != nil { + return err + } + verb := ":" + if mv != 0 { + verb = " is now" + } + if rep.MV == 0 { + fmt.Fprintf(out, "LA voltage of %s: not set (the pod refuses LA operations until it is)\n", label) + return nil + } + fmt.Fprintf(out, "LA voltage of %s%s %s\n", label, verb, formatMV(rep.MV)) + if rep.ReadbackMV != 0 && rep.ReadbackMV != rep.MV { + fmt.Fprintf(warn, "Warning: the bank's power mux reports %s, not %s\n", formatMV(rep.ReadbackMV), formatMV(rep.MV)) + } + return nil +} + +// 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. +func laVoltageDo(g *globalFlags, mv int) (string, laVoltageReply, error) { what := "la voltage" spec, err := g.resolveConnection() if err != nil { - return err + return "", laVoltageReply{}, err } - var label string var rep laVoltageReply if spec.IsSerial() { console, path, ctx, cancel, err := openLAVoltageConsole(g, spec.Device, g.effectiveTimeout(15*time.Second)) if err != nil { - return err + return "", rep, err } defer cancel() defer console.Close() - label = "the pod on " + path rep.MV, err = console.LAVoltage(ctx, mv) if err != nil { - return laVoltageError(what, err) - } - } else { - ctx, cancel, client, err := g.wifiClient(what, 15*time.Second) - if err != nil { - return err - } - defer cancel() - label = spec.Addr - if rep, err = laVoltageLAN(ctx, client, mv); err != nil { - return laVoltageError(what, err) + return "", rep, laVoltageError(what, err) } + return "the pod on " + path, rep, nil } - - verb := ":" - if mv != 0 { - verb = " is now" - } - if rep.MV == 0 { - fmt.Fprintf(out, "LA voltage of %s: not set (the pod refuses LA operations until it is)\n", label) - return nil + ctx, cancel, client, err := g.wifiClient(what, 15*time.Second) + if err != nil { + return "", rep, err } - fmt.Fprintf(out, "LA voltage of %s%s %s\n", label, verb, formatMV(rep.MV)) - if rep.ReadbackMV != 0 && rep.ReadbackMV != rep.MV { - fmt.Fprintf(warn, "Warning: the bank's power mux reports %s, not %s\n", formatMV(rep.ReadbackMV), formatMV(rep.MV)) + defer cancel() + if rep, err = laVoltageLAN(ctx, client, mv); err != nil { + return "", rep, laVoltageError(what, err) } - return nil + return spec.Addr, rep, nil } func laVoltageLAN(ctx context.Context, client *tcpclient.Client, mv int) (laVoltageReply, error) { diff --git a/cmd/benchpod-cli/main.go b/cmd/benchpod-cli/main.go index 83a1c7e..a20c6c1 100644 --- a/cmd/benchpod-cli/main.go +++ b/cmd/benchpod-cli/main.go @@ -5,18 +5,17 @@ // // --connection 192.168.1.5[:8080] TCP/JSON API (an address ⇒ wifi). // --connection /dev/tty... | COM3 USB serial console, explicit device. -// --connection serial USB serial console, auto-detected (USB VID 2E8A). +// --connection usb USB serial console, auto-detected. // (omitted) the default saved by `benchpod set-connection`. // -// The firmware itself is unauthenticated; the `login` subcommand is independent -// of the device path and authenticates with embeddedci-server (device-login -// flow) for the future cloud features. Direct firmware commands do not send -// tokens. +// 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. // -// Only `flash` (SWD) is implemented over the serial console today; the other -// TCP/JSON commands reject a serial connection with a clear message. The -// set/show/clear-wifi and bootsel subcommands always use the serial console -// regardless of --connection (a device path still selects the port). +// 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. package main import "os" diff --git a/cmd/benchpod-cli/refusals_test.go b/cmd/benchpod-cli/refusals_test.go index a4bb3ee..c3d8437 100644 --- a/cmd/benchpod-cli/refusals_test.go +++ b/cmd/benchpod-cli/refusals_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "net/http" "strings" "testing" @@ -83,3 +84,48 @@ func TestFirmwareRefusalsKeepTheirPrefixes(t *testing.T) { } } } + +func TestTheLAVoltageRefusalNamesTheCLICommand(t *testing.T) { + pe := &tcpclient.PodError{Reason: fwrefusals.Example("la_voltage_unset")} + err := withRefusalHint(fmt.Errorf("flash: %w", pe)) + if !strings.Contains(err.Error(), "benchpod la voltage 3.3V") || !strings.Contains(err.Error(), "Wiring tab") { + t.Fatalf("got %v", err) + } + if exitCode(err) != exitRefused { + t.Fatalf("exit code %d", exitCode(err)) + } +} + +func TestPinConflictHintsAreTranslated(t *testing.T) { + for _, tc := range []struct{ owner, hintID, want string }{ + {"gpio", "pin_hint_gpio", "holds LA4 as a GPIO"}, + {"uart_rx", "pin_hint_uart", "UART session"}, + {"swd_clk", "pin_hint_swd", "SWD session"}, + {"i2c_sda", "pin_hint_sensor", "disable_i2c_sensor"}, + {"spi_sck", "pin_hint_spi", "SPI session"}, + {"gps_tx", "pin_hint_gps", "disable_gps"}, + } { + msg := "pin conflict: LA4 is in use by " + tc.owner + "; " + fwrefusals.Example(tc.hintID) + err := withRefusalHint(fmt.Errorf("la step: %w", &tcpclient.PodError{Reason: msg})) + if !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), `{"cmd"`) { + t.Errorf("%s: got %v", tc.hintID, err) + } + if !strings.Contains(err.Error(), "pin conflict: LA4 is in use by "+tc.owner) || exitCode(err) != exitRefused { + t.Errorf("%s: got %v (exit %d)", tc.hintID, err, exitCode(err)) + } + } + // The pinned example renders the gpio case. + if shown, hint := translateRefusal(fwrefusals.Example("pin_conflict")); shown != "pin conflict: LA4 is in use by gpio" || hint == "" { + t.Fatalf("got %q / %q", shown, hint) + } + // A hint the CLI does not know (the step train's is already plain) stays as the pod wrote it. + msg := "pin conflict: LA2 is in use by step; " + fwrefusals.Example("pin_hint_step") + if err := withRefusalHint(&tcpclient.PodError{Reason: msg}); err.Error() != (&tcpclient.PodError{Reason: msg}).Error() { + t.Fatalf("got %v", err) + } + // refusedError shows the translated text too. + pe := &tcpclient.PodError{Reason: fwrefusals.Example("pin_conflict")} + if err := refusedError("flash", pe, true); strings.Contains(err.Error(), `{"cmd"`) || !strings.Contains(err.Error(), "gpio_release") { + t.Fatalf("got %v", err) + } +} diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index 7a6bdbf..c6fea6c 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -44,19 +44,19 @@ func newRootCmd() *cobra.Command { Version: version, Short: "EmbeddedCI bench pod CLI", Long: "EmbeddedCI bench pod CLI.\n\n" + - "--connection says where and how to reach the bench pod, and the transport\n" + - "is inferred from its value: an address (192.168.1.5[:8080]) uses the TCP/JSON\n" + - "API; a device path (/dev/tty..., COM3) or the keyword `usb` uses the pod's\n" + - "USB console. Omit it to use the default saved by `benchpod set-connection`.\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; the other commands need the network or USB.\n" + - "Over USB, `flash` (SWD), `status`, `la voltage`, `lan-policy`, `sig-policy`,\n" + - "`cloud ca` and `cloud proxy` work; the other network commands refuse a USB\n" + - "connection. `identity`, `install-blobs`, `set-wifi`, `show-network`,\n" + - "`clear-wifi`, `bootsel` and `dfu` always use the USB console. `flash-self`\n" + - "reflashes the pod's own firmware over USB DFU (STM32) via dfu-util, independent\n" + - "of --connection. `lan-policy` and `sig-policy` also work over embeddedci.com\n" + - "with --device-name.\n\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" + exitCodesHelp, SilenceUsage: true, SilenceErrors: true, @@ -84,35 +84,35 @@ func newRootCmd() *cobra.Command { _ = viper.BindPFlag(name, pf.Lookup(name)) } - root.AddCommand( - newLoginCmd(g), - newLogoutCmd(g), - newRegisterCmd(g), - newDeregisterCmd(g), - newSetConnectionCmd(g), - newDiscoverCmd(g), - newPingCmd(g), - newStatusCmd(g), - newGenerateCmd(g), - newCaptureCmd(g), - newStreamCmd(g), - newMeasureCmd(g), - newTestCmd(g), - newLACmd(g), - newFlashCmd(g), - newFlashSelfCmd(g), - newInstallBlobsCmd(g), - newSPIFlashCmd(g), - newSetWifiCmd(g), - newShowNetworkCmd(g), - newClearWifiCmd(g), - newBootselCmd(g), - newDfuCmd(g), - newLanPolicyCmd(g), - newSigPolicyCmd(g), - newIdentityCmd(g), - newCloudCmd(g), - ) + // Commands are grouped in the help by what they act on. + groups := []struct { + id, title string + cmds []*cobra.Command + }{ + {"setup", "Setup:", []*cobra.Command{ + newSetupCmd(g), newDiscoverCmd(g), newSetConnectionCmd(g), + newSetWifiCmd(g), newShowNetworkCmd(g), newClearWifiCmd(g), + }}, + {"pod", "The pod:", []*cobra.Command{ + newStatusCmd(g), newPingCmd(g), newLACmd(g), newIdentityCmd(g), + newFlashSelfCmd(g), newInstallBlobsCmd(g), newBootselCmd(g), newDfuCmd(g), + newLanPolicyCmd(g), newSigPolicyCmd(g), + }}, + {"target", "The target (DUT) and analog I/O:", []*cobra.Command{ + newFlashCmd(g), newSPIFlashCmd(g), newGenerateCmd(g), newCaptureCmd(g), + newStreamCmd(g), newMeasureCmd(g), newTestCmd(g), + }}, + {"cloud", "embeddedci.com:", []*cobra.Command{ + newLoginCmd(g), newLogoutCmd(g), newRegisterCmd(g), newDeregisterCmd(g), newCloudCmd(g), + }}, + } + for _, grp := range groups { + root.AddGroup(&cobra.Group{ID: grp.id, Title: grp.title}) + for _, c := range grp.cmds { + c.GroupID = grp.id + root.AddCommand(c) + } + } return root } diff --git a/cmd/benchpod-cli/setup.go b/cmd/benchpod-cli/setup.go new file mode 100644 index 0000000..736a75e --- /dev/null +++ b/cmd/benchpod-cli/setup.go @@ -0,0 +1,573 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/embeddedci-com/benchpod-cli/internal/benchpodconfig" + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" +) + +// ── setup ──────────────────────────────────────────────────────────────────── +// +// setup walks the getting-started steps (https://www.embeddedci.com/docs/benchpod-getting-started) +// in order and skips each one that is already done: find the pod, get it on the network, set the +// board I/O voltage, register it with embeddedci.com, save the connection. Every step reuses the +// command that does it on its own (discover, set-wifi, la voltage, login, register, +// set-connection); setup only decides what is left to do and asks. + +const gettingStartedURL = "https://www.embeddedci.com/docs/benchpod-getting-started" + +func newSetupCmd(g *globalFlags) *cobra.Command { + var ( + laVoltage string + ssid string + passwordStdin bool + noRegister bool + yes bool + ) + cmd := &cobra.Command{ + Use: "setup", + Short: "Guided first-time setup: find the pod, network, I/O voltage, registration, saved connection", + Long: "Walk through the getting-started steps for a BenchPod, skipping what is already done:\n\n" + + " 1. Find the pod over USB and on the LAN (like `benchpod discover`).\n" + + " 2. Network: when the pod has no address, choose Ethernet (setup waits for its\n" + + " DHCP lease) or Wi-Fi (SSID and password, like `benchpod set-wifi`).\n" + + " 3. Board I/O voltage: 1.8 V or 3.3 V, the DUT's logic level (like\n" + + " `benchpod la voltage`). Already set is kept unless --la-voltage is given.\n" + + " 4. embeddedci.com: an unregistered pod is registered to your account (signing\n" + + " in first when needed). A pod on another account is explained, not touched.\n" + + " 5. Save the pod's address as the default connection (like `discover --save`).\n\n" + + "It then prints the next steps: wiring SWD, flashing and running tests.\n\n" + + "Pass --connection to set up a pod at a known address or USB device instead of\n" + + "discovering it. With --yes setup never prompts: it fails with a message naming\n" + + "the flag to pass when it needs an answer. Docs: " + gettingStartedURL, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + opts := setupOpts{ssid: strings.TrimSpace(ssid), passwordStdin: passwordStdin, + noRegister: noRegister, yes: yes, ethernetWait: 2 * time.Minute} + if laVoltage != "" { + mv, err := parseLAVoltage(laVoltage) + if err != nil { + return &usageError{err: fmt.Errorf("--la-voltage: %w", err)} + } + opts.laVoltageMV = mv + } + if passwordStdin && opts.ssid == "" { + return usageErrorf("--password-stdin needs --ssid") + } + ui := &setupUI{in: bufio.NewReader(os.Stdin), out: os.Stdout, yes: yes} + return runSetup(&realSetupEnv{g: g}, ui, opts) + }, + } + f := cmd.Flags() + f.StringVar(&laVoltage, "la-voltage", "", "board I/O voltage to set: 1.8V or 3.3V (default: keep the current one, or ask)") + f.StringVar(&ssid, "ssid", "", "join this Wi-Fi network when the pod has no network address") + f.BoolVar(&passwordStdin, "password-stdin", false, "read the Wi-Fi password from the first line of stdin (with --ssid)") + f.BoolVar(&noRegister, "no-register", false, "do not register the pod with embeddedci.com") + f.BoolVar(&yes, "yes", false, "never prompt: register without asking, and fail with a message when an answer is needed") + return cmd +} + +type setupOpts struct { + laVoltageMV int + ssid string + passwordStdin bool + noRegister bool + yes bool + ethernetWait time.Duration +} + +// setupPod is one physical pod as setup sees it. +type setupPod struct { + device string // USB console device, "" when not seen over USB + addr string // network host:port, "" when it has no address + firmware string + cloud cloudState // known=false when the pod could not say +} + +// setupEnv is everything setup does to the pod, the account and the config; tests fake it. +type setupEnv interface { + find() ([]setupPod, error) + waitForAddress(device string, wait time.Duration) (string, error) + setWifi(device, ssid, password string) (string, error) + readPassword() (string, error) + laVoltage(target string, mv int) (int, error) + cloudStatus(addr string) cloudState + signedIn() string + login() error + accountDevices() map[string]string + register(addr string) error + save(target string) error +} + +// errNeedsAnswer is returned under --yes when a step needs an answer that no flag gave. +var errNeedsAnswer = errors.New("setup needs an answer") + +// setupUI asks the questions. Under --yes it never reads: it fails with the flag to pass. +type setupUI struct { + in *bufio.Reader + out io.Writer + yes bool +} + +func (u *setupUI) readLine() (string, error) { + line, err := u.in.ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("read answer: %w", err) + } + return strings.TrimSpace(line), nil +} + +// choose asks for one of options (1-based) and returns its index; flagHint names the flag that +// answers it under --yes. +func (u *setupUI) choose(question, flagHint string, options []string) (int, error) { + if u.yes { + return 0, fmt.Errorf("%w: %s", errNeedsAnswer, flagHint) + } + fmt.Fprintln(u.out, question) + for i, o := range options { + fmt.Fprintf(u.out, " %d) %s\n", i+1, o) + } + for tries := 0; tries < 3; tries++ { + fmt.Fprintf(u.out, "Choose 1-%d: ", len(options)) + ans, err := u.readLine() + if err != nil { + return 0, err + } + for i := range options { + if ans == fmt.Sprint(i+1) { + return i, nil + } + } + } + return 0, errors.New("no valid answer") +} + +// confirm asks a yes/no question; Enter takes def. Under --yes the answer is yes. +func (u *setupUI) confirm(question string, def bool) (bool, error) { + if u.yes { + return true, nil + } + hint := "[Y/n]" + if !def { + hint = "[y/N]" + } + fmt.Fprintf(u.out, "%s %s ", question, hint) + ans, err := u.readLine() + if err != nil { + return false, err + } + switch strings.ToLower(ans) { + case "": + return def, nil + case "y", "yes": + return true, nil + } + return false, nil +} + +// ask reads a free-text answer. +func (u *setupUI) ask(prompt, flagHint string) (string, error) { + if u.yes { + return "", fmt.Errorf("%w: %s", errNeedsAnswer, flagHint) + } + fmt.Fprint(u.out, prompt) + return u.readLine() +} + +// runSetup is the step logic. +func runSetup(env setupEnv, ui *setupUI, o setupOpts) error { + out := ui.out + step := func(n int, title string) { fmt.Fprintf(out, "\n%d/5 %s\n", n, title) } + + // 1. Find the pod. + step(1, "Find the pod") + pods, err := env.find() + if err != nil { + return err + } + switch { + case len(pods) == 0: + return errors.New("no BenchPod found over USB or on this LAN. Check the pod is powered and the USB " + + "cable carries data (many are charge-only), then run setup again; `benchpod discover` " + + "explains what it sees. A pod you know the address of: `benchpod setup --connection
`") + case len(pods) > 1: + return fmt.Errorf("found %d BenchPods; set up one at a time with `benchpod setup --connection ` "+ + "(`benchpod discover` lists them)", len(pods)) + } + p := pods[0] + fmt.Fprintf(out, " Found %s.\n", describeSetupPod(p)) + + // 2. Network. + step(2, "Network") + if p.addr != "" { + fmt.Fprintf(out, " Done: the pod is on the network at %s.\n", p.addr) + } else { + if p.device == "" { + return errors.New("the pod has no network address and is not on USB; connect it over USB and run setup again") + } + addr, err := setupNetwork(env, ui, o, p.device) + if err != nil { + return err + } + p.addr = addr + fmt.Fprintf(out, " The pod is on the network at %s.\n", p.addr) + } + + // 3. Board I/O voltage. + step(3, "Board I/O voltage") + if err := setupLAVoltage(env, ui, o, p.addr); err != nil { + return err + } + + // 4. embeddedci.com. + step(4, "embeddedci.com") + if c := env.cloudStatus(p.addr); c.known { + p.cloud = c + } + name, err := setupCloud(env, ui, o, p) + if err != nil { + return err + } + + // 5. Save the connection. + step(5, "Save the connection") + if err := env.save(p.addr); err != nil { + return err + } + + printSetupNextSteps(out, p.addr, name) + return nil +} + +func describeSetupPod(p setupPod) string { + var parts []string + if p.device != "" { + parts = append(parts, "USB "+p.device) + } + if p.addr != "" { + parts = append(parts, "network "+p.addr) + } else { + parts = append(parts, "no network address yet") + } + if p.firmware != "" { + parts = append(parts, "firmware "+p.firmware) + } + return "a BenchPod: " + strings.Join(parts, ", ") +} + +// setupNetwork gets a pod that is only on USB onto the network and returns its host:port. +func setupNetwork(env setupEnv, ui *setupUI, o setupOpts, device string) (string, error) { + out := ui.out + useWifi := o.ssid != "" + if !useWifi { + i, err := ui.choose(" The pod has no network address yet. How should it connect?", + "the pod has no network address: plug in Ethernet and run setup again, or pass --ssid (and --password-stdin) for Wi-Fi", + []string{"Ethernet (plug in a cable; the pod takes a DHCP lease on its own)", "Wi-Fi"}) + if err != nil { + return "", err + } + useWifi = i == 1 + } + if !useWifi { + fmt.Fprintf(out, " Plug the pod's Ethernet port into your network. Waiting up to %s for a DHCP lease...\n", o.ethernetWait) + addr, err := env.waitForAddress(device, o.ethernetWait) + if err != nil { + return "", fmt.Errorf("no DHCP lease within %s: check the cable and that the network hands out addresses, "+ + "then run setup again (or use Wi-Fi: `benchpod setup --ssid `): %w", o.ethernetWait, err) + } + return addr, nil + } + + ssid := o.ssid + if ssid == "" { + var err error + if ssid, err = ui.ask(" Wi-Fi network name (SSID): ", "pass --ssid"); err != nil { + return "", err + } + if ssid == "" { + return "", errors.New("no SSID given") + } + } + var pw string + var err error + if o.passwordStdin { + pw, err = ui.readLine() + if err == nil && pw == "" { + err = errors.New("empty password on stdin") + } + } else { + if ui.yes { + return "", fmt.Errorf("%w: pass the Wi-Fi password with --password-stdin", errNeedsAnswer) + } + pw, err = env.readPassword() + } + if err != nil { + return "", err + } + addr, err := env.setWifi(device, ssid, pw) + if err != nil { + return "", err + } + if addr == "" { + fmt.Fprintln(out, " Waiting for the pod to report its Wi-Fi address...") + if addr, err = env.waitForAddress(device, 30*time.Second); err != nil { + return "", fmt.Errorf("the pod did not get an address on %q: check the password and that the network is 2.4 GHz, "+ + "then run setup again (`benchpod show-network` shows what it sees): %w", ssid, err) + } + } + return addr, nil +} + +// setupLAVoltage keeps a voltage that is already set unless --la-voltage asks for one. +func setupLAVoltage(env setupEnv, ui *setupUI, o setupOpts, target string) error { + out := ui.out + mv := o.laVoltageMV + if mv == 0 { + cur, err := env.laVoltage(target, 0) + if err != nil { + return err + } + if cur != 0 { + fmt.Fprintf(out, " Done: already %s. Change it with `benchpod la voltage 1.8V|3.3V` if your DUT differs.\n", formatMV(cur)) + return nil + } + i, err := ui.choose(" Which I/O voltage does your target board (DUT) use? The pod's LA pins, UART and SWD use it.", + "the board I/O voltage is not set: pass --la-voltage 3.3V or --la-voltage 1.8V (the DUT's logic level)", + []string{"3.3 V (most boards, including the NUCLEO examples)", "1.8 V"}) + if err != nil { + return err + } + mv = []int{3300, 1800}[i] + } + got, err := env.laVoltage(target, mv) + if err != nil { + return err + } + fmt.Fprintf(out, " Set to %s.\n", formatMV(got)) + return nil +} + +// setupCloud registers an unregistered pod (unless --no-register) and says whose account a +// registered one is on. It returns the pod's device name on the signed-in account, "" if unknown. +func setupCloud(env setupEnv, ui *setupUI, o setupOpts, p setupPod) (string, error) { + out := ui.out + c := p.cloud + if !c.known { + fmt.Fprintln(out, " Skipped: this firmware does not report its registration. Update it (`benchpod flash-self`) and run setup again.") + return "", nil + } + if c.Configured { + who := env.signedIn() + if who == "" { + fmt.Fprintln(out, " Done: the pod is registered to an embeddedci.com account. If it was set up for you, sign in") + fmt.Fprintln(out, " at https://www.embeddedci.com (or `benchpod login`) with the address your activation email") + fmt.Fprintln(out, " was sent to, and it is on your BenchPod page.") + return "", nil + } + if name, ok := env.accountDevices()[strings.TrimSpace(c.DeviceID)]; ok { + fmt.Fprintf(out, " Done: registered to your account (%s) as %s.\n", who, name) + return name, nil + } + fmt.Fprintf(out, " The pod is registered to a different embeddedci.com account than %s, so setup leaves it alone.\n", who) + fmt.Fprintln(out, " If it was set up for you, sign in with the address your activation email was sent to:") + fmt.Fprintln(out, " benchpod login --force") + fmt.Fprintln(out, " A new account does not get the pod: it stays on the account it is registered to.") + return "", nil + } + + if o.noRegister { + fmt.Fprintln(out, " Skipped (--no-register): the pod works over the LAN without an account.") + fmt.Fprintln(out, " Register it later with `benchpod login` and `benchpod register`.") + return "", nil + } + ok, err := ui.confirm(" The pod is not registered. Register it to your embeddedci.com account (web app, CI, remote use)?", true) + if err != nil { + return "", err + } + if !ok { + fmt.Fprintln(out, " Skipped. Register it later with `benchpod register`.") + return "", nil + } + if env.signedIn() == "" { + fmt.Fprintln(out, " Signing in to embeddedci.com first.") + if err := env.login(); err != nil { + return "", err + } + } + if err := env.register(p.addr); err != nil { + return "", err + } + if c := env.cloudStatus(p.addr); c.known && c.DeviceID != "" { + if name, ok := env.accountDevices()[strings.TrimSpace(c.DeviceID)]; ok { + return name, nil + } + } + return "", nil +} + +func printSetupNextSteps(out io.Writer, addr, name string) { + addr = strings.TrimSuffix(addr, ":8080") // the SDK's default port + fmt.Fprintln(out, "\nThe pod is set up. Next steps:") + fmt.Fprintln(out, " • Wire SWD to your target: SWCLK on LA11, SWDIO on LA12, and GND. The DUT's reset") + fmt.Fprintln(out, " line goes to the pod's reset pin.") + fmt.Fprintln(out, " • Flash it with OpenOCD (the pod is the CMSIS-DAP probe), e.g. for a NUCLEO-F446RE:") + fmt.Fprintln(out, " benchpod flash --swclk 11 --swdio 12 --nreset --target target/stm32f4x.cfg --file firmware.elf") + if name != "" { + fmt.Fprintf(out, " • Run tests: `pytest --benchpod-connection=%s` (or embeddedci:%s through embeddedci.com).\n", addr, name) + } else { + fmt.Fprintf(out, " • Run tests: `pytest --benchpod-connection=%s`.\n", addr) + } + fmt.Fprintf(out, " • Guide: %s\n", gettingStartedURL) +} + +// ── the real environment ───────────────────────────────────────────────────── + +type realSetupEnv struct{ g *globalFlags } + +// with returns a copy of the global flags aimed at target. +func (e *realSetupEnv) with(target string) *globalFlags { + g := *e.g + g.connection = target + return &g +} + +func (e *realSetupEnv) find() ([]setupPod, error) { + if raw := strings.TrimSpace(e.g.connection); raw != "" { + spec, err := classifyConnection(raw) + if err != nil { + return nil, err + } + if spec.IsWifi() { + dp := discoveredPod{addr: spec.Addr} + checkPod(&dp) + if !dp.reachable { + return nil, fmt.Errorf("no BenchPod answers at %s: %s", spec.Addr, firstLine(dp.healthErr)) + } + return []setupPod{{addr: spec.Addr, cloud: dp.cloud}}, nil + } + serialPods, _, err := serialconsole.ProbeSerial(spec.Device, 2*time.Second) + if err != nil { + return nil, err + } + var pods []setupPod + for _, sp := range serialPods { + if spec.Device == "" || sp.Device == spec.Device { + pods = append(pods, setupPodFromSerial(sp)) + } + } + return pods, nil + } + + fmt.Println(" Looking over USB and on the LAN (mDNS)...") + serialPods, _, serialErr := serialconsole.ProbeSerial("", 2*time.Second) + netPods, netErr := discoverPods(3 * time.Second) + if serialErr != nil && netErr != nil { + return nil, fmt.Errorf("could not look for a pod: USB: %v; LAN: %v", serialErr, netErr) + } + for i := range netPods { + checkPod(&netPods[i]) + } + correlate(netPods, serialPods) + return mergeSetupPods(serialPods, netPods), nil +} + +// mergeSetupPods turns discover's two lists into one entry per physical pod. A network pod that +// does not answer its API is left out (a stale mDNS record is not a pod to set up). +func mergeSetupPods(serialPods []serialconsole.SerialPod, netPods []discoveredPod) []setupPod { + var pods []setupPod + for _, sp := range serialPods { + p := setupPodFromSerial(sp) + for _, np := range netPods { + if np.sameAs == sp.Device && np.reachable { + p.addr = np.addr + if np.cloud.known { + p.cloud = np.cloud + } + } + } + pods = append(pods, p) + } + for _, np := range netPods { + if np.sameAs == "" && np.reachable { + pods = append(pods, setupPod{addr: np.addr, cloud: np.cloud}) + } + } + return pods +} + +func setupPodFromSerial(sp serialconsole.SerialPod) setupPod { + p := setupPod{device: sp.Device, firmware: sp.Firmware} + if sp.Addressed() { + p.addr = benchpodconfig.EnsurePort(strings.TrimSpace(sp.IP)) + } + if sp.CloudKnown { + p.cloud = cloudState{known: true, Configured: sp.Registered, State: sp.CloudState, DeviceID: sp.DeviceID} + } + return p +} + +func (e *realSetupEnv) waitForAddress(device string, wait time.Duration) (string, error) { + deadline := time.Now().Add(wait) + for { + pods, _, err := serialconsole.ProbeSerial(device, 2*time.Second) + if err == nil { + for _, sp := range pods { + if sp.Device == device && sp.Addressed() { + return benchpodconfig.EnsurePort(strings.TrimSpace(sp.IP)), nil + } + } + } + if time.Now().After(deadline) { + if err != nil { + return "", err + } + return "", errors.New("the pod still reports no address") + } + time.Sleep(3 * time.Second) + } +} + +func (e *realSetupEnv) setWifi(device, ssid, password string) (string, error) { + ip, err := runSetWifi(e.with(device), ssid, password, false) + if err != nil || ip == "" { + return "", err + } + return benchpodconfig.EnsurePort(ip), nil +} + +func (e *realSetupEnv) readPassword() (string, error) { return resolveWifiPassword("", false) } + +func (e *realSetupEnv) laVoltage(target string, mv int) (int, error) { + _, rep, err := laVoltageDo(e.with(target), mv) + return rep.MV, err +} + +func (e *realSetupEnv) cloudStatus(addr string) cloudState { + dp := discoveredPod{addr: addr} + checkPod(&dp) + return dp.cloud +} + +func (e *realSetupEnv) signedIn() string { + who, _ := currentSession("", "") + return who +} + +func (e *realSetupEnv) login() error { return runLogin(defaultServerURL, "", false) } + +func (e *realSetupEnv) accountDevices() map[string]string { return accountDevices() } + +func (e *realSetupEnv) register(addr string) error { + return runRegister(e.with(addr), registerOptions{serverURL: defaultServerURL, wait: 30 * time.Second}) +} + +func (e *realSetupEnv) save(target string) error { return runSetConnection(e.g, target) } diff --git a/cmd/benchpod-cli/setup_test.go b/cmd/benchpod-cli/setup_test.go new file mode 100644 index 0000000..a429a84 --- /dev/null +++ b/cmd/benchpod-cli/setup_test.go @@ -0,0 +1,278 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" +) + +// fakeSetupEnv records what setup did. +type fakeSetupEnv struct { + pods []setupPod + findErr error + leaseAddr string // what waitForAddress returns + wifiAddr string // what setWifi returns + laMV int // the pod's current LA voltage + cloud cloudState + who string + account map[string]string + afterReg cloudState // cloud state after register + + calls []string + wifiSSID string + wifiPW string + setMV int + registered string + saved string +} + +func (f *fakeSetupEnv) find() ([]setupPod, error) { + f.calls = append(f.calls, "find") + return f.pods, f.findErr +} +func (f *fakeSetupEnv) waitForAddress(string, time.Duration) (string, error) { + f.calls = append(f.calls, "wait") + if f.leaseAddr == "" { + return "", errors.New("no lease") + } + return f.leaseAddr, nil +} +func (f *fakeSetupEnv) setWifi(_, ssid, pw string) (string, error) { + f.calls = append(f.calls, "wifi") + f.wifiSSID, f.wifiPW = ssid, pw + return f.wifiAddr, nil +} +func (f *fakeSetupEnv) readPassword() (string, error) { return "typed-pw", nil } +func (f *fakeSetupEnv) laVoltage(_ string, mv int) (int, error) { + if mv != 0 { + f.calls = append(f.calls, "set-la") + f.setMV, f.laMV = mv, mv + } + return f.laMV, nil +} +func (f *fakeSetupEnv) cloudStatus(string) cloudState { return f.cloud } +func (f *fakeSetupEnv) signedIn() string { return f.who } +func (f *fakeSetupEnv) login() error { + f.calls = append(f.calls, "login") + f.who = "you@example.com" + return nil +} +func (f *fakeSetupEnv) accountDevices() map[string]string { return f.account } +func (f *fakeSetupEnv) register(addr string) error { + f.calls = append(f.calls, "register") + f.registered = addr + f.cloud = f.afterReg + return nil +} +func (f *fakeSetupEnv) save(target string) error { + f.calls = append(f.calls, "save") + f.saved = target + return nil +} + +func (f *fakeSetupEnv) did(call string) bool { + for _, c := range f.calls { + if c == call { + return true + } + } + return false +} + +func runSetupWith(t *testing.T, env *fakeSetupEnv, input string, o setupOpts) (string, error) { + t.Helper() + var out bytes.Buffer + ui := &setupUI{in: bufio.NewReader(strings.NewReader(input)), out: &out, yes: o.yes} + err := runSetup(env, ui, o) + return out.String(), err +} + +var registeredMine = cloudState{known: true, Configured: true, State: "connected", DeviceID: "dev-1"} + +func TestSetupOnADonePodOnlySetsTheRequestedVoltage(t *testing.T) { + env := &fakeSetupEnv{ + pods: []setupPod{{device: "/dev/cu.usbmodem1", addr: "192.168.1.221:8080", firmware: "v3.8.0", cloud: registeredMine}}, + laMV: 3300, + cloud: registeredMine, + who: "you@example.com", + account: map[string]string{"dev-1": "benchpod-baea06"}, + } + out, err := runSetupWith(t, env, "", setupOpts{laVoltageMV: 3300, yes: true}) + if err != nil { + t.Fatalf("setup: %v\n%s", err, out) + } + for _, c := range []string{"wait", "wifi", "login", "register"} { + if env.did(c) { + t.Errorf("setup ran %s on a pod that needs nothing\n%s", c, out) + } + } + if env.setMV != 3300 || env.saved != "192.168.1.221:8080" { + t.Errorf("set %d, saved %q", env.setMV, env.saved) + } + for _, want := range []string{"done: the pod is on the network", "registered to your account (you@example.com) as benchpod-baea06", + "--benchpod-connection=192.168.1.221", "LA11", "benchpod-getting-started"} { + if !strings.Contains(strings.ToLower(out), strings.ToLower(want)) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } +} + +func TestSetupKeepsAVoltageAlreadySet(t *testing.T) { + env := &fakeSetupEnv{pods: []setupPod{{addr: "10.0.0.5:8080", cloud: registeredMine}}, laMV: 1800, cloud: registeredMine} + out, err := runSetupWith(t, env, "", setupOpts{yes: true}) + if err != nil { + t.Fatal(err) + } + if env.did("set-la") || !strings.Contains(out, "already 1.8 V") { + t.Fatalf("calls %v\n%s", env.calls, out) + } + // Registered, not signed in: no login, no register, just the advice. + if env.did("login") || env.did("register") || !strings.Contains(out, "activation email") { + t.Fatalf("calls %v\n%s", env.calls, out) + } +} + +func TestSetupAsksForTheVoltageWhenUnset(t *testing.T) { + env := &fakeSetupEnv{pods: []setupPod{{addr: "10.0.0.5:8080"}}} + out, err := runSetupWith(t, env, "2\n", setupOpts{noRegister: true}) + if err != nil { + t.Fatal(err) + } + if env.setMV != 1800 { + t.Fatalf("set %d\n%s", env.setMV, out) + } + // Under --yes there is nobody to ask: fail and name the flag. + env = &fakeSetupEnv{pods: []setupPod{{addr: "10.0.0.5:8080"}}} + _, err = runSetupWith(t, env, "", setupOpts{yes: true}) + if !errors.Is(err, errNeedsAnswer) || !strings.Contains(err.Error(), "--la-voltage") { + t.Fatalf("got %v", err) + } + if env.did("save") { + t.Fatal("saved after failing") + } +} + +func TestSetupWaitsForAnEthernetLease(t *testing.T) { + env := &fakeSetupEnv{pods: []setupPod{{device: "/dev/cu.usbmodem1"}}, leaseAddr: "10.0.0.7:8080", laMV: 3300} + out, err := runSetupWith(t, env, "1\n", setupOpts{noRegister: true, ethernetWait: time.Second}) + if err != nil { + t.Fatal(err) + } + if !env.did("wait") || env.did("wifi") || env.saved != "10.0.0.7:8080" { + t.Fatalf("calls %v saved %q\n%s", env.calls, env.saved, out) + } +} + +func TestSetupJoinsWifi(t *testing.T) { + // Asked interactively. + env := &fakeSetupEnv{pods: []setupPod{{device: "/dev/cu.usbmodem1"}}, wifiAddr: "10.0.0.8:8080", laMV: 3300} + if _, err := runSetupWith(t, env, "2\nlab-net\n", setupOpts{noRegister: true}); err != nil { + t.Fatal(err) + } + if env.wifiSSID != "lab-net" || env.wifiPW != "typed-pw" || env.saved != "10.0.0.8:8080" { + t.Fatalf("ssid %q pw %q saved %q", env.wifiSSID, env.wifiPW, env.saved) + } + // From flags, the password on stdin, and the address only reported afterwards. + env = &fakeSetupEnv{pods: []setupPod{{device: "/dev/cu.usbmodem1"}}, leaseAddr: "10.0.0.9:8080", laMV: 3300} + if _, err := runSetupWith(t, env, "s3cret\n", setupOpts{ssid: "lab-net", passwordStdin: true, yes: true, noRegister: true}); err != nil { + t.Fatal(err) + } + if env.wifiPW != "s3cret" || !env.did("wait") || env.saved != "10.0.0.9:8080" { + t.Fatalf("pw %q calls %v saved %q", env.wifiPW, env.calls, env.saved) + } + // --yes with no network and no --ssid cannot pick for the user. + env = &fakeSetupEnv{pods: []setupPod{{device: "/dev/cu.usbmodem1"}}} + _, err := runSetupWith(t, env, "", setupOpts{yes: true}) + if !errors.Is(err, errNeedsAnswer) || !strings.Contains(err.Error(), "--ssid") { + t.Fatalf("got %v", err) + } + // --yes with --ssid but no --password-stdin. + env = &fakeSetupEnv{pods: []setupPod{{device: "/dev/cu.usbmodem1"}}} + _, err = runSetupWith(t, env, "", setupOpts{yes: true, ssid: "lab-net"}) + if !errors.Is(err, errNeedsAnswer) || !strings.Contains(err.Error(), "--password-stdin") { + t.Fatalf("got %v", err) + } +} + +func TestSetupRegistersAnUnregisteredPod(t *testing.T) { + unreg := cloudState{known: true} + env := &fakeSetupEnv{ + pods: []setupPod{{addr: "10.0.0.5:8080", cloud: unreg}}, cloud: unreg, laMV: 3300, + afterReg: registeredMine, account: map[string]string{"dev-1": "bench-01"}, + } + out, err := runSetupWith(t, env, "", setupOpts{yes: true}) + if err != nil { + t.Fatal(err) + } + if !env.did("login") || env.registered != "10.0.0.5:8080" || !strings.Contains(out, "embeddedci:bench-01") { + t.Fatalf("calls %v\n%s", env.calls, out) + } + // Signed in already: no login. Declined: nothing. + env = &fakeSetupEnv{pods: []setupPod{{addr: "10.0.0.5:8080", cloud: unreg}}, cloud: unreg, laMV: 3300, who: "me@x.com"} + if _, err := runSetupWith(t, env, "n\n", setupOpts{}); err != nil { + t.Fatal(err) + } + if env.did("login") || env.did("register") || !env.did("save") { + t.Fatalf("calls %v", env.calls) + } + // --no-register. + env = &fakeSetupEnv{pods: []setupPod{{addr: "10.0.0.5:8080", cloud: unreg}}, cloud: unreg, laMV: 3300} + if _, err := runSetupWith(t, env, "", setupOpts{yes: true, noRegister: true}); err != nil || env.did("register") { + t.Fatalf("err %v calls %v", err, env.calls) + } +} + +func TestSetupLeavesAPodOnAnotherAccountAlone(t *testing.T) { + env := &fakeSetupEnv{ + pods: []setupPod{{addr: "10.0.0.5:8080", cloud: registeredMine}}, + cloud: registeredMine, laMV: 3300, who: "new@x.com", account: map[string]string{"other": "x"}, + } + out, err := runSetupWith(t, env, "", setupOpts{yes: true}) + if err != nil { + t.Fatal(err) + } + if env.did("register") || !strings.Contains(out, "benchpod login --force") || !strings.Contains(out, "different embeddedci.com account") { + t.Fatalf("calls %v\n%s", env.calls, out) + } +} + +func TestSetupNeedsExactlyOnePod(t *testing.T) { + env := &fakeSetupEnv{} + if _, err := runSetupWith(t, env, "", setupOpts{yes: true}); err == nil || !strings.Contains(err.Error(), "no BenchPod found") { + t.Fatalf("got %v", err) + } + env = &fakeSetupEnv{pods: []setupPod{{addr: "a:8080"}, {addr: "b:8080"}}} + if _, err := runSetupWith(t, env, "", setupOpts{yes: true}); err == nil || !strings.Contains(err.Error(), "--connection") { + t.Fatalf("got %v", err) + } +} + +func TestMergeSetupPodsMatchesUSBAndNetwork(t *testing.T) { + serialPods := []serialconsole.SerialPod{ + {Device: "/dev/a", Firmware: "v3.8.0", IP: "10.0.0.5", CloudKnown: true}, + {Device: "/dev/b", IP: "0.0.0.0"}, + } + netPods := []discoveredPod{ + {addr: "10.0.0.5:8080", reachable: true, sameAs: "/dev/a", cloud: registeredMine}, + {addr: "10.0.0.6:8080", reachable: true}, + {addr: "10.0.0.7:8080"}, // a stale record: not a pod to set up + } + pods := mergeSetupPods(serialPods, netPods) + if len(pods) != 3 { + t.Fatalf("got %+v", pods) + } + if p := pods[0]; p.device != "/dev/a" || p.addr != "10.0.0.5:8080" || !p.cloud.Configured || p.firmware != "v3.8.0" { + t.Errorf("got %+v", p) + } + if p := pods[1]; p.device != "/dev/b" || p.addr != "" { + t.Errorf("got %+v", p) + } + if p := pods[2]; p.addr != "10.0.0.6:8080" || p.device != "" { + t.Errorf("got %+v", p) + } +} diff --git a/cmd/benchpod-cli/status.go b/cmd/benchpod-cli/status.go new file mode 100644 index 0000000..1c65e0d --- /dev/null +++ b/cmd/benchpod-cli/status.go @@ -0,0 +1,337 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" +) + +// ── status ─────────────────────────────────────────────────────────────────── + +func newStatusCmd(g *globalFlags) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "status", + Short: "Show the pod's firmware, I/O voltage, target power, network and cloud state", + Long: "Show a short summary of the pod: firmware and gateware version (and whether a\n" + + "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.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + spec, err := g.resolveConnection() + if err != nil { + return err + } + if spec.IsSerial() { + return runStatusSerial(g, spec.Device) + } + + ctx, cancel, client, err := g.wifiClient("status", 30*time.Second) + if err != nil { + return err + } + defer cancel() + + data, err := client.Command(ctx, map[string]any{"cmd": "status"}) + if err != nil { + return fmt.Errorf("status: %w", err) + } + out, closeOut, err := resolveOutput(g.outputFilename) + if err != nil { + return fmt.Errorf("status: open output: %w", err) + } + defer closeOut() + if asJSON { + printJSON(out, data) + return nil + } + var st podStatus + if err := json.Unmarshal(data, &st); err != nil { + 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) + printStatusSummary(out, sum) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "print the pod's raw status JSON instead of the summary") + return cmd +} + +// runStatusSerial runs the firmware's `status` command over the USB serial +// console and prints its (plain-text) output. +func runStatusSerial(g *globalFlags, device string) error { + console, _, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(15*time.Second)) + if err != nil { + return err + } + defer cancel() + defer console.Close() + + text, err := console.Status(ctx) + if err != nil { + return fmt.Errorf("status: %w", err) + } + out, closeOut, err := resolveOutput(g.outputFilename) + if err != nil { + return fmt.Errorf("status: open output: %w", err) + } + defer closeOut() + fmt.Fprintln(out, text) + return nil +} + +// podStatus is the part of the firmware's `status` reply the summary shows. +type podStatus struct { + Version string `json:"version"` + Board string `json:"board"` + BoardRev string `json:"board_rev"` + Net string `json:"net"` + IP string `json:"ip"` + RSSI *int `json:"rssi_dbm"` + Wifi string `json:"wifi"` + Cloud string `json:"cloud"` + LAVccioMV int `json:"la_vccio_mv"` + Gateware int `json:"gateware"` + GatewareEmbedded int `json:"gateware_embedded"` + SafeMode bool `json:"safe_mode"` + SafeReason string `json:"safe_reason"` + Lease *struct { + Held bool `json:"held"` + Holder string `json:"holder"` + LeftS int `json:"left_s"` + } `json:"lease"` +} + +// targetPower is the firmware's `target_status` reply: eFuse 1 is the internal 5 V rail, eFuse 2 +// the external supply. +type targetPower struct { + Efuse1 struct { + Enabled int `json:"enabled"` + Fault int `json:"fault"` + } `json:"efuse1"` + Efuse2 struct { + Enabled int `json:"enabled"` + Fault int `json:"fault"` + } `json:"efuse2"` +} + +// statusSummary is everything the summary prints; the extras are optional (nil/"" = unknown). +type statusSummary struct { + addr string + st podStatus + cloud *cloudState + power *targetPower + account map[string]string // signed-in account's devices by id, nil = not signed in / unknown + latest string // latest firmware release tag, "" = unknown +} + +// 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) { + 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 + if json.Unmarshal(raw, &c) == nil { + c.known = true + sum.cloud = &c + } + } + cancel() + cctx, cancel = context.WithTimeout(ctx, 5*time.Second) + if raw, err := client.Command(cctx, map[string]any{"cmd": "target_status"}); err == nil { + var p targetPower + if json.Unmarshal(raw, &p) == nil { + sum.power = &p + } + } + cancel() + if sum.cloud != nil && sum.cloud.Configured && strings.TrimSpace(sum.cloud.DeviceID) != "" { + sum.account = accountDevices() + } + sum.latest = latestFirmwareRelease() +} + +// printStatusSummary writes the readable status. +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) + fw := valueOrDash(st.Version) + if s.latest != "" && st.Version != "" { + if versionLess(st.Version, s.latest) { + fw += fmt.Sprintf(" (%s is available: install it from the web app or with `benchpod flash-self`)", s.latest) + } else { + fw += " (latest)" + } + } + row("Firmware", fw) + if st.Gateware != 0 { + gw := strconv.Itoa(st.Gateware) + if st.GatewareEmbedded != 0 && st.GatewareEmbedded != st.Gateware { + gw += fmt.Sprintf(" (the firmware carries %d; it is installed on the next boot)", st.GatewareEmbedded) + } + row("Gateware", gw) + } + if board := strings.TrimSpace(st.Board + " " + st.BoardRev); board != "" { + row("Board", board) + } + if st.LAVccioMV != 0 { + row("I/O voltage", formatMV(st.LAVccioMV)) + } else { + row("I/O voltage", "not set (run `benchpod la voltage 3.3V` or 1.8V; LA, UART and flash need it)") + } + if s.power != nil { + row("Target power", describeTargetPower(*s.power)) + } + row("Network", describeNetwork(st)) + row("Cloud", describeStatusCloud(st, s.cloud, s.account)) + if st.Lease != nil && st.Lease.Held { + row("Lease", fmt.Sprintf("held by cloud job %s (%d s left); the LAN is read-only until it ends", + valueOrDash(st.Lease.Holder), st.Lease.LeftS)) + } + if st.SafeMode { + row("Safe mode", valueOrDash(st.SafeReason)) + } +} + +func describeTargetPower(p targetPower) string { + rail := func(name string, enabled, fault int) string { + switch { + case fault != 0: + return name + " FAULT" + case enabled != 0: + return name + " on" + } + return "" + } + var on []string + for _, r := range []string{rail("internal 5 V", p.Efuse1.Enabled, p.Efuse1.Fault), rail("external", p.Efuse2.Enabled, p.Efuse2.Fault)} { + if r != "" { + on = append(on, r) + } + } + if len(on) == 0 { + return "off" + } + return strings.Join(on, ", ") +} + +func describeNetwork(st podStatus) string { + out := strings.TrimSpace(st.IP) + if out == "" || out == "0.0.0.0" { + out = "no address" + } + if n := strings.TrimSpace(st.Net); n != "" { + out += " (" + n + ")" + } + if w := strings.TrimSpace(st.Wifi); w != "" { + out += ", Wi-Fi " + w + if st.RSSI != nil { + out += fmt.Sprintf(" %d dBm", *st.RSSI) + } + } + return out +} + +// describeStatusCloud says whether the pod is registered and connected, and whose account it is +// on when that is known. +func describeStatusCloud(st podStatus, c *cloudState, account map[string]string) string { + if c == nil { + if st.Cloud != "" { + return st.Cloud + } + return "unknown" + } + if !c.Configured { + return "not registered (`benchpod setup`, or `benchpod login` then `benchpod register`)" + } + out := "registered" + if state := strings.ToLower(strings.TrimSpace(c.State)); state != "" { + out += ", " + state + } + if account != nil { + if name, ok := account[strings.TrimSpace(c.DeviceID)]; ok { + out += ", on your account as " + name + } else { + out += ", on a different account than the one this machine is signed in to" + } + } + return out +} + +// latestFirmwareRelease returns the tag of the latest public firmware release, or "" when it is +// not known within a short deadline. It reads the redirect of GitHub's releases/latest page +// rather than downloading anything. A variable so tests never touch the network. +var latestFirmwareRelease = func() string { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodHead, + "https://github.com/"+defaultFirmwareRepo+"/releases/latest", nil) + if err != nil { + return "" + } + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + return "" + } + _ = resp.Body.Close() + loc := resp.Header.Get("Location") + i := strings.LastIndex(loc, "/tag/") + if i < 0 { + return "" + } + return strings.TrimSpace(loc[i+len("/tag/"):]) +} + +// versionLess compares dotted versions ("3.8.0", "v3.9.1", "3.8.0-rc1" counts as 3.8.0). +// Anything unparsable compares as not less, so the summary never claims an update it is unsure of. +func versionLess(a, b string) bool { + pa, okA := parseVersion(a) + pb, okB := parseVersion(b) + if !okA || !okB { + return false + } + for i := 0; i < 3; i++ { + if pa[i] != pb[i] { + return pa[i] < pb[i] + } + } + return false +} + +func parseVersion(v string) ([3]int, bool) { + var out [3]int + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + if i := strings.IndexAny(v, "-+ "); i >= 0 { + v = v[:i] + } + parts := strings.Split(v, ".") + if len(parts) == 0 || len(parts) > 3 { + return out, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return out, false + } + out[i] = n + } + return out, true +} diff --git a/cmd/benchpod-cli/status_test.go b/cmd/benchpod-cli/status_test.go new file mode 100644 index 0000000..c95eedf --- /dev/null +++ b/cmd/benchpod-cli/status_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/embeddedci-com/benchpod-cli/internal/serverapi" +) + +func TestStatusSummaryIsReadable(t *testing.T) { + var st podStatus + raw := `{"device":"benchpod","version":"3.8.0","board":"stm32h563","board_rev":"v3","net":"ready", + "ip":"192.168.1.221","rssi_dbm":null,"wifi":"off","cloud":"connected","la_vccio_mv":0, + "gateware":48,"gateware_embedded":48,"lease":{"held":true,"holder":"ci-run-42","left_s":37}}` + if err := json.Unmarshal([]byte(raw), &st); err != nil { + t.Fatal(err) + } + p := targetPower{} + p.Efuse1.Enabled = 1 + var out bytes.Buffer + printStatusSummary(&out, statusSummary{ + addr: "192.168.1.221:8080", st: st, power: &p, latest: "v3.9.0", + cloud: &cloudState{known: true, Configured: true, State: "connected", DeviceID: "dev-1"}, + account: map[string]string{"dev-1": "bench-01"}, + }) + got := out.String() + for _, want := range []string{ + "3.8.0 (v3.9.0 is available", "Gateware 48", "I/O voltage not set (run `benchpod la voltage 3.3V`", + "Target power internal 5 V on", "192.168.1.221 (ready), Wi-Fi off", + "registered, connected, on your account as bench-01", "held by cloud job ci-run-42 (37 s left)", + } { + if !strings.Contains(got, want) { + t.Errorf("summary lacks %q:\n%s", want, got) + } + } + if strings.Contains(got, "—") { + t.Errorf("em-dash in output:\n%s", got) + } +} + +func TestStatusCloudLine(t *testing.T) { + st := podStatus{Cloud: "disabled"} + if got := describeStatusCloud(st, &cloudState{known: true}, nil); !strings.Contains(got, "not registered") { + t.Errorf("got %q", got) + } + c := &cloudState{known: true, Configured: true, State: "Connected", DeviceID: "x"} + if got := describeStatusCloud(st, c, nil); got != "registered, connected" { + t.Errorf("got %q", got) + } + if got := describeStatusCloud(st, c, map[string]string{}); !strings.Contains(got, "different account") { + t.Errorf("got %q", got) + } + if got := describeStatusCloud(st, nil, nil); got != "disabled" { + t.Errorf("got %q", got) + } +} + +func TestVersionLess(t *testing.T) { + for _, tc := range []struct { + a, b string + want bool + }{ + {"3.8.0", "v3.9.0", true}, {"v3.8.0", "3.8.0", false}, {"3.10.0", "3.9.9", false}, + {"3.8.0-rc1", "3.8.1", true}, {"dev", "3.8.0", false}, {"3.8", "3.8.1", true}, + } { + if got := versionLess(tc.a, tc.b); got != tc.want { + t.Errorf("versionLess(%q, %q) = %v", tc.a, tc.b, got) + } + } +} + +func TestLoginKeepsTheEmail(t *testing.T) { + path := filepath.Join(t.TempDir(), "token.json") + tok, err := saveTokensFromResponse(path, &serverapi.TokenResponse{AccessToken: "a", RefreshToken: "r", + AccessExpiresIn: 60, RefreshExpiresIn: 600, UserID: "u-1", Email: "you@example.com"}, nil) + if err != nil || tok.Who() != "you@example.com" { + t.Fatalf("got %v %+v", err, tok) + } + // A refresh from an older server sends no email: keep the one we had. + tok, err = saveTokensFromResponse(path, &serverapi.TokenResponse{AccessToken: "b", RefreshToken: "r2", + AccessExpiresIn: 60, RefreshExpiresIn: 600}, tok) + if err != nil || tok.Email != "you@example.com" || tok.UserID != "u-1" { + t.Fatalf("got %v %+v", err, tok) + } + who, _ := currentSession("", path) + if who != "you@example.com" { + t.Fatalf("currentSession = %q", who) + } + // No email at all (old token file): the user id. + tok, _ = saveTokensFromResponse(path, &serverapi.TokenResponse{AccessToken: "c", RefreshToken: "r3", + AccessExpiresIn: 60, RefreshExpiresIn: 600, UserID: "u-2"}, nil) + if tok.Who() != "u-2" { + t.Fatalf("got %q", tok.Who()) + } +} diff --git a/internal/authstore/authstore.go b/internal/authstore/authstore.go index 933b465..56787a6 100644 --- a/internal/authstore/authstore.go +++ b/internal/authstore/authstore.go @@ -25,6 +25,21 @@ type Tokens struct { RefreshExpiresAt time.Time `json:"refresh_expires_at"` SessionID string `json:"session_id"` UserID string `json:"user_id"` + // Email is the account's address, when the server reported it (older servers and + // token files written before it was added have none). + Email string `json:"email,omitempty"` +} + +// Who names the signed-in account for messages: the email address when known, else +// the user id, else "". +func (t *Tokens) Who() string { + if t == nil { + return "" + } + if t.Email != "" { + return t.Email + } + return t.UserID } // DefaultPath returns the canonical token file path, honouring $XDG_CONFIG_HOME first and diff --git a/internal/fwrefusals/refusals.json b/internal/fwrefusals/refusals.json index 024c5a3..0f7faf6 100644 --- a/internal/fwrefusals/refusals.json +++ b/internal/fwrefusals/refusals.json @@ -1,5 +1,5 @@ { - "_comment": "Firmware refusal texts the CLI shows or matches on, copied from benchpod-firmware main (897f057). format is the C string literal exactly as it appears in the source (adjacent literals joined, escapes kept); example is one rendering of it. The CLI tests check every example against its format, that the CLI shows or classifies it as intended, and, when a benchpod-firmware checkout is available (CI checks it out; locally the sibling directory), that each format is still in its source file, so a firmware wording change fails here. tiers pins cmd_tier.c entries for commands the CLI sends.", + "_comment": "Firmware refusal texts the CLI shows or matches on, copied from benchpod-firmware main (897f057; la voltage + pin conflict texts 6e31527). format is the C string literal exactly as it appears in the source (adjacent literals joined, escapes kept); example is one rendering of it. The CLI tests check every example against its format, that the CLI shows or classifies it as intended, and, when a benchpod-firmware checkout is available (CI checks it out; locally the sibling directory), that each format is still in its source file, so a firmware wording change fails here. tiers pins cmd_tier.c entries for commands the CLI sends.", "refusals": [ {"id": "lan_locked", "file": "stm32h563/src/cmd_gate.c", "kind": "json", "format": "locked: %s needs the cloud or the USB console", @@ -25,6 +25,33 @@ {"id": "uart_busy", "file": "stm32h563/src/command_handler_uart.c", "kind": "json", "format": "uart busy", "example": "uart busy"}, + {"id": "la_voltage_unset", "file": "stm32h563/src/command_handler.c", "kind": "json", + "format": "la voltage not set; set it with la_voltage (mv 1800 or 3300) first", + "example": "la voltage not set; set it with la_voltage (mv 1800 or 3300) first"}, + {"id": "pin_conflict", "file": "stm32h563/src/la_pins.c", "kind": "json", + "format": "pin conflict: LA%u is in use by %s; %s", + "example": "pin conflict: LA4 is in use by gpio; release it with {\"cmd\":\"gpio\",\"la\":4,\"mode\":\"off\"}"}, + {"id": "pin_hint_gpio", "file": "stm32h563/src/la_pins.c", "kind": "json", + "format": "release it with {\\\"cmd\\\":\\\"gpio\\\",\\\"la\\\":%u,\\\"mode\\\":\\\"off\\\"}", + "example": "release it with {\"cmd\":\"gpio\",\"la\":4,\"mode\":\"off\"}"}, + {"id": "pin_hint_uart", "file": "stm32h563/src/la_pins.c", "kind": "json", + "format": "stop the uart proxy first", + "example": "stop the uart proxy first"}, + {"id": "pin_hint_swd", "file": "stm32h563/src/la_pins.c", "kind": "json", + "format": "end the SWD session first", + "example": "end the SWD session first"}, + {"id": "pin_hint_sensor", "file": "stm32h563/src/la_pins.c", "kind": "json", + "format": "stop the sensor emulation first ({\\\"cmd\\\":\\\"sensor_stop\\\"})", + "example": "stop the sensor emulation first ({\"cmd\":\"sensor_stop\"})"}, + {"id": "pin_hint_step", "file": "stm32h563/src/la_pins.c", "kind": "json", + "format": "wait for the step train to finish", + "example": "wait for the step train to finish"}, + {"id": "pin_hint_spi", "file": "stm32h563/src/la_pins.c", "kind": "json", + "format": "stop the SPI session first ({\\\"cmd\\\":\\\"spi_stop\\\"})", + "example": "stop the SPI session first ({\"cmd\":\"spi_stop\"})"}, + {"id": "pin_hint_gps", "file": "stm32h563/src/la_pins.c", "kind": "json", + "format": "stop the GPS receiver first ({\\\"cmd\\\":\\\"gps_stop\\\"})", + "example": "stop the GPS receiver first ({\"cmd\":\"gps_stop\"})"}, {"id": "unknown_cmd", "file": "stm32h563/src/command_handler.c", "kind": "json", "format": "unknown cmd", "example": "unknown cmd"}, diff --git a/internal/serverapi/client.go b/internal/serverapi/client.go index 76e1c26..d72cb8c 100644 --- a/internal/serverapi/client.go +++ b/internal/serverapi/client.go @@ -31,6 +31,8 @@ type TokenResponse struct { RefreshExpiresIn int `json:"refresh_expires_in"` SessionID string `json:"session_id"` UserID string `json:"user_id"` + // Email is the signed-in account's address. Servers before it was added omit it. + Email string `json:"email,omitempty"` } // DeviceResponse is the parsed body returned by POST /api/benchpod/devices (and by the