diff --git a/README.md b/README.md index cc9e931..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 @@ -1145,6 +1162,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/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..f8c1256 --- /dev/null +++ b/cmd/benchpod-cli/fwreport.go @@ -0,0 +1,275 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "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 { + 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("") + 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 || err == errFWReportRejected { + 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..8af011c --- /dev/null +++ b/cmd/benchpod-cli/fwreport_test.go @@ -0,0 +1,103 @@ +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") + } +} + +// 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) + } +} 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 4b9d789..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("-", "_")) @@ -108,7 +112,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/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 }, } 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/fwrefusals.go b/internal/fwrefusals/fwrefusals.go index 3f003be..cf65cd1 100644 --- a/internal/fwrefusals/fwrefusals.go +++ b/internal/fwrefusals/fwrefusals.go @@ -26,6 +26,13 @@ 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"` + // 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 { @@ -133,12 +140,15 @@ 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)) 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 0f7faf6..d7b1bc5 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,34 @@ {"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": "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", + "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/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": 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,