Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d76ae18
usb: network commands over the pod's USB network adapter; usb-bridge,…
wardviaene Oct 13, 2026
b9dfab8
usb: --connection help names the USB network adapter
wardviaene Oct 13, 2026
3cf5bf9
usb-bridge: document the Windows firewall rule
wardviaene Oct 13, 2026
544a9d2
discover: prefer the pod's USB link address; --save stores usb for it
wardviaene Oct 13, 2026
bd51200
usb:<name> picks one of several pods on USB; plain usb lists them
wardviaene Oct 13, 2026
cb5fea4
usb-bridge --watch and a background service: install, uninstall, status
wardviaene Oct 13, 2026
b2d678c
Linux udev rule that keeps ModemManager off the pod's console
wardviaene Oct 13, 2026
e211a7e
status: USB line with the USB network and a weak-port warning; usb us…
wardviaene Oct 13, 2026
900ed64
usb: la voltage, flash, wiring lookups and blob installs over the USB…
wardviaene Oct 13, 2026
3011984
status: power draw row from power_status, with the pod's own current …
wardviaene Oct 13, 2026
b507a49
Merge branch 'usb-net' into usb-net-phase2
wardviaene Oct 13, 2026
7299d88
usb: clear errors when a 3.9.0 console lacks la-voltage or power
wardviaene Oct 13, 2026
3ebe633
flash: import serialconsole (build fix for 7299d88)
wardviaene Oct 13, 2026
09ebe59
la voltage: keep the update-firmware message for consoles without a U…
wardviaene Oct 13, 2026
62a0eaa
usb: recognize the 3.9.0 console's moved reply; tests across firmware…
wardviaene Oct 13, 2026
d0d968f
README: compatibility table (old/new firmware x old/new CLI), Windows…
wardviaene Oct 13, 2026
bb92764
Merge branch 'usb-net' into usb-net-phase2
wardviaene Oct 13, 2026
1b690d0
usb: fall back to the pod's network address when its USB network is off
wardviaene Oct 13, 2026
08a8d32
Merge branch 'usb-net' into usb-net-phase2 (network-address fallback)
wardviaene Oct 13, 2026
5b82e42
usb: ping before falling back to the pod's LAN address
wardviaene Oct 13, 2026
f471e68
Merge branch 'usb-net' into usb-net-phase2
wardviaene Oct 13, 2026
28f0d82
usb: probe the USB network once per run, failures included
wardviaene Oct 13, 2026
766e6f2
Merge branch 'usb-net' into usb-net-phase2
wardviaene Oct 13, 2026
b70a9a5
usb-bridge --watch: never poll the console
wardviaene Oct 13, 2026
9d5050f
Merge branch 'usb-net' into usb-net-phase2
wardviaene Oct 13, 2026
b616905
flash-self: find the pod again by its USB serial number after the update
wardviaene Oct 13, 2026
2564511
README: WSL2 and Docker on Windows are not tested
wardviaene Oct 13, 2026
93c8971
Merge branch 'usb-net' into usb-net-phase2 (flash-self serial-number …
wardviaene Oct 13, 2026
3e02477
usb: CA, proxy and blobs over the USB network; usb-bridge never opens…
wardviaene Oct 13, 2026
e1f8c85
scripts/hw-e2e.sh: CLI hardware check over any connection
wardviaene Oct 13, 2026
bf7a9b5
docs: two pods on one computer are not tested on hardware yet
wardviaene Oct 13, 2026
f88f301
Refusal of a CA/proxy change from the LAN says "or over USB"
wardviaene Oct 13, 2026
b31bcb6
Locked-LAN refusal says "needs the cloud or USB"
wardviaene Oct 13, 2026
2ecdc87
usb-bridge: help says it never opens the console
wardviaene Oct 13, 2026
6e2ce55
usb: network commands find the pod from this computer's USB link
wardviaene Oct 13, 2026
1f89d34
usb: policy show and install-blobs use the local USB link before the …
wardviaene Oct 13, 2026
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
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ archives:
files:
- README.md
- LICENSE
- packaging/linux/70-benchpod.rules

checksum:
name_template: checksums.txt
Expand Down
146 changes: 134 additions & 12 deletions README.md

Large diffs are not rendered by default.

36 changes: 25 additions & 11 deletions cmd/benchpod-cli/blobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func blobsToInstall(m blobsManifest, slots []serialconsole.BlobSlot, force bool)

// installBlobs brings the pod's blob slots in line with the manifest at src, over the USB
// console. only, when non-empty, limits it to those slot names.
func installBlobs(ctx context.Context, console *serialconsole.Console, src blobSource, force bool, only []string) error {
func installBlobs(ctx context.Context, console blobTarget, src blobSource, force bool, only []string) error {
slots, err := console.Blobs(ctx)
if err != nil {
return err
Expand Down Expand Up @@ -170,7 +170,7 @@ func installBlobs(ctx context.Context, console *serialconsole.Console, src blobS
return err
}
manifest := checkBlobSignature(ctx, src, e, data) // report only
fmt.Fprintf(os.Stderr, "blobs: installing %s (%d KB) over USB...\n", e.Name, (e.Size+1023)/1024)
fmt.Fprintf(os.Stderr, "blobs: installing %s (%d KB) through %s...\n", e.Name, (e.Size+1023)/1024, console.Via())
last := -1
start := time.Now()
rep, err := console.UploadSigned(ctx, e.Name, data, e.Version, manifest, func(done, total int) {
Expand Down Expand Up @@ -211,7 +211,8 @@ func newInstallBlobsCmd(g *globalFlags) *cobra.Command {
Short: "Install the gateware and ESP32-C3 images the pod's firmware goes with (over USB)",
Long: "The pod keeps its iCE40 gateware images and its ESP32-C3 Wi-Fi image in the\n" +
"W25Q flash next to the FPGA, not in its firmware. This sends the ones it lacks\n" +
"over the USB console. By default they come from the GitHub release that matches\n" +
"over USB: the pod's USB network adapter when it is up (firmware 3.9.0+), else its\n" +
"USB console. By default they come from the GitHub release that matches\n" +
"the firmware the pod runs; --dir takes them from a local build (stm32h563/build/blobs).\n\n" +
"Each blob's signature (blob-*.bin.sig) is checked and reported, and handed to\n" +
"firmware that checks it too; for now a missing or failing one never stops an install.",
Expand All @@ -221,26 +222,39 @@ func newInstallBlobsCmd(g *globalFlags) *cobra.Command {
if err != nil {
return err
}
console, path, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(blobsInstallTimeout))
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(blobsInstallTimeout))
defer cancel()
defer console.Close()
// This computer's end of the pod's USB network link (firmware 3.9.0+) needs no console;
// else the console names the way in: the USB network when it is up, else the console.
target, closeTarget := g.localBlobTarget(ctx)
path := "the USB network"
if target == nil {
console, cpath, cctx, ccancel, err := g.openSerialConsole(device, g.effectiveTimeout(blobsInstallTimeout))
if err != nil {
return err
}
defer ccancel()
defer console.Close()
ctx, path = cctx, cpath
if target, closeTarget, err = blobTargetFor(ctx, console, path); err != nil {
return err
}
}
defer closeTarget()
src := blobSource{base: dir}
if strings.TrimSpace(dir) == "" {
tag := release
if strings.TrimSpace(tag) == "" {
tag = podFirmwareRelease(ctx, console)
tag = target.Release(ctx)
}
src = releaseBlobSource(tag)
}
fmt.Fprintf(os.Stderr, "blobs: pod on %s, blobs from %s\n", path, src)
fmt.Fprintf(os.Stderr, "blobs: pod on %s (through %s), blobs from %s\n", path, target.Via(), src)
var sel []string
if strings.TrimSpace(only) != "" {
sel = strings.Split(only, ",")
}
return installBlobs(ctx, console, src, force, sel)
return installBlobs(ctx, target, src, force, sel)
},
}
cmd.Flags().StringVar(&dir, "dir", "", "take the blobs from this directory (holding blobs-manifest.json)")
Expand Down
202 changes: 202 additions & 0 deletions cmd/benchpod-cli/blobs_target.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package main

import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"

"github.com/embeddedci-com/benchpod-cli/internal/serialconsole"
"github.com/embeddedci-com/benchpod-cli/internal/tcpclient"
)

// blobTarget is where install-blobs reads the pod's W25Q slots and sends blobs: the pod's network
// API (over its USB network adapter, firmware 3.9.0+: blob_status and ota_* with a target) or its
// USB console (blobs and upload-*, older firmware).
type blobTarget interface {
Blobs(ctx context.Context) ([]serialconsole.BlobSlot, error)
UploadSigned(ctx context.Context, target string, data []byte, version int, manifest []byte,
progress func(done, total int)) (serialconsole.SigReport, error)
// Release is the tag of the firmware the pod runs ("v3.9.0"), "" if unknown.
Release(ctx context.Context) string
// Via names the way in for messages: "/dev/cu.usbmodem1", "10.237.8.85:8080".
Via() string
}

// consoleBlobs is the USB console.
type consoleBlobs struct {
*serialconsole.Console
path string
}

func (c consoleBlobs) Release(ctx context.Context) string { return podFirmwareRelease(ctx, c.Console) }
func (c consoleBlobs) Via() string { return c.path }

// lanBlobChunk is the raw bytes per ota_data line (as the LAN OTA in hwe2e sends).
const lanBlobChunk = 768

// lanBlobs is the pod's JSON API on one connection.
type lanBlobs struct {
lan lanSession
addr string
}

func (l lanBlobs) Via() string { return l.addr }

func (l lanBlobs) Release(ctx context.Context) string {
var st struct {
Version string `json:"version"`
}
if l.lan.cmd(ctx, map[string]any{"cmd": "status"}, &st) != nil || st.Version == "" {
return ""
}
if strings.HasPrefix(st.Version, "v") {
return st.Version
}
return "v" + st.Version
}

func (l lanBlobs) Blobs(ctx context.Context) ([]serialconsole.BlobSlot, error) {
var rep struct {
Blobs []struct {
Name string `json:"name"`
State string `json:"state"`
Size int `json:"size"`
Version int `json:"version"`
SHA256 string `json:"sha256"`
Present bool `json:"present"`
} `json:"blobs"`
}
if err := l.lan.cmd(ctx, map[string]any{"cmd": "blob_status"}, &rep); err != nil {
if pe, ok := tcpclient.AsPodError(err); ok && strings.Contains(pe.Reason, "unknown cmd") {
return nil, serialconsole.ErrNoBlobSlots
}
return nil, err
}
slots := make([]serialconsole.BlobSlot, 0, len(rep.Blobs))
for _, b := range rep.Blobs {
sha := b.SHA256
if !b.Present {
sha = ""
}
slots = append(slots, serialconsole.BlobSlot{Name: b.Name, State: b.State, Size: b.Size, Version: b.Version, SHA256: sha})
}
return slots, nil
}

// otaSession is the pod's ota_* reply.
type otaSession struct {
State string `json:"state"`
Error string `json:"error"`
Sig string `json:"sig"`
SigKey string `json:"sig_key"`
}

func (l lanBlobs) UploadSigned(ctx context.Context, target string, data []byte, version int, manifest []byte,
progress func(done, total int)) (serialconsole.SigReport, error) {
var rep serialconsole.SigReport
if len(data) == 0 {
return rep, fmt.Errorf("upload %s: empty image", target)
}
sum := sha256.Sum256(data)
begin := map[string]any{"cmd": "ota_begin", "size": len(data), "sha256": hex.EncodeToString(sum[:]),
"target": target, "version": version}
if len(manifest) == serialconsole.ManifestLen {
begin["sig"] = base64.RawURLEncoding.EncodeToString(manifest)
rep.Supported, rep.Sent = true, true
}
var st otaSession
if err := l.lan.cmd(ctx, begin, &st); err != nil {
return rep, fmt.Errorf("upload %s: begin: %w", target, err)
}
rep.Result, rep.KeyID = st.Sig, st.SigKey
abort := func() {
if !l.lan.s.Broken() {
_ = l.lan.cmd(ctx, map[string]any{"cmd": "ota_abort"}, nil)
}
}
for off := 0; off < len(data); off += lanBlobChunk {
end := min(off+lanBlobChunk, len(data))
if err := l.lan.cmd(ctx, map[string]any{"cmd": "ota_data", "offset": off,
"data": base64.RawURLEncoding.EncodeToString(data[off:end])}, nil); err != nil {
abort()
return rep, fmt.Errorf("upload %s at %d: %w", target, off, err)
}
if progress != nil {
progress(end, len(data))
}
}
if err := l.lan.cmd(ctx, map[string]any{"cmd": "ota_end"}, &st); err != nil {
abort()
return rep, fmt.Errorf("upload %s: end: %w", target, err)
}
if st.State != "verified" {
abort()
return rep, fmt.Errorf("upload %s: verify: %s", target, valueOr(st.Error, st.State))
}
if err := l.lan.cmd(ctx, map[string]any{"cmd": "ota_commit"}, &st); err != nil {
return rep, fmt.Errorf("upload %s: commit: %w", target, err)
}
if st.State == "error" {
return rep, fmt.Errorf("upload %s: commit: %s", target, valueOr(st.Error, "failed"))
}
return rep, nil
}

// blobTargetFor picks the pod's USB network over its console when it is up (the console's
// status names the address); the caller closes the returned closer.
func blobTargetFor(ctx context.Context, console *serialconsole.Console, path string) (blobTarget, func(), error) {
u, err := console.USBNetwork(ctx)
if errors.Is(err, serialconsole.ErrNoUSBNet) {
return consoleBlobs{Console: console, path: path}, func() {}, nil // firmware before 3.9.0
}
if err != nil {
return nil, func() {}, fmt.Errorf("read the pod's status: %w", err)
}
try := func(addr string, check func(string) error) (blobTarget, func(), bool) {
if check(addr) != nil {
return nil, nil, false
}
lan, err := openLAN(ctx, addr)
if err != nil {
return nil, nil, false
}
return lanBlobs{lan: lan, addr: addr}, func() { lan.s.Close() }, true
}
if u.On && u.Link {
if t, c, ok := try(u.PodIP+":8080", dialUSBPod); ok {
return t, c, nil
}
}
// Firmware 3.9.0+ has no console upload: without the USB network, the pod's Ethernet or Wi-Fi
// address (its external LAN policy applies there).
if u.LANIP != "" {
if t, c, ok := try(u.LANIP+":8080", pingLANPod); ok {
fmt.Fprintf(usbNote, "blobs: the pod's USB network is not usable; using its network address %s:8080\n", u.LANIP)
return t, c, nil
}
}
return nil, func() {}, errors.New("this pod's firmware (3.9.0 or later) takes blobs over the network, and neither its USB " +
"network adapter (Wi-Fi configured on the pod turns it off: `benchpod clear-wifi`) nor its network address answers")
}

// localBlobTarget is the pod's network API on this computer's end of its USB network link
// (firmware 3.9.0+), found without opening the console; nil when there is no such link.
func (g *globalFlags) localBlobTarget(ctx context.Context) (blobTarget, func()) {
spec, err := g.resolveTarget()
if err != nil || !spec.IsSerial() || spec.Device != "" {
return nil, nil
}
addr := localUSBPod(spec.Pod)
if addr == "" {
return nil, nil
}
lan, err := openLAN(ctx, addr)
if err != nil {
return nil, nil
}
return lanBlobs{lan: lan, addr: addr}, func() { lan.s.Close() }
}
78 changes: 78 additions & 0 deletions cmd/benchpod-cli/blobs_target_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"time"

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

// textConsole answers `status` with the given lines and a prompt, like the firmware's console.
type textConsole struct {
status string
out bytes.Buffer
}

func (c *textConsole) Read(p []byte) (int, error) {
if c.out.Len() == 0 {
time.Sleep(5 * time.Millisecond)
return 0, nil
}
return c.out.Read(p)
}

func (c *textConsole) Write(p []byte) (int, error) {
line := strings.TrimSpace(string(bytes.ReplaceAll(p, []byte{0x08}, nil)))
if line != "" {
c.out.WriteString(line + "\r\n")
if line == "status" {
c.out.WriteString(c.status)
}
c.out.WriteString("> ")
}
return len(p), nil
}

func (c *textConsole) Close() error { return nil }

var _ io.ReadWriteCloser = (*textConsole)(nil)

// New CLI + firmware without a usable USB network: install-blobs keeps today's console path.
func TestBlobTargetFallsBackToTheConsole(t *testing.T) {
cases := map[string]string{
"firmware before 3.9.0": " device : benchpod\r\n ip : 192.168.1.220\r\n",
}
for name, status := range cases {
c := serialconsole.NewConsole(&textConsole{status: status})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
target, closeT, err := blobTargetFor(ctx, c, "/dev/cu.usbmodem1")
cancel()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
closeT()
if _, ok := target.(consoleBlobs); !ok || target.Via() != "/dev/cu.usbmodem1" {
t.Fatalf("%s: target %T via %s, want the console", name, target, target.Via())
}
}
}

// Firmware 3.9.0 with Wi-Fi configured and no network address answering: no console upload to fall
// back to, so a clear error.
func TestBlobTargetOn39WithoutANetworkIsAnError(t *testing.T) {
oldPing := pingLANPod
pingLANPod = func(string) error { return errors.New("refused") }
t.Cleanup(func() { pingLANPod = oldPing })
c := serialconsole.NewConsole(&textConsole{status: " device : benchpod\r\n ip : 192.168.1.221\r\n" +
" usb : net off (Wi-Fi configured) pod 10.237.8.85 host 10.237.8.86 link down rx 0 (drop 0) tx 0 (full 0)\r\n"})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, _, err := blobTargetFor(ctx, c, "/dev/cu.usbmodem1"); err == nil || !strings.Contains(err.Error(), "clear-wifi") {
t.Fatalf("err = %v", err)
}
}
Loading
Loading