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
39 changes: 31 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,12 @@ API takes, so the output is the same. It needs:
`https://www.embeddedci.com`), as it does for the SDK.

Commands that stream data or need a direct link to the pod do not go through
embeddedci.com: `capture`, `stream`, `measure`, `test`, `flash`, `spi-flash`,
embeddedci.com: `capture`, `stream`, `measure`, `test`, `flash --file`, `spi-flash`,
`cloud ca set`, `register`, the USB-only commands and `discover`. Over a cloud
connection they refuse with a message pointing to the web app, the Python SDK or
pytest plugin (which take `embeddedci:<name>` for those too), or a network/USB
connection. The CLI has no UART command; use the web app's Terminal or the SDK.
connection. `flash --build` (a build uploaded to embeddedci.com) does work over a
cloud connection: embeddedci.com runs OpenOCD. The CLI has no UART command; use the web app's Terminal or the SDK.

### One wiring profile

Expand All @@ -167,7 +168,8 @@ MCP read the same one) says which LA channel carries which signal. `flash` and
| `flash` | `--target` | `swd_target` |
| `spi-flash` | `--sck`, `--mosi`, `--miso`, `--cs` | `spi_sclk`, `spi_mosi`, `spi_miso`, `spi_cs` |

That happens when the connection is the pod's network address, this machine is
That happens when the connection is the pod's network address (or, for `flash --build`,
`embeddedci:<pod>`), this machine is
signed in (`benchpod login` or `BENCHPOD_API_KEY`) and the pod is on that account
(its `cloud_status` device id is in the account's pod list, as `status` and
`discover` check). Flags always win. The log says which values came from the
Expand Down Expand Up @@ -240,7 +242,7 @@ benchpod show-network # the pod's IP (wired lease included), SSID, state, RS
benchpod clear-wifi

# Firmware:
benchpod flash ... # flash a TARGET/DUT wired to the pod (SWD via OpenOCD CMSIS-DAP)
benchpod flash ... # flash a TARGET/DUT wired to the pod (SWD via OpenOCD CMSIS-DAP; --build: an uploaded build)
benchpod spi-flash ... # read/erase/program an SPI NOR flash on four LA pins (network)
benchpod bootsel # reboot a (retired) RP2350 pod into its UF2 bootloader (USB console only)
benchpod dfu # reboot an STM32 pod into its USB DFU bootloader (USB console only)
Expand Down Expand Up @@ -778,17 +780,38 @@ Pulse an LA pin N times, for step/dir stepper drivers. The FPGA times the train.
### `benchpod flash`

Flash an SWD target wired to the pod, through host-side OpenOCD and the pod's
CMSIS-DAP backend (network or USB; not through embeddedci.com). See
[Flashing: CMSIS-DAP](#flashing-cmsis-dap). Over the network, `--swclk`, `--swdio`,
`--nreset` and `--target` default to the pod's wiring profile (see
CMSIS-DAP backend (network or USB). See
[Flashing: CMSIS-DAP](#flashing-cmsis-dap). Over the network and embeddedci.com,
`--swclk`, `--swdio`, `--nreset` and `--target` default to the pod's wiring profile (see
[One wiring profile](#one-wiring-profile)).

`--build`, `--repo` and `--ref` flash a build uploaded to embeddedci.com (the
`upload-artifact` GitHub action or the pytest `build_report` fixture) instead of a
`--file`. Over embeddedci.com (`--connection embeddedci:<pod>`) embeddedci.com runs
OpenOCD, so nothing needs installing here; on the network or USB the file is downloaded
and flashed by the local OpenOCD. Both need `benchpod login` or `BENCHPOD_API_KEY`.

```bash
benchpod flash --connection embeddedci:bench-1 --repo acme/firmware --ref main
benchpod flash --build 3f9c2a1e-... --target-power 1
```

Without `--artifact` the build's `.elf` is flashed, else its `.hex`, else its `.bin`.
An omitted `--target` falls back to the target the build recorded. A flash run by
embeddedci.com always verifies and resets: `--no-verify`, `--no-reset`,
`--keep-reset-init`, `--openocd`, `-c` and `--openocd-arg` only apply to a local run.

| Flag | Default | Purpose |
|----------------------------|---------|-------------------------------------------------------------------------|
| `--swclk` | (profile `swd_swclk`, else required) | LA pin for SWCLK, 1-14 (`1` or `la1`). |
| `--swdio` | (profile `swd_swdio`, else required) | LA pin for SWDIO, 1-14. |
| `--target` | (profile `swd_target`, else none) | OpenOCD target config, passed as `-f`, e.g. `target/stm32f1x.cfg`. |
| `--file` | (none) | Firmware image to flash, used with `--target`. |
| `--build` | (none) | Flash a build uploaded to embeddedci.com instead of `--file`: its build id (the upload's `build_id`), or `latest` for the newest one matching `--repo`/`--ref`. |
| `--repo` | (none) | With `--build`: the GitHub repository of the uploaded build, `owner/name`. |
| `--ref` | (none) | With `--build`: its branch or tag, e.g. `main`. |
| `--artifact` | (`.elf`, `.hex`, then `.bin`) | With `--build`: the file to flash when the build holds several. |
| `--server-side` | (on over embeddedci.com) | With `--build`: embeddedci.com runs OpenOCD. `--server-side=false` downloads the file and flashes it here (the default on the network and USB). |
| `--load-address` | (none) | Load address for a raw `.bin` image. |
| `--nreset` | (profile `swd_nreset`, else off) | The target's NRST is wired to the pod's reset pin (DUT header J1 pin 22); turns on connect-under-reset. `--nreset=false` overrides the profile. |
| `--no-connect-under-reset` | off | Do not hold the target in reset while connecting. |
Expand All @@ -800,7 +823,7 @@ CMSIS-DAP backend (network or USB; not through embeddedci.com). See
| `--command`, `-c` | (none) | Extra OpenOCD command, repeatable, appended last. |
| `--openocd-arg` | (none) | Extra raw OpenOCD argument, repeatable, appended last. |

Pass `--target` (with `--file`) or at least one `-c`/`--openocd-arg`.
Pass `--target` (with `--file` or `--build`) or at least one `-c`/`--openocd-arg`.

### `benchpod spi-flash`

Expand Down
2 changes: 1 addition & 1 deletion cmd/benchpod-cli/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func cloudRefusal(cmd string, c ConnSpec, usb bool) error {

// cloudCommandList names the commands that work over embeddedci.com, for messages and help.
const cloudCommandList = "status, ping, la voltage, la pullup, la status, la step, generate, " +
"lan-policy, sig-policy, cloud ca show/clear, cloud proxy, deregister"
"lan-policy, sig-policy, cloud ca show/clear, cloud proxy, deregister, flash --build"

// ── set-connection ───────────────────────────────────────────────────────────

Expand Down
63 changes: 58 additions & 5 deletions cmd/benchpod-cli/flash.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/embeddedci-com/benchpod-cli/internal/openocd"
"github.com/embeddedci-com/benchpod-cli/internal/serverapi"
"github.com/embeddedci-com/benchpod-cli/internal/tcpclient"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -66,6 +67,11 @@ type flashFlags struct {
extraConfigs []string
extraArgs []string

// An uploaded build instead of --file (see flashFromBuild).
build, repo, ref, artifact string
// serverSide: --server-side was given (serverSideSet) and its value.
serverSide, serverSideSet bool

// nresetSet says --nreset was given (either way), so the wiring profile does not override it.
nresetSet bool

Expand All @@ -89,10 +95,15 @@ func newFlashCmd(g *globalFlags) *cobra.Command {
f := &flashFlags{}
cmd := &cobra.Command{
Use: "flash",
Short: "Flash an SWD target via OpenOCD's CMSIS-DAP backend (network or USB)",
Short: "Flash an SWD target via OpenOCD's CMSIS-DAP backend (network, USB, or an uploaded build)",
Long: "Flash an SWD target wired to the pod's LA pins: the pod becomes a CMSIS-DAP\n" +
"probe and a local OpenOCD programs the target through it. Works over the network\n" +
"and over USB; not over embeddedci.com (use the web app or the Python SDK there).\n\n" +
"and over USB.\n\n" +
"--build, --repo, --ref and --artifact flash a build uploaded to embeddedci.com\n" +
"(the upload-artifact action or the pytest build_report fixture) instead of --file.\n" +
"Over embeddedci.com (--connection embeddedci:<pod>) embeddedci.com runs OpenOCD, so\n" +
"none is needed here; on the network or USB the file is downloaded and flashed by the\n" +
"local OpenOCD. Both need `benchpod login` or BENCHPOD_API_KEY.\n\n" +
"--swclk, --swdio, --nreset and --target default to swd_swclk, swd_swdio,\n" +
"swd_nreset and swd_target in the pod's wiring profile on embeddedci.com when this\n" +
"machine is signed in (`benchpod login` or BENCHPOD_API_KEY), the pod is on that\n" +
Expand All @@ -101,6 +112,7 @@ func newFlashCmd(g *globalFlags) *cobra.Command {
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
f.nresetSet = cmd.Flags().Changed("nreset")
f.serverSideSet = cmd.Flags().Changed("server-side")
return runFlash(g, f)
},
}
Expand All @@ -110,6 +122,11 @@ func newFlashCmd(g *globalFlags) *cobra.Command {
fl.BoolVar(&f.nreset, "nreset", false, "the target's NRST is wired to the pod's reset pin ("+nrstPinLocation+"); enables connect-under-reset (default: swd_nreset from the pod's wiring profile)")
fl.StringVar(&f.target, "target", "", "OpenOCD target config (passed as -f), e.g. target/stm32f1x.cfg (default: swd_target from the pod's wiring profile)")
fl.StringVar(&f.file, "file", "", "firmware image to flash (used with --target)")
fl.StringVar(&f.build, "build", "", "flash a build uploaded to embeddedci.com instead of --file: its build id, or latest (the newest one matching --repo/--ref)")
fl.StringVar(&f.repo, "repo", "", "with --build: the GitHub repository of the uploaded build, owner/name (newest build of that repo)")
fl.StringVar(&f.ref, "ref", "", "with --build: the branch or tag of the uploaded build, e.g. main")
fl.StringVar(&f.artifact, "artifact", "", "with --build: the file to flash when the build holds several (default: its .elf, then .hex, then .bin)")
fl.BoolVar(&f.serverSide, "server-side", false, "with --build: have embeddedci.com run OpenOCD (default over embeddedci.com); --server-side=false downloads the file and flashes it here (default on the network and USB)")
fl.StringVar(&f.loadAddr, "load-address", "", "load address for a raw .bin image (appended to the program command)")
fl.BoolVar(&f.noVerify, "no-verify", false, "do not verify after programming")
fl.BoolVar(&f.noReset, "no-reset", false, "do not reset the target after programming")
Expand All @@ -127,12 +144,42 @@ func runFlash(g *globalFlags, f *flashFlags) error {
if err != nil {
return err
}
if spec.IsCloud() {
return cloudRefusal("flash", spec, true)
fromBuild := f.fromBuild()
if fromBuild && strings.TrimSpace(f.file) != "" {
return fmt.Errorf("pass either --file or an uploaded build (--build, --repo, --ref, --artifact), not both")
}
if spec.IsNetwork() {
if !fromBuild && f.serverSideSet {
return fmt.Errorf("--server-side only applies to an uploaded build (--build, --repo, --ref)")
}
serverSide := spec.IsCloud()
if f.serverSideSet {
serverSide = f.serverSide
}
if spec.IsCloud() && !fromBuild {
return fmt.Errorf("flash over embeddedci.com (--connection %s%s) flashes a build uploaded there: pass --build, --repo or --ref. "+
"A local --file needs a direct link to the pod: pass its network address (`benchpod discover` finds it) or `usb`",
cloudConnPrefix, spec.Name)
}
if spec.IsCloud() && !serverSide {
return fmt.Errorf("--server-side=false needs a direct link to the pod: pass its network address or `usb` as --connection")
}
if spec.IsNetwork() || spec.IsCloud() {
f.fillFromWiring(g)
}
var art *serverapi.BuildArtifact
if fromBuild {
var cleanup func()
var err error
art, cleanup, err = f.resolveBuild(g, !serverSide)
if err != nil {
return err
}
defer cleanup()
if strings.TrimSpace(f.target) == "" && art.Target() != "" {
f.target = art.Target()
log.Printf("flash: target %s from the build", f.target)
}
}
if strings.TrimSpace(f.swclk) == "" || strings.TrimSpace(f.swdio) == "" {
return fmt.Errorf("--swclk and --swdio are required (or set swd_swclk and swd_swdio in the pod's wiring profile on embeddedci.com)")
}
Expand All @@ -148,11 +195,17 @@ func runFlash(g *globalFlags, f *flashFlags) error {
return fmt.Errorf("--swclk and --swdio must be different pins")
}
if strings.TrimSpace(f.file) != "" && strings.TrimSpace(f.target) == "" {
if fromBuild {
return fmt.Errorf("--target is required: the build recorded none and the wiring profile has no swd_target")
}
return fmt.Errorf("--file requires --target")
}
if f.targetPower != 0 && f.targetPower != 1 && f.targetPower != 2 {
return fmt.Errorf("--target-power must be 1 (internal 5V) or 2 (external)")
}
if fromBuild && serverSide {
return f.flashOnServer(g, spec, art, swclk, swdio)
}
// 1. Identify OpenOCD. 2. Validate it actually runs.
bin := strings.TrimSpace(f.openocdBin)
if bin == "" {
Expand Down
148 changes: 148 additions & 0 deletions cmd/benchpod-cli/flash_build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package main

import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/embeddedci-com/benchpod-cli/internal/serverapi"
)

// flash_build.go: `benchpod flash --build/--repo/--ref/--artifact` flashes a build uploaded to
// embeddedci.com instead of a local --file. Over embeddedci.com the server runs OpenOCD through
// the pod (POST /api/benchpod/devices/{id}/flash); on the network or USB the CLI downloads the file
// and runs the usual local OpenOCD flash. The server picks the artifact (newest matching build,
// .elf before .hex before .bin), so every client flashes the same file for the same selector.

// buildLookupTimeout bounds resolving and downloading the build (images are a few MB at most).
const buildLookupTimeout = 2 * time.Minute

// fromBuild says the flash names an uploaded build rather than a --file.
func (f *flashFlags) fromBuild() bool {
for _, v := range []string{f.build, f.repo, f.ref, f.artifact} {
if strings.TrimSpace(v) != "" {
return true
}
}
return false
}

func (f *flashFlags) buildSelector() serverapi.BuildSelector {
return serverapi.BuildSelector{Build: f.build, Repo: f.repo, Ref: f.ref, Artifact: f.artifact}
}

// resolveBuild asks embeddedci.com which file the selector names. With download it also fetches
// the file into a temp dir and points f.file at it; the returned cleanup removes it.
func (f *flashFlags) resolveBuild(g *globalFlags, download bool) (*serverapi.BuildArtifact, func(), error) {
noop := func() {}
ctx, cancel := context.WithTimeout(context.Background(), buildLookupTimeout)
defer cancel()
api := serverapi.New(cloudServerURL())
cred, err := cloudCredential(ctx, api)
if err != nil {
return nil, noop, fmt.Errorf("flash: %w", err)
}
art, err := api.ResolveBuildArtifact(ctx, cred, f.buildSelector())
if err != nil {
return nil, noop, fmt.Errorf("flash: find the uploaded build: %w", err)
}
if !download {
log.Printf("flash: %s", art.Describe())
return art, noop, nil
}
dir, err := os.MkdirTemp("", "benchpod-build-")
if err != nil {
return nil, noop, err
}
cleanup := func() { _ = os.RemoveAll(dir) }
path := filepath.Join(dir, filepath.Base(art.Name))
out, err := os.Create(path)
if err != nil {
cleanup()
return nil, noop, err
}
err = api.DownloadBuildArtifact(ctx, cred, art, out)
if cerr := out.Close(); err == nil {
err = cerr
}
if err != nil {
cleanup()
return nil, noop, fmt.Errorf("flash: download %s: %w", art.Name, err)
}
log.Printf("flash: downloaded %s", art.Describe())
f.file = path
return art, cleanup, nil
}

// flashOnServer has embeddedci.com flash art through the pod with its own OpenOCD.
func (f *flashFlags) flashOnServer(g *globalFlags, spec ConnSpec, art *serverapi.BuildArtifact, swclk, swdio int) error {
var localOnly []string
for name, set := range map[string]bool{
"--no-verify": f.noVerify, "--no-reset": f.noReset, "--keep-reset-init": f.keepResetInit,
"--openocd": strings.TrimSpace(f.openocdBin) != "", "-c/--command": len(f.extraConfigs) > 0,
"--openocd-arg": len(f.extraArgs) > 0,
} {
if set {
localOnly = append(localOnly, name)
}
}
sort.Strings(localOnly)
if len(localOnly) > 0 {
return fmt.Errorf("%s only apply to the local OpenOCD; embeddedci.com always verifies and resets. Drop them, or flash from a direct connection with --server-side=false",
strings.Join(localOnly, ", "))
}
if strings.TrimSpace(f.target) == "" {
return fmt.Errorf("--target is required (an OpenOCD config such as target/stm32f4x.cfg), or set swd_target in the pod's wiring profile")
}
if spec.IsSerial() {
return fmt.Errorf("--server-side needs the pod's network address or --connection embeddedci:<pod>, not a USB connection")
}
ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(flashTimeout)+time.Minute)
defer cancel()
defer installSignalHandler(ctx, cancel)()
api := serverapi.New(cloudServerURL())
cred, err := cloudCredential(ctx, api)
if err != nil {
return fmt.Errorf("flash: %w", err)
}
deviceID, err := cloudDeviceID(ctx, api, cred, spec)
if err != nil {
return fmt.Errorf("flash: %w", err)
}
if deviceID == "" {
return fmt.Errorf("flash: the pod is not registered on the signed-in embeddedci.com account; flash it here with --server-side=false")
}
req := serverapi.FlashRequest{
JobID: art.JobID, PackID: art.PackID, ArtifactName: art.Name, Target: f.target,
LoadAddr: f.loadAddr, SWCLK: swclk, SWDIO: swdio, NReset: f.nreset, Efuse: f.targetPower,
}
if f.noConnectUnderReset {
off := false
req.ConnectUnderReset = &off
}
fmt.Fprintln(os.Stderr, "flash: embeddedci.com is flashing the target (OpenOCD runs on the server)")
resp, err := api.FlashDevice(ctx, cred, deviceID, req)
if err != nil {
return fmt.Errorf("flash: %w", err)
}
if resp.Output != "" {
fmt.Fprint(os.Stdout, resp.Output)
if !strings.HasSuffix(resp.Output, "\n") {
fmt.Fprintln(os.Stdout)
}
}
if !resp.OK {
if resp.TargetUnreachable {
f.hasNRST = true // the server does not say; assume the pod has its reset pin (rev3+)
printTargetUnreachableHint(f)
}
return fmt.Errorf("flash: %s", resp.Error)
}
fmt.Fprintln(os.Stderr, "flash: success")
return nil
}
Loading
Loading