From 8bc5a277972d392a2afc14646a1c739c0e365c8a Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 09:18:54 -0500 Subject: [PATCH 1/6] fwrefusals: pin firmware 3.9.0's texts, keep the older ones as removed CI (Merge pull request #27, run 38411480189) failed: firmware 3.9.0 dropped the console upload replies and reworded the locked-LAN and CA/proxy refusals. The CLI still meets the old texts on pods running older firmware, so those entries stay pinned with "removed": "3.9.0" (Check skips them) and the new texts are pinned next to them. Co-Authored-By: Claude Opus 5.5 --- internal/fwrefusals/fwrefusals.go | 6 ++++++ internal/fwrefusals/refusals.json | 23 ++++++++++++++++------- internal/tcpclient/refusals_test.go | 1 + 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/internal/fwrefusals/fwrefusals.go b/internal/fwrefusals/fwrefusals.go index 3f003be..ad81a07 100644 --- a/internal/fwrefusals/fwrefusals.go +++ b/internal/fwrefusals/fwrefusals.go @@ -26,6 +26,9 @@ type Refusal struct { Kind string `json:"kind"` // "json" (an error reply), "console", "console_upload" Format string `json:"format"` // the C literal, escapes kept Example string `json:"example"` // one rendering, without a trailing newline or prompt + // Removed is the firmware version that no longer sends this text ("3.9.0"), "" while current. + // Such a text stays pinned: the CLI still meets it on pods running older firmware. Check skips it. + Removed string `json:"removed,omitempty"` } type doc struct { @@ -133,6 +136,9 @@ func Check(dir string) []string { return s, nil } for _, r := range All() { + if r.Removed != "" { + continue // older firmware only: the current tree has dropped it + } src, err := read(r.File) if err != nil { problems = append(problems, fmt.Sprintf("%s: %v", r.ID, err)) diff --git a/internal/fwrefusals/refusals.json b/internal/fwrefusals/refusals.json index 0f7faf6..262686e 100644 --- a/internal/fwrefusals/refusals.json +++ b/internal/fwrefusals/refusals.json @@ -1,7 +1,10 @@ { "_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", + {"id": "lan_locked_v39", "file": "stm32h563/src/cmd_gate.c", "kind": "json", + "format": "locked: %s needs the cloud or USB", + "example": "locked: cloud_set needs the cloud or USB"}, + {"id": "lan_locked", "file": "stm32h563/src/cmd_gate.c", "removed": "3.9.0", "kind": "json", "format": "locked: %s needs the cloud or the USB console", "example": "locked: cloud_set needs the cloud or the USB console"}, {"id": "lease_busy", "file": "stm32h563/src/cmd_gate.c", "kind": "json", @@ -82,25 +85,31 @@ {"id": "lan_policy_from_lan", "file": "stm32h563/src/pod_policy.c", "kind": "json", "format": "lan_policy: change it from the cloud or the USB console", "example": "lan_policy: change it from the cloud or the USB console"}, - {"id": "cloud_ca_from_lan", "file": "stm32h563/src/pod_policy.c", "kind": "json", + {"id": "cloud_ca_from_lan_v39", "file": "stm32h563/src/pod_policy.c", "kind": "json", + "format": "%s: change it from the cloud or over USB", + "example": "cloud_ca: change it from the cloud or over USB"}, + {"id": "cloud_ca_from_lan", "file": "stm32h563/src/pod_policy.c", "removed": "3.9.0", "kind": "json", "format": "%s: change it from the cloud or the USB console", "example": "cloud_ca: change it from the cloud or the USB console"}, - {"id": "cloud_proxy_from_lan", "file": "stm32h563/src/pod_policy.c", "kind": "json", + {"id": "cloud_proxy_from_lan_v39", "file": "stm32h563/src/pod_policy.c", "kind": "json", + "format": "%s: change it from the cloud or over USB", + "example": "cloud_proxy: change it from the cloud or over USB"}, + {"id": "cloud_proxy_from_lan", "file": "stm32h563/src/pod_policy.c", "removed": "3.9.0", "kind": "json", "format": "%s: change it from the cloud or the USB console", "example": "cloud_proxy: change it from the cloud or the USB console"}, {"id": "console_unknown_command", "file": "stm32h563/src/console.c", "kind": "console", "format": " unknown command '%s' (try 'help')\\r\\n", "example": " unknown command 'cloud-ca' (try 'help')"}, - {"id": "console_upload_busy", "file": "stm32h563/src/console.c", "kind": "console_upload", + {"id": "console_upload_busy", "file": "stm32h563/src/console.c", "removed": "3.9.0", "kind": "console_upload", "format": "upload-data busy\\r\\n> ", "example": "upload-data busy"}, - {"id": "console_upload_retry_crc", "file": "stm32h563/src/console.c", "kind": "console_upload", + {"id": "console_upload_retry_crc", "file": "stm32h563/src/console.c", "removed": "3.9.0", "kind": "console_upload", "format": "upload-data retry crc\\r\\n> ", "example": "upload-data retry crc"}, - {"id": "console_upload_ok", "file": "stm32h563/src/console.c", "kind": "console_upload", + {"id": "console_upload_ok", "file": "stm32h563/src/console.c", "removed": "3.9.0", "kind": "console_upload", "format": "upload-data ok %lu\\r\\n> ", "example": "upload-data ok 4096"}, - {"id": "console_upload_end_busy", "file": "stm32h563/src/console.c", "kind": "console_upload", + {"id": "console_upload_end_busy", "file": "stm32h563/src/console.c", "removed": "3.9.0", "kind": "console_upload", "format": "upload-end error %s\\r\\n", "example": "upload-end error busy: firmware update from the cloud is in progress"} ], diff --git a/internal/tcpclient/refusals_test.go b/internal/tcpclient/refusals_test.go index bd9ea29..81fd0ef 100644 --- a/internal/tcpclient/refusals_test.go +++ b/internal/tcpclient/refusals_test.go @@ -37,6 +37,7 @@ func TestFirmwareRefusalsAreShownVerbatim(t *testing.T) { func TestFirmwareRefusalsArePodErrorsOfTheirKind(t *testing.T) { want := map[string]RefusalKind{ "lan_locked": Locked, + "lan_locked_v39": Locked, "tunnel_forbidden": Forbidden, "lease_busy": Busy, "heavy_busy": Busy, From 460a2ad77ac08cd7a22df7dfcb11e30f2b160556 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 09:26:08 -0500 Subject: [PATCH 2/6] benchpod usb-network [show|on|off]: the pod's USB network setting Firmware 3.9.0's device-only USB network setting. Reading works over any connection; off goes over the USB link itself (no console), on over the USB console (the link does not exist while it is off); Ethernet, Wi-Fi and the cloud are refused before anything is sent. status shows "network off (turned off on the pod ...)" and the usb fallback says why. The console's "net off (setting)" status is parsed; the new refusal text is pinned. README reference and tests. HW (baea06): show over USB and Ethernet, refused over Ethernet, off over the link, on over the console. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++ cmd/benchpod-cli/root.go | 2 +- cmd/benchpod-cli/status.go | 16 +-- cmd/benchpod-cli/usbnet.go | 2 + cmd/benchpod-cli/usbnetwork.go | 149 ++++++++++++++++++++++++++++ cmd/benchpod-cli/usbnetwork_test.go | 68 +++++++++++++ internal/fwrefusals/refusals.json | 3 + internal/serialconsole/policy.go | 2 +- internal/serialconsole/usbnet.go | 4 + 9 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 cmd/benchpod-cli/usbnetwork.go create mode 100644 cmd/benchpod-cli/usbnetwork_test.go diff --git a/README.md b/README.md index cc9e931..fe634b6 100644 --- a/README.md +++ b/README.md @@ -1145,6 +1145,18 @@ Same as `benchpod lan-policy`. Set the LAN policy (cloud with an owner or admin role, or USB). +### `benchpod usb-network [show|on|off]` + +Show the pod's USB network setting, or turn its USB network adapter on or off +(firmware 3.9.0 and later). With `off` the pod re-enumerates as its USB console only, +so the computer sees no network adapter; `on` brings the adapter back. The setting +is stored and is separate from `lan-policy`, which covers Ethernet and Wi-Fi. + +Only the device changes it: `off` over `--connection usb` (the USB link itself, or +the console), `on` over the USB console (the link does not exist while it is off). +Over Ethernet, Wi-Fi or `--connection embeddedci:` it can only be read. +`benchpod status` shows the adapter as "network off (turned off on the pod)". + ### `benchpod sig-policy` Show the pod's signature policy: `audit`, `permissive` or `required`. Same diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index 4b9d789..d96aa29 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -108,7 +108,7 @@ func newRootCmd() *cobra.Command { {"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), + newLanPolicyCmd(g), newSigPolicyCmd(g), newUSBNetworkCmd(g), }}, {"target", "The target (DUT) and analog I/O:", []*cobra.Command{ newFlashCmd(g), newSPIFlashCmd(g), newGenerateCmd(g), newCaptureCmd(g), diff --git a/cmd/benchpod-cli/status.go b/cmd/benchpod-cli/status.go index b5b8b13..db4060c 100644 --- a/cmd/benchpod-cli/status.go +++ b/cmd/benchpod-cli/status.go @@ -123,12 +123,14 @@ type podStatus struct { // podUSB is status.usb: the USB network adapter and what the USB-C source advertises. type podUSB struct { - Net string `json:"net"` - Link bool `json:"link"` - IP string `json:"ip"` - HostIP string `json:"host_ip"` - Source string `json:"source"` // "none", "default", "1.5A", "3.0A"; "" on v2 - SourceMA *int `json:"source_ma"` // nil on v2 + Net string `json:"net"` + Setting string `json:"setting"` // the usb-network setting, "" before it existed + OffReason string `json:"off_reason"` // "setting" or "wifi" when Net is "off" + Link bool `json:"link"` + IP string `json:"ip"` + HostIP string `json:"host_ip"` + Source string `json:"source"` // "none", "default", "1.5A", "3.0A"; "" on v2 + SourceMA *int `json:"source_ma"` // nil on v2 } // describeUSB is the status row for the USB side, with a warning when the port powering the pod @@ -136,6 +138,8 @@ type podUSB struct { func describeUSB(u podUSB) string { var parts []string switch { + case u.Net == "off" && u.OffReason == "setting": + parts = append(parts, "network off (turned off on the pod: `benchpod usb-network on` over its USB console)") case u.Net == "off": parts = append(parts, "network off (Wi-Fi is configured)") case u.Link: diff --git a/cmd/benchpod-cli/usbnet.go b/cmd/benchpod-cli/usbnet.go index a6fab50..0235ca9 100644 --- a/cmd/benchpod-cli/usbnet.go +++ b/cmd/benchpod-cli/usbnet.go @@ -69,6 +69,8 @@ func (g *globalFlags) usbNetAddr(device string) (string, error) { addr := net.JoinHostPort(u.PodIP, "8080") var why error switch { + case !u.On && u.OffBySetting: + why = errors.New("USB networking is turned off on the pod (`benchpod usb-network on` over its USB console turns it back on)") case !u.On: why = errors.New("USB networking is off because Wi-Fi is configured on the pod (`benchpod clear-wifi` turns it back on)") case !u.Link: diff --git a/cmd/benchpod-cli/usbnetwork.go b/cmd/benchpod-cli/usbnetwork.go new file mode 100644 index 0000000..552bff6 --- /dev/null +++ b/cmd/benchpod-cli/usbnetwork.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" +) + +// The pod's USB network setting (firmware 3.9.0+): whether its USB port is also a network +// adapter. Only the device changes it: the USB console, or the USB link itself (which can turn it +// off, not back on: the link is gone then). Ethernet, Wi-Fi and the cloud may read it. + +const usbNetworkMissing = "this firmware has no USB network setting; update the pod's firmware (3.9.0 or later)" + +func newUSBNetworkCmd(g *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "usb-network [show|on|off]", + Short: "Show, or on the device turn on or off, the pod's USB network adapter", + Long: "Show the pod's USB network setting, or turn the adapter on or off (firmware 3.9.0+).\n\n" + + "Off, the pod re-enumerates as its USB console only: the computer sees no network\n" + + "adapter. The setting is stored, and is separate from lan-policy (which covers Ethernet\n" + + "and Wi-Fi). Only the device changes it: `off` over --connection usb (the USB link or the\n" + + "console), `on` over the USB console. Over Ethernet, Wi-Fi or embeddedci.com it can only\n" + + "be read.", + Args: cobra.MaximumNArgs(1), + ValidArgs: []string{"show", "on", "off"}, + RunE: func(cmd *cobra.Command, args []string) error { + set := "" + if len(args) == 1 { + switch args[0] { + case "show": + case "on", "off": + set = args[0] + default: + return fmt.Errorf("usb-network: use show, on or off (got %q)", args[0]) + } + } + out, closeOut, err := resolveOutput(g.outputFilename) + if err != nil { + return err + } + defer closeOut() + return runUSBNetwork(g, set, out) + }, + } +} + +func runUSBNetwork(g *globalFlags, set string, out io.Writer) error { + what := "usb-network" + if set != "" { + what += " " + set + } + spec, err := g.resolveTarget() + if err != nil { + return err + } + if !spec.IsSerial() { + if set != "" { + return fmt.Errorf("%s: only the device changes it: run it over USB (--connection usb); "+ + "Ethernet, Wi-Fi and embeddedci.com may only read the setting", what) + } + ctx, cancel, t, err := g.podClient(what, 15*time.Second) + if err != nil { + return err + } + defer cancel() + setting, err := usbNetworkJSON(ctx, t.client, "") + if err != nil { + return fmt.Errorf("%s: %w", what, err) + } + printUSBNetwork(out, t.label, setting, "") + return nil + } + // Reading and turning off work over the USB link itself when it is up (no console needed); + // turning on needs the console (the link does not exist while the setting is off). + if set != "on" && spec.Device == "" { + if addr := localUSBPod(spec.Pod); addr != "" { + ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(15*time.Second)) + defer cancel() + setting, err := usbNetworkJSON(ctx, &tcpclient.Client{Addr: addr}, set) + if err != nil { + return fmt.Errorf("%s: %w", what, err) + } + printUSBNetwork(out, addr, setting, set) + return nil + } + } + console, path, ctx, cancel, err := g.openSerialConsole(spec.Device, g.effectiveTimeout(15*time.Second)) + if err != nil { + return err + } + defer cancel() + defer console.Close() + rep, err := console.Policy(ctx, "usb-network", set) + if err != nil { + if errors.Is(err, serialconsole.ErrPolicyUnsupported) { + return errors.New(usbNetworkMissing) + } + if pe, ok := tcpclient.AsPodError(err); ok { + return refusedError(what, pe, false) + } + return fmt.Errorf("%s: %w", what, err) + } + printUSBNetwork(out, path, rep.Policy, set) + return nil +} + +// usbNetworkJSON reads, or with set changes, the setting over the JSON API. +func usbNetworkJSON(ctx context.Context, c podCommander, set string) (string, error) { + req := map[string]any{"cmd": "usb_network"} + if set != "" { + req["set"] = set + } + data, err := c.Command(ctx, req) + if err != nil { + if strings.Contains(err.Error(), "unknown cmd") { + return "", errors.New(usbNetworkMissing) + } + return "", err + } + var rep struct { + Setting string `json:"setting"` + } + if err := json.Unmarshal(data, &rep); err != nil || rep.Setting == "" { + return "", fmt.Errorf("unexpected reply %s", strings.TrimSpace(string(data))) + } + return rep.Setting, nil +} + +func printUSBNetwork(out io.Writer, via, setting, set string) { + switch { + case set == "": + fmt.Fprintf(out, "USB network of the pod at %s: %s\n", via, setting) + case setting == "off": + fmt.Fprintf(out, "USB network of the pod at %s is now off: it re-enumerates as its USB console only "+ + "(no network adapter). Turn it back on with `benchpod usb-network on` over the USB console.\n", via) + default: + fmt.Fprintf(out, "USB network of the pod at %s is now on: the adapter comes up in a few seconds.\n", via) + } +} diff --git a/cmd/benchpod-cli/usbnetwork_test.go b/cmd/benchpod-cli/usbnetwork_test.go new file mode 100644 index 0000000..60870a0 --- /dev/null +++ b/cmd/benchpod-cli/usbnetwork_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" +) + +// Over Ethernet/Wi-Fi the setting is read; changing it is refused before anything is sent. +func TestUSBNetworkOnTheLANReadsOnly(t *testing.T) { + addr, got := fakeLANPod(t, `{"status":"ok","data":{"setting":"on"}}`) + g := &globalFlags{connection: addr} + var out bytes.Buffer + if err := runUSBNetwork(g, "", &out); err != nil || !strings.Contains(out.String(), ": on") { + t.Fatalf("show: %v %q", err, out.String()) + } + if req := <-got; req != `{"cmd":"usb_network"}` { + t.Fatalf("request %s", req) + } + err := runUSBNetwork(g, "off", &out) + if err == nil || !strings.Contains(err.Error(), "only the device") || len(got) != 0 { + t.Fatalf("set over the LAN: %v (sent %d)", err, len(got)) + } +} + +// Over USB, `off` goes over the pod's USB link itself (no console); the pod's reply is printed. +func TestUSBNetworkOffOverTheUSBLink(t *testing.T) { + addr, got := fakeLANPod(t, `{"status":"ok","data":{"setting":"off"}}`) + old := localUSBPod + t.Cleanup(func() { localUSBPod = old }) + localUSBPod = func(string) string { return addr } + var out bytes.Buffer + if err := runUSBNetwork(&globalFlags{connection: "usb"}, "off", &out); err != nil { + t.Fatal(err) + } + if req := <-got; !strings.Contains(req, `"set":"off"`) || !strings.Contains(req, `"usb_network"`) { + t.Fatalf("request %s", req) + } + if !strings.Contains(out.String(), "now off") || !strings.Contains(out.String(), "usb-network on") { + t.Fatalf("output %q", out.String()) + } +} + +// Older firmware: a clear "update the firmware". +func TestUSBNetworkOnOlderFirmware(t *testing.T) { + addr, _ := fakeLANPod(t, `{"status":"error","message":"unknown cmd"}`) + err := runUSBNetwork(&globalFlags{connection: addr}, "", &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "3.9.0 or later") { + t.Fatalf("err = %v", err) + } +} + +// The console's status line says why the adapter is off. +func TestParseUSBNetOffBySetting(t *testing.T) { + u, err := serialconsole.ParseUSBNet(" usb : net off (setting) pod 10.237.8.85 host 10.237.8.86 link down rx 1 (drop 0) tx 1 (full 0)\r\n") + if err != nil || u.On || !u.OffBySetting { + t.Fatalf("%+v %v", u, err) + } + u, _ = serialconsole.ParseUSBNet(" usb : net off (Wi-Fi configured) pod 10.237.8.85 host 10.237.8.86 link down\r\n") + if u.On || u.OffBySetting { + t.Fatalf("wifi: %+v", u) + } + if got := describeUSB(podUSB{Net: "off", OffReason: "setting"}); !strings.Contains(got, "usb-network on") { + t.Fatalf("status row %q", got) + } +} diff --git a/internal/fwrefusals/refusals.json b/internal/fwrefusals/refusals.json index 262686e..d3b9a20 100644 --- a/internal/fwrefusals/refusals.json +++ b/internal/fwrefusals/refusals.json @@ -85,6 +85,9 @@ {"id": "lan_policy_from_lan", "file": "stm32h563/src/pod_policy.c", "kind": "json", "format": "lan_policy: change it from the cloud or the USB console", "example": "lan_policy: change it from the cloud or the USB console"}, + {"id": "usb_network_not_device", "file": "stm32h563/src/pod_policy.c", "kind": "json", + "format": "usb_network: change it on the device (the USB console, or over the USB link)", + "example": "usb_network: change it on the device (the USB console, or over the USB link)"}, {"id": "cloud_ca_from_lan_v39", "file": "stm32h563/src/pod_policy.c", "kind": "json", "format": "%s: change it from the cloud or over USB", "example": "cloud_ca: change it from the cloud or over USB"}, diff --git a/internal/serialconsole/policy.go b/internal/serialconsole/policy.go index 08deda4..74aa855 100644 --- a/internal/serialconsole/policy.go +++ b/internal/serialconsole/policy.go @@ -33,7 +33,7 @@ type PolicyError = CommandError // Policy runs the console command cmd ("lan-policy" or "sig-policy"), setting the policy to set // when it is not empty, and returns what the pod reports afterwards. func (c *Console) Policy(ctx context.Context, cmd, set string) (PolicyReply, error) { - if cmd != "lan-policy" && cmd != "sig-policy" { + if cmd != "lan-policy" && cmd != "sig-policy" && cmd != "usb-network" { return PolicyReply{}, fmt.Errorf("unknown policy command %q", cmd) } line := cmd diff --git a/internal/serialconsole/usbnet.go b/internal/serialconsole/usbnet.go index 407cac4..7dc7161 100644 --- a/internal/serialconsole/usbnet.go +++ b/internal/serialconsole/usbnet.go @@ -21,6 +21,9 @@ type USBNet struct { PodIP string HostIP string LANIP string // the status `ip` line: the pod's Ethernet (else Wi-Fi) address, "" when it has none + // OffBySetting: off because the pod's usb-network setting is off, not because Wi-Fi is + // configured (the console line reads "net off (setting)"). + OffBySetting bool } // ErrMovedToNetwork is a console command firmware 3.9.0 moved to the JSON API: the console answers @@ -55,6 +58,7 @@ func ParseUSBNet(status string) (USBNet, error) { switch f[i] { case "net": u.On = f[i+1] == "on" + u.OffBySetting = !u.On && i+2 < len(f) && f[i+2] == "(setting)" case "pod": u.PodIP = f[i+1] case "host": From 5169256eb371d5763325696ef6754a7f6c8f6bce Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 09:30:07 -0500 Subject: [PATCH 3/6] Firmware-update reports: flash-self and install-blobs tell embeddedci.com After flash-self (the pod before, when it was running, and as it came back) and after install-blobs installed something, the CLI posts the pod's identity public key and name, old/new firmware and gateware, the blobs, the method (dfu, usb_network, usb_console, lan), the CLI version and the time to POST /api/benchpod/firmware-events. Best effort: 5 s timeout, never fails the update, silent when not signed in, one more try on a later run from firmware-reports.jsonl next to the token. Opt out: --no-firmware-report or BENCHPOD_NO_FIRMWARE_REPORT=1 (README section). Tests stub the server. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++ cmd/benchpod-cli/blobs.go | 27 ++- cmd/benchpod-cli/blobs_target.go | 32 ++++ cmd/benchpod-cli/flash_self.go | 30 +++- cmd/benchpod-cli/flash_self_test.go | 2 +- cmd/benchpod-cli/fwreport.go | 268 ++++++++++++++++++++++++++++ cmd/benchpod-cli/fwreport_test.go | 94 ++++++++++ cmd/benchpod-cli/podclient_test.go | 4 + cmd/benchpod-cli/root.go | 4 + 9 files changed, 461 insertions(+), 17 deletions(-) create mode 100644 cmd/benchpod-cli/fwreport.go create mode 100644 cmd/benchpod-cli/fwreport_test.go diff --git a/README.md b/README.md index fe634b6..192908c 100644 --- a/README.md +++ b/README.md @@ -593,6 +593,23 @@ and `token.json` for the embeddedci.com session. | `--config-file` | `~/.config/benchpod-cli/config.json` | The CLI's config file, where `set-connection` saves the default. | | `--output-filename` | (stdout) | Write command output to this file instead of stdout. | | `--timeout` | `0` (per-command default) | Overall command deadline; `0` uses each command's own default. | +| `--no-firmware-report` | `false` | Do not report firmware and blob updates to embeddedci.com (also `BENCHPOD_NO_FIRMWARE_REPORT=1`); see [Firmware-update reports](#firmware-update-reports). | + +### Firmware-update reports + +When you are signed in to embeddedci.com (`benchpod login` or `BENCHPOD_API_KEY`), +`flash-self` and `install-blobs` tell embeddedci.com which pod they updated, so the +server keeps each pod's firmware history next to what the pod reports when it +connects. A report holds the pod's identity public key and name, the firmware and +gateware versions before and after, the blobs installed, how they were installed +(`dfu`, `usb_network`, `usb_console`, `lan`), the CLI version and the time. Nothing +else from your computer is sent. + +The report never slows down or fails an update: it is sent with a short timeout, +nothing is sent when you are not signed in, and a report that could not be sent +(offline) is kept in `~/.config/benchpod-cli/firmware-reports.jsonl` for one more +try on a later run. Turn it off with `--no-firmware-report` or +`BENCHPOD_NO_FIRMWARE_REPORT=1`. ### Exit codes diff --git a/cmd/benchpod-cli/blobs.go b/cmd/benchpod-cli/blobs.go index 2b97c05..cc79aab 100644 --- a/cmd/benchpod-cli/blobs.go +++ b/cmd/benchpod-cli/blobs.go @@ -138,13 +138,19 @@ 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 blobTarget, src blobSource, force bool, only []string) error { + _, err := installBlobsList(ctx, console, src, force, only) + return err +} + +// installBlobsList is installBlobs, returning the blobs it installed (for the firmware report). +func installBlobsList(ctx context.Context, console blobTarget, src blobSource, force bool, only []string) ([]blobEntry, error) { slots, err := console.Blobs(ctx) if err != nil { - return err + return nil, err } m, err := loadBlobsManifest(ctx, src) if err != nil { - return err + return nil, err } todo := blobsToInstall(m, slots, force) if len(only) > 0 { @@ -162,12 +168,13 @@ func installBlobs(ctx context.Context, console blobTarget, src blobSource, force } if len(todo) == 0 { fmt.Fprintln(os.Stderr, "blobs: the pod already holds every blob this firmware goes with") - return nil + return nil, nil } + var done []blobEntry for _, e := range todo { data, err := fetchBlob(ctx, src, e) if err != nil { - return err + return done, err } manifest := checkBlobSignature(ctx, src, e, data) // report only fmt.Fprintf(os.Stderr, "blobs: installing %s (%d KB) through %s...\n", e.Name, (e.Size+1023)/1024, console.Via()) @@ -180,14 +187,15 @@ func installBlobs(ctx context.Context, console blobTarget, src blobSource, force } }) if err != nil { - return err + return done, err } if line := podSigLine(rep); line != "" { fmt.Fprintf(os.Stderr, " %s %s\n", e.Name, line) } fmt.Fprintf(os.Stderr, "blobs: %s installed (%.1f s)\n", e.Name, time.Since(start).Seconds()) + done = append(done, e) } - return nil + return done, nil } // podFirmwareRelease is the release tag of the firmware a pod runs ("v3.4.0"), "" if unknown. @@ -254,7 +262,12 @@ func newInstallBlobsCmd(g *globalFlags) *cobra.Command { if strings.TrimSpace(only) != "" { sel = strings.Split(only, ",") } - return installBlobs(ctx, target, src, force, sel) + before, _ := target.Blobs(ctx) + installed, err := installBlobsList(ctx, target, src, force, sel) + if len(installed) > 0 { + g.reportFirmware(blobsEvent(ctx, target, before, installed)) + } + return err }, } cmd.Flags().StringVar(&dir, "dir", "", "take the blobs from this directory (holding blobs-manifest.json)") diff --git a/cmd/benchpod-cli/blobs_target.go b/cmd/benchpod-cli/blobs_target.go index 71db30d..f74c0fb 100644 --- a/cmd/benchpod-cli/blobs_target.go +++ b/cmd/benchpod-cli/blobs_target.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "fmt" + "net" "strings" "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" @@ -24,6 +25,11 @@ type blobTarget interface { Release(ctx context.Context) string // Via names the way in for messages: "/dev/cu.usbmodem1", "10.237.8.85:8080". Via() string + // Identity is the pod's identity public key and short id ("" when unknown), for the + // firmware report. + Identity(ctx context.Context) (publicKey, shortID string) + // Method is the firmware report's name for the way in: "usb_console", "usb_network", "lan". + Method() string } // consoleBlobs is the USB console. @@ -34,6 +40,15 @@ type consoleBlobs struct { func (c consoleBlobs) Release(ctx context.Context) string { return podFirmwareRelease(ctx, c.Console) } func (c consoleBlobs) Via() string { return c.path } +func (c consoleBlobs) Method() string { return "usb_console" } + +func (c consoleBlobs) Identity(ctx context.Context) (string, string) { + id, err := c.Console.Identity(ctx) + if err != nil { + return "", "" + } + return id.PublicKey, id.ShortID +} // lanBlobChunk is the raw bytes per ota_data line (as the LAN OTA in hwe2e sends). const lanBlobChunk = 768 @@ -46,6 +61,23 @@ type lanBlobs struct { func (l lanBlobs) Via() string { return l.addr } +func (l lanBlobs) Method() string { + if host, _, err := net.SplitHostPort(l.addr); err == nil && usbLinkNet.Contains(net.ParseIP(host)) { + return "usb_network" + } + return "lan" +} + +func (l lanBlobs) Identity(ctx context.Context) (string, string) { + var rep struct { + Public string `json:"public"` + } + if l.lan.cmd(ctx, map[string]any{"cmd": "identity_public"}, &rep) != nil || rep.Public == "" { + return "", "" + } + return rep.Public, shortIDOf(rep.Public) +} + func (l lanBlobs) Release(ctx context.Context) string { var st struct { Version string `json:"version"` diff --git a/cmd/benchpod-cli/flash_self.go b/cmd/benchpod-cli/flash_self.go index e405193..8a3c40e 100644 --- a/cmd/benchpod-cli/flash_self.go +++ b/cmd/benchpod-cli/flash_self.go @@ -149,6 +149,12 @@ func newFlashSelfCmd(g *globalFlags) *cobra.Command { // Report on the release's signature (.sig). Report only: never refuses. checkFirmwareSignature(ctx, location, file) + // The pod as it is now, for the firmware report (nothing when it is already in DFU). + var before podVersions + if !g.fwReportOptOut() && !dfuDevicePresent(ctx, dfuPath) { + before = g.runningPodVersions() + } + // 2. Make sure the pod is in DFU mode (guiding the user if not). if err := ensureDfuReady(ctx, g, f, dfuPath); err != nil { return err @@ -181,8 +187,11 @@ func newFlashSelfCmd(g *globalFlags) *cobra.Command { if strings.TrimSpace(f.blobsDir) != "" { src = blobSource{base: f.blobsDir} } - return installBlobsAfterFlash(g, src) + after, installed, err := installBlobsAfterFlash(g, src) + g.reportFirmware(firmwareEvent(before, after, f.firmwareVer, installed)) + return err } + g.reportFirmware(firmwareEvent(before, podVersions{}, f.firmwareVer, nil)) fmt.Fprintln(os.Stderr, " If `benchpod discover` does not find it within 30 s, unplug USB-C and plug it back in (without BOOT0).") } return nil @@ -510,7 +519,9 @@ var ( // blobs that go with its firmware. A pod whose firmware still embeds its blobs (older releases) // has no slots to fill and is left alone. Anything else that keeps the blobs from being // installed is an error, so flash-self does not report success for a pod that is missing them. -func installBlobsAfterFlash(g *globalFlags, src blobSource) error { +// installBlobsAfterFlash waits for the flashed pod, installs its blobs, and returns the pod's +// versions as it came back and the blobs it installed (for the firmware report). +func installBlobsAfterFlash(g *globalFlags, src blobSource) (after podVersions, installed []blobEntry, err error) { fmt.Fprintln(os.Stderr, "flash-self: waiting for the pod's USB console to install its blobs (after an update to 3.9.0 "+ "it comes back under a new USB ID: Windows gives it a new COM port and may install drivers first)...") var console *serialconsole.Console @@ -522,13 +533,14 @@ func installBlobsAfterFlash(g *globalFlags, src blobSource) error { break } if time.Now().After(deadline) { - return fmt.Errorf("flash-self: the firmware is flashed, but the pod's USB console did not come back, so its blobs are not installed: unplug USB-C and plug it back in (without BOOT0), then run `benchpod install-blobs` (%w)", err) + return after, installed, fmt.Errorf("flash-self: the firmware is flashed, but the pod's USB console did not come back, so its blobs are not installed: unplug USB-C and plug it back in (without BOOT0), then run `benchpod install-blobs` (%w)", err) } time.Sleep(flashedConsoleRetry) } defer console.Close() ctx, cancel := context.WithTimeout(context.Background(), blobsInstallTimeout) defer cancel() + after = consoleVersions(ctx, console) target := blobTarget(consoleBlobs{Console: console, path: "the USB console"}) if !skipUSBNetForBlobs { // Firmware before 3.9.0: the console. 3.9.0+: the USB network (or the pod's network @@ -552,19 +564,19 @@ func installBlobsAfterFlash(g *globalFlags, src blobSource) error { time.Sleep(time.Second) } if lastErr != nil { - return fmt.Errorf("flash-self: the firmware is flashed, but its blobs are not installed: %w "+ + return after, installed, fmt.Errorf("flash-self: the firmware is flashed, but its blobs are not installed: %w "+ "(retry with `benchpod install-blobs`)", lastErr) } } if _, err := target.Blobs(ctx); err != nil { if errors.Is(err, serialconsole.ErrNoBlobSlots) { fmt.Fprintln(os.Stderr, "flash-self: this firmware keeps its blobs built in; nothing to install") - return nil + return after, installed, nil } - return fmt.Errorf("flash-self: the firmware is flashed, but reading the pod's blob slots failed, so its blobs are not installed (retry with `benchpod install-blobs`): %w", err) + return after, installed, fmt.Errorf("flash-self: the firmware is flashed, but reading the pod's blob slots failed, so its blobs are not installed (retry with `benchpod install-blobs`): %w", err) } - if err := installBlobs(ctx, target, src, false, nil); err != nil { - return fmt.Errorf("flash-self: the firmware is flashed, but installing its blobs failed (retry with `benchpod install-blobs`): %w", err) + if installed, err = installBlobsList(ctx, target, src, false, nil); err != nil { + return after, installed, fmt.Errorf("flash-self: the firmware is flashed, but installing its blobs failed (retry with `benchpod install-blobs`): %w", err) } - return nil + return after, installed, nil } diff --git a/cmd/benchpod-cli/flash_self_test.go b/cmd/benchpod-cli/flash_self_test.go index 2fa43d4..54fdfaa 100644 --- a/cmd/benchpod-cli/flash_self_test.go +++ b/cmd/benchpod-cli/flash_self_test.go @@ -108,7 +108,7 @@ func TestInstallBlobsAfterFlashFailsWhenTheConsoleNeverReturns(t *testing.T) { openFlashedConsole = func(*globalFlags) (*serialconsole.Console, error) { return nil, errors.New("no BenchPod found") } - err := installBlobsAfterFlash(&globalFlags{}, blobSource{base: t.TempDir()}) + _, _, err := installBlobsAfterFlash(&globalFlags{}, blobSource{base: t.TempDir()}) if err == nil || !strings.Contains(err.Error(), "install-blobs") { t.Fatalf("err = %v, want an error naming install-blobs", err) } diff --git a/cmd/benchpod-cli/fwreport.go b/cmd/benchpod-cli/fwreport.go new file mode 100644 index 0000000..52d1197 --- /dev/null +++ b/cmd/benchpod-cli/fwreport.go @@ -0,0 +1,268 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" + "github.com/embeddedci-com/benchpod-cli/internal/serverapi" +) + +// Firmware-update reports: after this CLI changes a pod's firmware (flash-self) or its blobs +// (install-blobs), it tells embeddedci.com which pod went from which version to which, so the +// server keeps the pod's version history next to what the pod reports when it connects. +// +// Best effort: it never blocks or fails the update (a short timeout), says nothing when nobody +// is signed in, and keeps a report that could not be sent for one more try on a later run. +// Opt out with BENCHPOD_NO_FIRMWARE_REPORT=1 or --no-firmware-report. + +// fwEvent is POST /api/benchpod/firmware-events. +type fwEvent struct { + PublicKey string `json:"public_key"` + DeviceID string `json:"device_id,omitempty"` + Name string `json:"name,omitempty"` + Kind string `json:"kind"` // "firmware" or "blobs" + OldVersion string `json:"old_version"` + NewVersion string `json:"new_version"` + OldGateware int `json:"old_gateware"` + NewGateware int `json:"new_gateware"` + Blobs []fwBlobVer `json:"blobs,omitempty"` + Method string `json:"method"` // "dfu", "usb_network", "lan", "cloud" + CLIVersion string `json:"cli_version"` + At string `json:"at"` +} + +type fwBlobVer struct { + Name string `json:"name"` + Version int `json:"version"` +} + +// podVersions is what a report says about the pod at one moment. +type podVersions struct { + PublicKey, ShortID, Version string + Gateware int +} + +var gatewareRe = regexp.MustCompile(`gateware v(\d+)`) + +// consoleVersions reads the pod's identity and versions from its console; empty fields when +// unknown. +func consoleVersions(ctx context.Context, c *serialconsole.Console) podVersions { + var v podVersions + if id, err := c.Identity(ctx); err == nil { + v.PublicKey, v.ShortID = id.PublicKey, id.ShortID + } + if raw, err := c.Status(ctx); err == nil { + v.Version = strings.TrimPrefix(serialconsole.FirmwareVersion(raw), "v") + if m := gatewareRe.FindStringSubmatch(raw); m != nil { + v.Gateware, _ = strconv.Atoi(m[1]) + } + } + return v +} + +// fwReportOptOut reports whether the user turned the reports off. +func (g *globalFlags) fwReportOptOut() bool { + if g.noFirmwareReport { + return true + } + switch strings.ToLower(strings.TrimSpace(os.Getenv("BENCHPOD_NO_FIRMWARE_REPORT"))) { + case "", "0", "false", "no": + return false + } + return true +} + +// fwReportTimeout bounds one send. +const fwReportTimeout = 5 * time.Second + +// fwReportQueueMax caps the reports kept for a later try. +const fwReportQueueMax = 20 + +// fwReportPost sends one report. A variable so tests can replace it. +var fwReportPost = func(ctx context.Context, ev fwEvent) error { + api := &serverapi.Client{BaseURL: cloudServerURL()} + cred, err := cloudCredential(ctx, api) + if err != nil { + return errNoCloudCredential // not signed in: nothing to send it with + } + body, _ := json.Marshal(ev) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, api.BaseURL+"/api/benchpod/firmware-events", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", serverapi.Authorization(cred)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil +} + +// fwReportQueuePath is where unsent reports wait: next to the sign-in token. +var fwReportQueuePath = func() string { + p, err := resolveTokenPath("") + if err != nil { + return "" + } + return filepath.Join(filepath.Dir(p), "firmware-reports.jsonl") +} + +type queuedEvent struct { + Event fwEvent `json:"event"` + Tries int `json:"tries"` +} + +// reportFirmware sends ev (and anything left from earlier runs) without ever failing the +// caller. A report whose first send fails is queued for one more try on a later run. +func (g *globalFlags) reportFirmware(ev fwEvent) { + if g.fwReportOptOut() || ev.PublicKey == "" { + return + } + ev.CLIVersion = version + if ev.At == "" { + ev.At = time.Now().UTC().Format(time.RFC3339) + } + pending := append(readFWQueue(), queuedEvent{Event: ev}) + var keep []queuedEvent + for _, q := range pending { + ctx, cancel := context.WithTimeout(context.Background(), fwReportTimeout) + err := fwReportPost(ctx, q.Event) + cancel() + if err == errNoCloudCredential { + return // not signed in: send nothing, keep nothing (the queue stays as it was) + } + if err == nil { + continue + } + if q.Tries++; q.Tries < 2 { + keep = append(keep, q) + } + } + writeFWQueue(keep) +} + +func readFWQueue() []queuedEvent { + path := fwReportQueuePath() + if path == "" { + return nil + } + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + var out []queuedEvent + sc := bufio.NewScanner(f) + for sc.Scan() { + var q queuedEvent + if json.Unmarshal(sc.Bytes(), &q) == nil && q.Event.PublicKey != "" { + out = append(out, q) + } + } + return out +} + +func writeFWQueue(q []queuedEvent) { + path := fwReportQueuePath() + if path == "" { + return + } + if len(q) == 0 { + _ = os.Remove(path) + return + } + if len(q) > fwReportQueueMax { + q = q[len(q)-fwReportQueueMax:] + } + var b bytes.Buffer + for _, e := range q { + line, _ := json.Marshal(e) + b.Write(line) + b.WriteByte('\n') + } + _ = os.MkdirAll(filepath.Dir(path), 0o700) + _ = os.WriteFile(path, b.Bytes(), 0o600) +} + +// podName is "benchpod-", "" when unknown. +func podName(shortID string) string { + if shortID == "" { + return "" + } + return "benchpod-" + shortID +} + +// gatewareOf is the newest gateware version among the gw slots (0 when none). +func gatewareOf(slots []serialconsole.BlobSlot) int { + v := 0 + for _, s := range slots { + if strings.HasPrefix(s.Name, "gw") && s.Version > v { + v = s.Version + } + } + return v +} + +// blobsEvent is the report of an install-blobs run that installed something. +func blobsEvent(ctx context.Context, t blobTarget, before []serialconsole.BlobSlot, installed []blobEntry) fwEvent { + pub, short := t.Identity(ctx) + ver := strings.TrimPrefix(t.Release(ctx), "v") + ev := fwEvent{PublicKey: pub, Name: podName(short), Kind: "blobs", OldVersion: ver, NewVersion: ver, + OldGateware: gatewareOf(before), Method: t.Method()} + for _, e := range installed { + ev.Blobs = append(ev.Blobs, fwBlobVer{Name: e.Name, Version: e.Version}) + if strings.HasPrefix(e.Name, "gw") && e.Version > ev.NewGateware { + ev.NewGateware = e.Version + } + } + if ev.NewGateware == 0 { + ev.NewGateware = ev.OldGateware + } + return ev +} + +// firmwareEvent is the report of a flash-self: the pod before (when it was running) and after +// (when it came back); tag is the release flashed ("v3.9.0", "" for a local file). +func firmwareEvent(before, after podVersions, tag string, installed []blobEntry) fwEvent { + ev := fwEvent{PublicKey: after.PublicKey, Name: podName(after.ShortID), Kind: "firmware", Method: "dfu", + OldVersion: before.Version, NewVersion: after.Version, OldGateware: before.Gateware, NewGateware: after.Gateware} + if ev.PublicKey == "" { + ev.PublicKey, ev.Name = before.PublicKey, podName(before.ShortID) + } + if ev.NewVersion == "" { + ev.NewVersion = strings.TrimPrefix(tag, "v") + } + for _, e := range installed { + ev.Blobs = append(ev.Blobs, fwBlobVer{Name: e.Name, Version: e.Version}) + } + return ev +} + +// runningPodVersions reads the pod's identity and versions over its USB console, when one +// answers quickly (zero values otherwise). +func (g *globalFlags) runningPodVersions() podVersions { + console, _, err := g.openBenchpodSerial(g.serialDevice(), 2*time.Second) + if err != nil { + return podVersions{} + } + defer console.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return consoleVersions(ctx, console) +} diff --git a/cmd/benchpod-cli/fwreport_test.go b/cmd/benchpod-cli/fwreport_test.go new file mode 100644 index 0000000..52dd16d --- /dev/null +++ b/cmd/benchpod-cli/fwreport_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" +) + +// withFWReports points the reporter at a fake server and a temporary queue. +func withFWReports(t *testing.T, post func(fwEvent) error) (sent *[]fwEvent) { + t.Helper() + oldPost, oldPath := fwReportPost, fwReportQueuePath + t.Cleanup(func() { fwReportPost, fwReportQueuePath = oldPost, oldPath }) + dir := t.TempDir() + fwReportQueuePath = func() string { return filepath.Join(dir, "firmware-reports.jsonl") } + var got []fwEvent + fwReportPost = func(_ context.Context, ev fwEvent) error { + got = append(got, ev) + return post(ev) + } + t.Setenv("BENCHPOD_NO_FIRMWARE_REPORT", "") + return &got +} + +func TestFirmwareReportSendsWhenSignedIn(t *testing.T) { + sent := withFWReports(t, func(fwEvent) error { return nil }) + (&globalFlags{}).reportFirmware(fwEvent{PublicKey: "pk", Kind: "firmware", Method: "dfu", OldVersion: "3.8.1", NewVersion: "3.9.0"}) + if len(*sent) != 1 || (*sent)[0].CLIVersion != version || (*sent)[0].At == "" || len(readFWQueue()) != 0 { + t.Fatalf("sent %+v, queue %d", *sent, len(readFWQueue())) + } +} + +func TestFirmwareReportOptOut(t *testing.T) { + sent := withFWReports(t, func(fwEvent) error { return nil }) + (&globalFlags{noFirmwareReport: true}).reportFirmware(fwEvent{PublicKey: "pk", Kind: "firmware"}) + t.Setenv("BENCHPOD_NO_FIRMWARE_REPORT", "1") + (&globalFlags{}).reportFirmware(fwEvent{PublicKey: "pk", Kind: "firmware"}) + if len(*sent) != 0 { + t.Fatalf("sent despite the opt-out: %+v", *sent) + } +} + +func TestFirmwareReportSilentWhenNotSignedIn(t *testing.T) { + sent := withFWReports(t, func(fwEvent) error { return errNoCloudCredential }) + (&globalFlags{}).reportFirmware(fwEvent{PublicKey: "pk", Kind: "firmware"}) + if len(*sent) != 1 || len(readFWQueue()) != 0 { + t.Fatalf("not signed in: queued %d", len(readFWQueue())) + } +} + +// Offline: the report waits for one more try on a later run, then is dropped. +func TestFirmwareReportRetriesOnceLater(t *testing.T) { + fail := true + sent := withFWReports(t, func(fwEvent) error { + if fail { + return errors.New("offline") + } + return nil + }) + g := &globalFlags{} + g.reportFirmware(fwEvent{PublicKey: "a", Kind: "firmware"}) + if q := readFWQueue(); len(q) != 1 || q[0].Tries != 1 { + t.Fatalf("queue after a failed send: %+v", q) + } + g.reportFirmware(fwEvent{PublicKey: "b", Kind: "firmware"}) // still offline: a is dropped, b kept + if q := readFWQueue(); len(q) != 1 || q[0].Event.PublicKey != "b" { + t.Fatalf("queue after the second failure: %+v", q) + } + fail = false + *sent = nil + g.reportFirmware(fwEvent{PublicKey: "c", Kind: "blobs"}) + if len(*sent) != 2 || (*sent)[0].PublicKey != "b" || (*sent)[1].PublicKey != "c" || len(readFWQueue()) != 0 { + t.Fatalf("online again: sent %+v, queue %d", *sent, len(readFWQueue())) + } +} + +func TestFirmwareEventFromBeforeAndAfter(t *testing.T) { + before := podVersions{PublicKey: "pk", ShortID: "baea06", Version: "3.8.1", Gateware: 48} + ev := firmwareEvent(before, podVersions{}, "v3.9.0", []blobEntry{{Name: "gw1", Version: 49}}) + if ev.PublicKey != "pk" || ev.Name != "benchpod-baea06" || ev.OldVersion != "3.8.1" || ev.NewVersion != "3.9.0" || + ev.Method != "dfu" || ev.Kind != "firmware" || len(ev.Blobs) != 1 { + t.Fatalf("%+v", ev) + } + ev = firmwareEvent(podVersions{}, podVersions{PublicKey: "pk2", ShortID: "aaaaaa", Version: "3.9.0", Gateware: 49}, "", nil) + if ev.PublicKey != "pk2" || ev.NewVersion != "3.9.0" || ev.NewGateware != 49 || ev.OldVersion != "" { + t.Fatalf("after only: %+v", ev) + } + if gatewareOf([]serialconsole.BlobSlot{{Name: "gw0", Version: 47}, {Name: "gw1", Version: 48}, {Name: "esp", Version: 3}}) != 48 { + t.Fatal("gatewareOf") + } +} diff --git a/cmd/benchpod-cli/podclient_test.go b/cmd/benchpod-cli/podclient_test.go index afa2789..18bf154 100644 --- a/cmd/benchpod-cli/podclient_test.go +++ b/cmd/benchpod-cli/podclient_test.go @@ -3,6 +3,7 @@ package main import ( "bufio" "bytes" + "context" "encoding/json" "errors" "fmt" @@ -36,6 +37,9 @@ func TestMain(m *testing.M) { return serialconsole.USBNet{}, errors.New("no pod on USB (tests)") } localUSBPod = func(string) string { return "" } + // Never report to the real embeddedci.com, never touch the real queue. + fwReportPost = func(context.Context, fwEvent) error { return errNoCloudCredential } + fwReportQueuePath = func() string { return "" } code := m.Run() os.RemoveAll(dir) os.Exit(code) diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index d96aa29..d2e827d 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -19,6 +19,8 @@ type globalFlags struct { outputFilename string timeout time.Duration + noFirmwareReport bool // --no-firmware-report: do not tell embeddedci.com about firmware updates (fwreport.go) + usbAddr string // the pod's USB network address, once networkTarget found it (one probe per run) usbErr error // why it found none (also one probe per run: the console may be open later) } @@ -87,6 +89,8 @@ func newRootCmd() *cobra.Command { pf.StringVar(&g.configFile, "config-file", "", "path to config file") pf.StringVar(&g.outputFilename, "output-filename", "", "write command output to this file instead of stdout") pf.DurationVar(&g.timeout, "timeout", 0, "overall command deadline (0 = per-command default)") + pf.BoolVar(&g.noFirmwareReport, "no-firmware-report", false, + "do not report firmware and blob updates to embeddedci.com (also BENCHPOD_NO_FIRMWARE_REPORT=1)") viper.SetEnvPrefix("BENCHPOD") viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) From a3e7fcc3c7d8364047931cb86ba97bb23d053f8e Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 09:36:02 -0500 Subject: [PATCH 4/6] usb-reattach: the adapter only comes back when the USB network setting is on Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/usbbridge.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/benchpod-cli/usbbridge.go b/cmd/benchpod-cli/usbbridge.go index 43602d3..19c2868 100644 --- a/cmd/benchpod-cli/usbbridge.go +++ b/cmd/benchpod-cli/usbbridge.go @@ -246,7 +246,7 @@ func newUSBReattachCmd(g *globalFlags) *cobra.Command { if err := console.USBReattach(ctx); err != nil { return fmt.Errorf("usb-reattach: %w", err) } - fmt.Fprintln(os.Stderr, "The pod reconnected on USB; its network adapter is back in a few seconds.") + fmt.Fprintln(os.Stderr, "The pod reconnected on USB; its network adapter (unless turned off with `benchpod usb-network off`) is back in a few seconds.") return nil }, } From 0dc3f8fa3180a7b739552695ae342516a6dd52b4 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 09:37:06 -0500 Subject: [PATCH 5/6] fwrefusals: a pin may be pending on a firmware PR The usb_network refusal is pinned already, but firmware main gets it only with benchpod-firmware#49; CI checks main. "pending" turns a missing text into no failure until that PR is merged (drop the field then). Co-Authored-By: Claude Opus 5.5 --- internal/fwrefusals/fwrefusals.go | 6 +++++- internal/fwrefusals/refusals.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/fwrefusals/fwrefusals.go b/internal/fwrefusals/fwrefusals.go index ad81a07..cf65cd1 100644 --- a/internal/fwrefusals/fwrefusals.go +++ b/internal/fwrefusals/fwrefusals.go @@ -29,6 +29,10 @@ type Refusal struct { // Removed is the firmware version that no longer sends this text ("3.9.0"), "" while current. // Such a text stays pinned: the CLI still meets it on pods running older firmware. Check skips it. Removed string `json:"removed,omitempty"` + // Pending names the firmware pull request that adds this text ("benchpod-firmware#49") while + // it is not on the firmware's main branch yet: Check then reports it as pending, not as a + // change. Drop the field once that PR is merged. + Pending string `json:"pending,omitempty"` } type doc struct { @@ -144,7 +148,7 @@ func Check(dir string) []string { problems = append(problems, fmt.Sprintf("%s: %v", r.ID, err)) continue } - if !strings.Contains(src, `"`+r.Format+`"`) { + if !strings.Contains(src, `"`+r.Format+`"`) && r.Pending == "" { problems = append(problems, fmt.Sprintf("%s: %q is no longer in %s", r.ID, r.Format, r.File)) } } diff --git a/internal/fwrefusals/refusals.json b/internal/fwrefusals/refusals.json index d3b9a20..d7b1bc5 100644 --- a/internal/fwrefusals/refusals.json +++ b/internal/fwrefusals/refusals.json @@ -85,7 +85,7 @@ {"id": "lan_policy_from_lan", "file": "stm32h563/src/pod_policy.c", "kind": "json", "format": "lan_policy: change it from the cloud or the USB console", "example": "lan_policy: change it from the cloud or the USB console"}, - {"id": "usb_network_not_device", "file": "stm32h563/src/pod_policy.c", "kind": "json", + {"id": "usb_network_not_device", "file": "stm32h563/src/pod_policy.c", "pending": "benchpod-firmware#49", "kind": "json", "format": "usb_network: change it on the device (the USB console, or over the USB link)", "example": "usb_network: change it on the device (the USB console, or over the USB link)"}, {"id": "cloud_ca_from_lan_v39", "file": "stm32h563/src/pod_policy.c", "kind": "json", From f6678f2f58619fbaa3d2327840dfc0b3dcf76ee0 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 09:41:52 -0500 Subject: [PATCH 6/6] Firmware reports: drop a report the server refuses (4xx), retry only network/5xx/429 Co-Authored-By: Claude Opus 5.5 --- cmd/benchpod-cli/fwreport.go | 9 ++++++++- cmd/benchpod-cli/fwreport_test.go | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd/benchpod-cli/fwreport.go b/cmd/benchpod-cli/fwreport.go index 52d1197..f8c1256 100644 --- a/cmd/benchpod-cli/fwreport.go +++ b/cmd/benchpod-cli/fwreport.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -109,11 +110,17 @@ var fwReportPost = func(ctx context.Context, ev fwEvent) error { } resp.Body.Close() if resp.StatusCode/100 != 2 { + if resp.StatusCode/100 == 4 && resp.StatusCode != http.StatusTooManyRequests { + return errFWReportRejected // the server will not take it: no point retrying + } return fmt.Errorf("HTTP %d", resp.StatusCode) } return nil } +// errFWReportRejected is a report the server refused (4xx other than 429): dropped, not queued. +var errFWReportRejected = errors.New("firmware report rejected") + // fwReportQueuePath is where unsent reports wait: next to the sign-in token. var fwReportQueuePath = func() string { p, err := resolveTokenPath("") @@ -147,7 +154,7 @@ func (g *globalFlags) reportFirmware(ev fwEvent) { if err == errNoCloudCredential { return // not signed in: send nothing, keep nothing (the queue stays as it was) } - if err == nil { + if err == nil || err == errFWReportRejected { continue } if q.Tries++; q.Tries < 2 { diff --git a/cmd/benchpod-cli/fwreport_test.go b/cmd/benchpod-cli/fwreport_test.go index 52dd16d..8af011c 100644 --- a/cmd/benchpod-cli/fwreport_test.go +++ b/cmd/benchpod-cli/fwreport_test.go @@ -92,3 +92,12 @@ func TestFirmwareEventFromBeforeAndAfter(t *testing.T) { t.Fatal("gatewareOf") } } + +// A report the server refuses (4xx) is dropped, not kept for a retry. +func TestFirmwareReportRejectedIsDropped(t *testing.T) { + withFWReports(t, func(fwEvent) error { return errFWReportRejected }) + (&globalFlags{}).reportFirmware(fwEvent{PublicKey: "pk", Kind: "firmware"}) + if q := readFWQueue(); len(q) != 0 { + t.Fatalf("queued a rejected report: %+v", q) + } +}