Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 91 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<name>` | The pod named `<name>` 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`,
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 <addr|device|usb>`

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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 |
|----------------|------------------------------|----------------------------------------------------------|
Expand Down
14 changes: 7 additions & 7 deletions cmd/benchpod-cli/commands_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 "", ""
Expand All @@ -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)"
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion cmd/benchpod-cli/commands_deregister.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions cmd/benchpod-cli/commands_discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions cmd/benchpod-cli/commands_discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cmd/benchpod-cli/commands_register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
108 changes: 59 additions & 49 deletions cmd/benchpod-cli/commands_serial.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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)")
Expand All @@ -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=<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 {
Expand Down
Loading
Loading