diff --git a/.gitignore b/.gitignore index 50dbff3..b96084c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .DS_Store .vscode/ .idea/ +/cmd/benchpod-cli/benchpod-cli diff --git a/README.md b/README.md index ce011ef..d9d1a46 100644 --- a/README.md +++ b/README.md @@ -489,11 +489,17 @@ one that is already done: 2. **Network**: a pod with no address gets one. Choose Ethernet (setup waits up to two minutes for its DHCP lease) or Wi-Fi (SSID and password, like `set-wifi`). 3. **Board I/O voltage**: 1.8 V or 3.3 V, the DUT's logic level (like `la voltage`). - One already set is kept unless `--la-voltage` is given. + One already set is kept unless `--la-voltage` is given. The pod forgets it on a + restart, so when this machine is signed in and the pod is registered to that + account, setup also saves it (`la_mv`) to the pod's wiring profile on + embeddedci.com, keeping every other field. The server applies it on every connect + and the web app's setup checklist ticks "I/O voltage". A voltage already on the pod + but missing from the profile is saved too. If the save fails, setup warns and goes on. 4. **embeddedci.com**: an unregistered pod is registered to your account, signing in - first (`login`) when there is no session. A pod registered to your account is - named; one registered to another account is explained (sign in with the address - your activation email was sent to, `benchpod login --force`) and left alone. + first (`login`) when there is no session, and its new wiring profile gets the I/O + voltage. A pod registered to your account is named; one registered to another + account is explained (sign in with the address your activation email was sent to, + `benchpod login --force`) and left alone. 5. **Save the connection**: the pod's address becomes the default (like `discover --save`). It ends with the next steps: SWD wiring (SWCLK on LA11, SWDIO on LA12, GND), a @@ -510,7 +516,7 @@ $ benchpod setup --la-voltage 3.3V Done: the pod is on the network at 192.168.1.221:8080. 3/5 Board I/O voltage - Set to 3.3 V. + Set to 3.3 V on the pod and saved to its wiring profile on embeddedci.com. 4/5 embeddedci.com Done: registered to your account (you@example.com) as benchpod-baea06. @@ -652,6 +658,13 @@ LA capture, UART, SWD flash, pull-ups and I2C-sensor emulation until it is set; 1.8 V needs a v3 pod, and the pod refuses a change while an LA pin is in use. Works over the network and over USB. No flags. +The pod forgets the voltage on a restart. When this machine is signed in +(`benchpod login`) and the pod is registered to that account, setting it over the +network or an `embeddedci:` connection also saves it to the pod's wiring profile on +embeddedci.com (only `la_mv` changes), which the server applies on every connect. +A failed save is a warning; the pod setting still stands. Over USB it is set on the +pod only. + ### `benchpod la pullup PIN STATE` Switch an LA pin's pull-up: `PIN` is 1-8 (or `la1`), `STATE` is `on` or `off`. No flags. diff --git a/cmd/benchpod-cli/benchpod-cli b/cmd/benchpod-cli/benchpod-cli deleted file mode 100755 index 35625f4..0000000 Binary files a/cmd/benchpod-cli/benchpod-cli and /dev/null differ diff --git a/cmd/benchpod-cli/lavoltage.go b/cmd/benchpod-cli/lavoltage.go index c0cbe82..6f8eca9 100644 --- a/cmd/benchpod-cli/lavoltage.go +++ b/cmd/benchpod-cli/lavoltage.go @@ -11,6 +11,7 @@ import ( "time" "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" + "github.com/embeddedci-com/benchpod-cli/internal/serverapi" "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" "github.com/spf13/cobra" ) @@ -32,7 +33,12 @@ func newLAVoltageCmd(g *globalFlags) *cobra.Command { "I2C-sensor emulation until it is set. 1.8V needs a v3 pod, and the pod refuses a\n" + "change while any LA pin is in use.\n\n" + "VOLTAGE is 1.8V or 3.3V (also 1800mV, 3.3, or 3300). Omit it to show the\n" + - "current setting. Works over the network and over USB (--connection usb).", + "current setting. Works over the network and over USB (--connection usb).\n\n" + + "The pod forgets the voltage on a restart. When this machine is signed in\n" + + "(`benchpod login`) and the pod is registered to that account, setting it over\n" + + "the network or embeddedci.com also saves it to the pod's wiring profile on\n" + + "embeddedci.com, which the server applies on every connect. Over USB it is set\n" + + "on the pod only.", Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { mv := 0 @@ -91,9 +97,70 @@ func runLAVoltage(g *globalFlags, mv int, out, warn io.Writer) error { if rep.ReadbackMV != 0 && rep.ReadbackMV != rep.MV { fmt.Fprintf(warn, "Warning: the bank's power mux reports %s, not %s\n", formatMV(rep.ReadbackMV), formatMV(rep.MV)) } + if mv != 0 { + if id := accountDeviceForConnection(g); id != "" { + changed, err := saveLaMVToProfile(id, rep.MV) + switch { + case err != nil: + fmt.Fprintf(warn, "Warning: could not save it to the pod's wiring profile on embeddedci.com: %v\n", err) + case changed: + fmt.Fprintln(out, "Saved to the pod's wiring profile on embeddedci.com (applied on every connect).") + } + } + } return nil } +// accountDeviceForConnection returns the cloud device id of the pod behind g's network or +// embeddedci.com connection when this machine is signed in and the pod is registered to that +// account, so its wiring profile can be written; "" otherwise (also over USB, and whenever it +// cannot tell). It never prompts or fails. A variable so tests can replace it. +var accountDeviceForConnection = func(g *globalFlags) string { + if who, _ := currentSession("", ""); who == "" { + return "" + } + spec, err := g.resolveConnection() + if err != nil || spec.IsSerial() { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + raw, err := (&tcpclient.Client{Addr: spec.Addr}).Command(ctx, map[string]any{"cmd": "cloud_status"}) + if err != nil { + return "" + } + var c cloudState + if json.Unmarshal(raw, &c) != nil || !c.Configured { + return "" + } + id := strings.TrimSpace(c.DeviceID) + if id == "" { + return "" + } + if _, ok := accountDevices()[id]; !ok { + return "" + } + return id +} + +// saveLaMVToProfile stores mv as la_mv in the wiring profile of the pod with this cloud device id +// on embeddedci.com (read-modify-write: every other field is kept). changed is false when the +// profile already had it. A variable so tests can replace it. +var saveLaMVToProfile = func(deviceID string, mv int) (changed bool, err error) { + tokenPath, err := resolveTokenPath("") + if err != nil { + return false, err + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + api := serverapi.New(defaultServerURL) + tokens, err := ensureTokens(ctx, api, tokenPath) + if err != nil { + return false, fmt.Errorf("sign in: %w", err) + } + return api.SetDeviceWiringLaMV(ctx, tokens.AccessToken, deviceID, mv) +} + // laVoltageDo shows (mv 0) or sets the LA voltage over the effective connection, network or // USB, and returns a label for the pod plus its reply. Shared by la voltage, status and setup. func laVoltageDo(g *globalFlags, mv int) (string, laVoltageReply, error) { diff --git a/cmd/benchpod-cli/lavoltage_test.go b/cmd/benchpod-cli/lavoltage_test.go index 5cfa511..fce6caf 100644 --- a/cmd/benchpod-cli/lavoltage_test.go +++ b/cmd/benchpod-cli/lavoltage_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "errors" "strings" "testing" "time" @@ -11,7 +12,55 @@ import ( "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" ) +// stubLAVoltageProfile replaces the embeddedci.com side of `la voltage` for one test: id is the +// device the connection resolves to on the signed-in account ("" = not on it), and the returned +// slice records each save. +func stubLAVoltageProfile(t *testing.T, id string, changed bool, err error) *[]int { + t.Helper() + savedAcct, savedSave := accountDeviceForConnection, saveLaMVToProfile + t.Cleanup(func() { accountDeviceForConnection, saveLaMVToProfile = savedAcct, savedSave }) + var saves []int + accountDeviceForConnection = func(*globalFlags) string { return id } + saveLaMVToProfile = func(dev string, mv int) (bool, error) { + if dev != id { + t.Errorf("saved to %q, want %q", dev, id) + } + saves = append(saves, mv) + return changed, err + } + return &saves +} + +func TestLAVoltageSavesToTheProfile(t *testing.T) { + addr, _ := fakeLANPod(t, `{"status":"ok","data":{"mv":3300,"st":1,"readback_mv":3300}}`) + saves := stubLAVoltageProfile(t, "dev-1", true, nil) + var out, warn bytes.Buffer + if err := runLAVoltage(&globalFlags{connection: addr}, 3300, &out, &warn); err != nil { + t.Fatal(err) + } + if len(*saves) != 1 || (*saves)[0] != 3300 || !strings.Contains(out.String(), "Saved to the pod's wiring profile on embeddedci.com") { + t.Fatalf("saves %v out %q", *saves, out.String()) + } + // A failed save warns; the command still succeeds. + addr, _ = fakeLANPod(t, `{"status":"ok","data":{"mv":3300,"st":1,"readback_mv":3300}}`) + stubLAVoltageProfile(t, "dev-1", false, errors.New("403 forbidden")) + out.Reset() + if err := runLAVoltage(&globalFlags{connection: addr}, 3300, &out, &warn); err != nil { + t.Fatal(err) + } + if !strings.Contains(warn.String(), "could not save it to the pod's wiring profile on embeddedci.com: 403 forbidden") { + t.Fatalf("warn %q", warn.String()) + } + // Showing the voltage never writes the profile. + addr, _ = fakeLANPod(t, `{"status":"ok","data":{"mv":3300,"st":1,"readback_mv":3300}}`) + saves = stubLAVoltageProfile(t, "dev-1", true, nil) + if err := runLAVoltage(&globalFlags{connection: addr}, 0, &bytes.Buffer{}, &bytes.Buffer{}); err != nil || len(*saves) != 0 { + t.Fatalf("err %v saves %v", err, *saves) + } +} + func TestLAVoltageLAN(t *testing.T) { + stubLAVoltageProfile(t, "", false, nil) addr, got := fakeLANPod(t, `{"status":"ok","data":{"mv":3300,"st":1,"readback_mv":3300}}`) g := &globalFlags{connection: addr} var out, warn bytes.Buffer @@ -83,6 +132,7 @@ func (f *fakeLAVoltageConsole) LAVoltage(_ context.Context, mv int) (int, error) func (f *fakeLAVoltageConsole) Close() error { return nil } func TestLAVoltageUSB(t *testing.T) { + stubLAVoltageProfile(t, "", false, nil) fc := &fakeLAVoltageConsole{} saved := openLAVoltageConsole t.Cleanup(func() { openLAVoltageConsole = saved }) diff --git a/cmd/benchpod-cli/setup.go b/cmd/benchpod-cli/setup.go index 736a75e..eaabc6e 100644 --- a/cmd/benchpod-cli/setup.go +++ b/cmd/benchpod-cli/setup.go @@ -42,8 +42,12 @@ func newSetupCmd(g *globalFlags) *cobra.Command { " DHCP lease) or Wi-Fi (SSID and password, like `benchpod set-wifi`).\n" + " 3. Board I/O voltage: 1.8 V or 3.3 V, the DUT's logic level (like\n" + " `benchpod la voltage`). Already set is kept unless --la-voltage is given.\n" + + " The pod forgets it on a restart, so when you are signed in and the pod is\n" + + " on your account, setup also saves it to the pod's wiring profile on\n" + + " embeddedci.com, which the server applies on every connect.\n" + " 4. embeddedci.com: an unregistered pod is registered to your account (signing\n" + - " in first when needed). A pod on another account is explained, not touched.\n" + + " in first when needed), and its wiring profile gets the I/O voltage. A pod\n" + + " on another account is explained, not touched.\n" + " 5. Save the pod's address as the default connection (like `discover --save`).\n\n" + "It then prints the next steps: wiring SWD, flashing and running tests.\n\n" + "Pass --connection to set up a pod at a known address or USB device instead of\n" + @@ -100,6 +104,7 @@ type setupEnv interface { setWifi(device, ssid, password string) (string, error) readPassword() (string, error) laVoltage(target string, mv int) (int, error) + saveLaMV(deviceID string, mv int) (changed bool, err error) cloudStatus(addr string) cloudState signedIn() string login() error @@ -224,16 +229,17 @@ func runSetup(env setupEnv, ui *setupUI, o setupOpts) error { // 3. Board I/O voltage. step(3, "Board I/O voltage") - if err := setupLAVoltage(env, ui, o, p.addr); err != nil { + if c := env.cloudStatus(p.addr); c.known { + p.cloud = c + } + mv, err := setupLAVoltage(env, ui, o, p.addr, setupProfileDevice(env, p.cloud)) + if err != nil { return err } // 4. embeddedci.com. step(4, "embeddedci.com") - if c := env.cloudStatus(p.addr); c.known { - p.cloud = c - } - name, err := setupCloud(env, ui, o, p) + name, err := setupCloud(env, ui, o, p, mv) if err != nil { return err } @@ -327,38 +333,88 @@ func setupNetwork(env setupEnv, ui *setupUI, o setupOpts, device string) (string return addr, nil } -// setupLAVoltage keeps a voltage that is already set unless --la-voltage asks for one. -func setupLAVoltage(env setupEnv, ui *setupUI, o setupOpts, target string) error { +// setupProfileDevice returns the pod's cloud device id when this machine is signed in and the pod +// is registered to that account, so its wiring profile on embeddedci.com can be written; "" otherwise. +func setupProfileDevice(env setupEnv, c cloudState) string { + id := strings.TrimSpace(c.DeviceID) + if !c.known || !c.Configured || id == "" || env.signedIn() == "" { + return "" + } + if _, ok := env.accountDevices()[id]; !ok { + return "" + } + return id +} + +// setupLAVoltage keeps a voltage that is already set unless --la-voltage asks for one, and returns +// the pod's voltage. The pod forgets it on a restart, so when profileDevice is set (signed in, pod on +// the account) it is also saved to the pod's wiring profile on embeddedci.com: the server applies it +// on every connect, and the web app's setup checklist ticks. A failed save only warns. +func setupLAVoltage(env setupEnv, ui *setupUI, o setupOpts, target, profileDevice string) (int, error) { out := ui.out mv := o.laVoltageMV if mv == 0 { cur, err := env.laVoltage(target, 0) if err != nil { - return err + return 0, err } if cur != 0 { - fmt.Fprintf(out, " Done: already %s. Change it with `benchpod la voltage 1.8V|3.3V` if your DUT differs.\n", formatMV(cur)) - return nil + var saveErr error + done := fmt.Sprintf(" Done: already %s.", formatMV(cur)) + if profileDevice != "" { + var changed bool + if changed, saveErr = env.saveLaMV(profileDevice, cur); saveErr == nil && changed { + done = fmt.Sprintf(" Done: already %s on the pod. Saved it to its wiring profile on embeddedci.com.", formatMV(cur)) + } + } + fmt.Fprintln(out, done) + if saveErr != nil { + warnProfileSave(out, cur, saveErr) + } + fmt.Fprintln(out, " Change it with `benchpod la voltage 1.8V|3.3V` if your DUT differs.") + return cur, nil } i, err := ui.choose(" Which I/O voltage does your target board (DUT) use? The pod's LA pins, UART and SWD use it.", "the board I/O voltage is not set: pass --la-voltage 3.3V or --la-voltage 1.8V (the DUT's logic level)", []string{"3.3 V (most boards, including the NUCLEO examples)", "1.8 V"}) if err != nil { - return err + return 0, err } mv = []int{3300, 1800}[i] } got, err := env.laVoltage(target, mv) if err != nil { - return err + return 0, err } - fmt.Fprintf(out, " Set to %s.\n", formatMV(got)) - return nil + if profileDevice == "" { + fmt.Fprintf(out, " Set to %s on the pod.\n", formatMV(got)) + return got, nil + } + changed, err := env.saveLaMV(profileDevice, got) + switch { + case err != nil: + fmt.Fprintf(out, " Set to %s on the pod.\n", formatMV(got)) + warnProfileSave(out, got, err) + case changed: + fmt.Fprintf(out, " Set to %s on the pod and saved to its wiring profile on embeddedci.com.\n", formatMV(got)) + default: + fmt.Fprintf(out, " Set to %s on the pod; its wiring profile on embeddedci.com already has it.\n", formatMV(got)) + } + return got, nil +} + +// warnProfileSave says the voltage is on the pod but not in its stored profile. +func warnProfileSave(out io.Writer, mv int, err error) { + fmt.Fprintf(out, " Warning: could not save it to the pod's wiring profile on embeddedci.com: %v\n", err) + fmt.Fprintf(out, " The pod keeps %s until it restarts; set it on the web app's Wiring tab to keep it.\n", formatMV(mv)) } // setupCloud registers an unregistered pod (unless --no-register) and says whose account a // registered one is on. It returns the pod's device name on the signed-in account, "" if unknown. -func setupCloud(env setupEnv, ui *setupUI, o setupOpts, p setupPod) (string, error) { +// +// mv is the pod's I/O voltage from step 3: a pod registered here gets it saved to its new wiring +// profile on embeddedci.com. +func setupCloud(env setupEnv, ui *setupUI, o setupOpts, p setupPod, mv int) (string, error) { out := ui.out c := p.cloud if !c.known { @@ -407,7 +463,15 @@ func setupCloud(env setupEnv, ui *setupUI, o setupOpts, p setupPod) (string, err return "", err } if c := env.cloudStatus(p.addr); c.known && c.DeviceID != "" { - if name, ok := env.accountDevices()[strings.TrimSpace(c.DeviceID)]; ok { + id := strings.TrimSpace(c.DeviceID) + if name, ok := env.accountDevices()[id]; ok { + if mv != 0 { + if _, err := env.saveLaMV(id, mv); err != nil { + warnProfileSave(out, mv, err) + } else { + fmt.Fprintf(out, " Saved the I/O voltage (%s) to its wiring profile on embeddedci.com.\n", formatMV(mv)) + } + } return name, nil } } @@ -431,7 +495,12 @@ func printSetupNextSteps(out io.Writer, addr, name string) { // ── the real environment ───────────────────────────────────────────────────── -type realSetupEnv struct{ g *globalFlags } +type realSetupEnv struct { + g *globalFlags + // account caches accountDevices for one run (login and register clear it). + account map[string]string + accountSet bool +} // with returns a copy of the global flags aimed at target. func (e *realSetupEnv) with(target string) *globalFlags { @@ -562,12 +631,25 @@ func (e *realSetupEnv) signedIn() string { return who } -func (e *realSetupEnv) login() error { return runLogin(defaultServerURL, "", false) } +func (e *realSetupEnv) login() error { + e.account, e.accountSet = nil, false + return runLogin(defaultServerURL, "", false) +} -func (e *realSetupEnv) accountDevices() map[string]string { return accountDevices() } +func (e *realSetupEnv) accountDevices() map[string]string { + if !e.accountSet { + e.account, e.accountSet = accountDevices(), true + } + return e.account +} func (e *realSetupEnv) register(addr string) error { + e.account, e.accountSet = nil, false return runRegister(e.with(addr), registerOptions{serverURL: defaultServerURL, wait: 30 * time.Second}) } +func (e *realSetupEnv) saveLaMV(deviceID string, mv int) (bool, error) { + return saveLaMVToProfile(deviceID, mv) +} + func (e *realSetupEnv) save(target string) error { return runSetConnection(e.g, target) } diff --git a/cmd/benchpod-cli/setup_test.go b/cmd/benchpod-cli/setup_test.go index a429a84..bbaf4a4 100644 --- a/cmd/benchpod-cli/setup_test.go +++ b/cmd/benchpod-cli/setup_test.go @@ -22,6 +22,8 @@ type fakeSetupEnv struct { who string account map[string]string afterReg cloudState // cloud state after register + profileMV int // la_mv in the stored wiring profile, 0 = none + saveErr error // what saveLaMV fails with calls []string wifiSSID string @@ -29,6 +31,7 @@ type fakeSetupEnv struct { setMV int registered string saved string + profileFor string // device id saveLaMV wrote } func (f *fakeSetupEnv) find() ([]setupPod, error) { @@ -55,6 +58,17 @@ func (f *fakeSetupEnv) laVoltage(_ string, mv int) (int, error) { } return f.laMV, nil } +func (f *fakeSetupEnv) saveLaMV(id string, mv int) (bool, error) { + f.calls = append(f.calls, "save-profile") + if f.saveErr != nil { + return false, f.saveErr + } + if f.profileMV == mv { + return false, nil + } + f.profileMV, f.profileFor = mv, id + return true, nil +} func (f *fakeSetupEnv) cloudStatus(string) cloudState { return f.cloud } func (f *fakeSetupEnv) signedIn() string { return f.who } func (f *fakeSetupEnv) login() error { @@ -276,3 +290,99 @@ func TestMergeSetupPodsMatchesUSBAndNetwork(t *testing.T) { t.Errorf("got %+v", p) } } + +func TestSetupSavesTheVoltageToTheProfile(t *testing.T) { + mine := func() *fakeSetupEnv { + return &fakeSetupEnv{ + pods: []setupPod{{addr: "10.0.0.5:8080", cloud: registeredMine}}, cloud: registeredMine, + who: "you@example.com", account: map[string]string{"dev-1": "bench-01"}, + } + } + // Set on the pod and saved to the profile. + env := mine() + out, err := runSetupWith(t, env, "", setupOpts{laVoltageMV: 3300, yes: true}) + if err != nil { + t.Fatal(err) + } + if env.profileMV != 3300 || env.profileFor != "dev-1" || + !strings.Contains(out, "Set to 3.3 V on the pod and saved to its wiring profile on embeddedci.com.") { + t.Fatalf("profile %d for %q\n%s", env.profileMV, env.profileFor, out) + } + // The profile already has it: nothing written, and the output says so. + env = mine() + env.profileMV = 3300 + out, _ = runSetupWith(t, env, "", setupOpts{laVoltageMV: 3300, yes: true}) + if env.profileFor != "" || !strings.Contains(out, "already has it") { + t.Fatalf("wrote %q\n%s", env.profileFor, out) + } + // Already set on the pod, the profile has none or another voltage: save it. + for _, stored := range []int{0, 1800} { + env = mine() + env.laMV, env.profileMV = 3300, stored + out, err = runSetupWith(t, env, "", setupOpts{yes: true}) + if err != nil { + t.Fatal(err) + } + if env.did("set-la") || env.profileMV != 3300 || !strings.Contains(out, "Done: already 3.3 V on the pod. Saved it to its wiring profile") { + t.Fatalf("stored %d: calls %v profile %d\n%s", stored, env.calls, env.profileMV, out) + } + } + // Already set and the profile agrees: the plain "Done". + env = mine() + env.laMV, env.profileMV = 1800, 1800 + out, _ = runSetupWith(t, env, "", setupOpts{yes: true}) + if env.profileFor != "" || !strings.Contains(out, "Done: already 1.8 V.\n") { + t.Fatalf("wrote %q\n%s", env.profileFor, out) + } +} + +func TestSetupProfileSaveFailureOnlyWarns(t *testing.T) { + env := &fakeSetupEnv{ + pods: []setupPod{{addr: "10.0.0.5:8080", cloud: registeredMine}}, cloud: registeredMine, + who: "you@example.com", account: map[string]string{"dev-1": "bench-01"}, saveErr: errors.New("PUT: 400 invalid wiring"), + } + out, err := runSetupWith(t, env, "", setupOpts{laVoltageMV: 3300, yes: true}) + if err != nil { + t.Fatalf("setup failed on a profile save error: %v", err) + } + if env.setMV != 3300 || !env.did("save") || !strings.Contains(out, "Set to 3.3 V on the pod.\n") || + !strings.Contains(out, "Warning: could not save it to the pod's wiring profile on embeddedci.com: PUT: 400 invalid wiring") { + t.Fatalf("calls %v\n%s", env.calls, out) + } +} + +func TestSetupSkipsTheProfileWhenNotOnTheAccount(t *testing.T) { + cases := map[string]*fakeSetupEnv{ + "not signed in": {pods: []setupPod{{addr: "a:8080", cloud: registeredMine}}, cloud: registeredMine, + account: map[string]string{"dev-1": "x"}}, + "another account": {pods: []setupPod{{addr: "a:8080", cloud: registeredMine}}, cloud: registeredMine, + who: "new@x.com", account: map[string]string{"other": "x"}}, + "unregistered, --no-register": {pods: []setupPod{{addr: "a:8080"}}, cloud: cloudState{known: true}, who: "me@x.com"}, + "old firmware": {pods: []setupPod{{addr: "a:8080"}}, who: "me@x.com", account: map[string]string{"dev-1": "x"}}, + } + for name, env := range cases { + out, err := runSetupWith(t, env, "", setupOpts{laVoltageMV: 3300, yes: true, noRegister: true}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if env.did("save-profile") || !strings.Contains(out, "Set to 3.3 V on the pod.\n") { + t.Errorf("%s: calls %v\n%s", name, env.calls, out) + } + } +} + +func TestSetupSavesTheVoltageAfterRegistering(t *testing.T) { + unreg := cloudState{known: true} + env := &fakeSetupEnv{ + pods: []setupPod{{addr: "10.0.0.5:8080", cloud: unreg}}, cloud: unreg, laMV: 3300, who: "me@x.com", + afterReg: registeredMine, account: map[string]string{"dev-1": "bench-01"}, + } + out, err := runSetupWith(t, env, "", setupOpts{yes: true}) + if err != nil { + t.Fatal(err) + } + if !env.did("register") || env.profileMV != 3300 || env.profileFor != "dev-1" || + !strings.Contains(out, "Saved the I/O voltage (3.3 V) to its wiring profile on embeddedci.com.") { + t.Fatalf("calls %v profile %d\n%s", env.calls, env.profileMV, out) + } +} diff --git a/internal/serverapi/client.go b/internal/serverapi/client.go index d72cb8c..38d511b 100644 --- a/internal/serverapi/client.go +++ b/internal/serverapi/client.go @@ -482,3 +482,69 @@ func (c *Client) doAuthedJSON(ctx context.Context, method, path, accessToken str } return nil } + +// GetDeviceWiring calls GET /api/benchpod/devices/{id}/wiring and returns the pod's stored wiring +// profile (an empty map when none is stored). Values stay raw JSON so a read-modify-write puts +// every field back exactly as it was. +func (c *Client) GetDeviceWiring(ctx context.Context, accessToken, deviceID string) (map[string]json.RawMessage, error) { + path, err := wiringPath(deviceID) + if err != nil { + return nil, err + } + var out map[string]json.RawMessage + if err := c.doAuthedJSON(ctx, http.MethodGet, path, accessToken, nil, &out); err != nil { + return nil, err + } + if out == nil { + out = map[string]json.RawMessage{} + } + return out, nil +} + +// PutDeviceWiring calls PUT /api/benchpod/devices/{id}/wiring. The server REPLACES the stored map +// with this one (it does not merge), so change one field with a read-modify-write +// (SetDeviceWiringLaMV). A map the server rejects comes back as an *APIError and leaves the stored +// one untouched. +func (c *Client) PutDeviceWiring(ctx context.Context, accessToken, deviceID string, wiring map[string]json.RawMessage) error { + path, err := wiringPath(deviceID) + if err != nil { + return err + } + if wiring == nil { + wiring = map[string]json.RawMessage{} + } + return c.doAuthedJSON(ctx, http.MethodPut, path, accessToken, wiring, nil) +} + +// SetDeviceWiringLaMV stores the LA I/O voltage (la_mv: 1800 or 3300) in the pod's wiring profile, +// keeping every other field: it reads the stored map, changes la_mv and writes the map back. The +// server then applies it on every connect. changed is false (and nothing is written) when the +// profile already has that voltage. +func (c *Client) SetDeviceWiringLaMV(ctx context.Context, accessToken, deviceID string, mv int) (changed bool, err error) { + if mv != 1800 && mv != 3300 { + return false, fmt.Errorf("serverapi: la_mv must be 1800 or 3300, got %d", mv) + } + wiring, err := c.GetDeviceWiring(ctx, accessToken, deviceID) + if err != nil { + return false, err + } + if raw, ok := wiring["la_mv"]; ok { + var cur float64 + if json.Unmarshal(raw, &cur) == nil && cur == float64(mv) { + return false, nil + } + } + wiring["la_mv"] = json.RawMessage(fmt.Sprint(mv)) + if err := c.PutDeviceWiring(ctx, accessToken, deviceID, wiring); err != nil { + return false, err + } + return true, nil +} + +func wiringPath(deviceID string) (string, error) { + id := strings.TrimSpace(deviceID) + if id == "" { + return "", errors.New("serverapi: device id is empty") + } + return "/api/benchpod/devices/" + url.PathEscape(id) + "/wiring", nil +} diff --git a/internal/serverapi/client_test.go b/internal/serverapi/client_test.go index 66e2faf..9548afe 100644 --- a/internal/serverapi/client_test.go +++ b/internal/serverapi/client_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "testing" ) @@ -92,3 +93,77 @@ func TestPodPolicyGetAndSet(t *testing.T) { t.Fatal("expected an error for an unknown policy") } } + +// wiringServer is a fake of the server's wiring routes: PUT replaces the stored map. +func wiringServer(t *testing.T, stored string) (*httptest.Server, *string, *int) { + t.Helper() + puts := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/benchpod/devices/dev-1/wiring" || r.Header.Get("Authorization") != "Bearer tok" { + w.WriteHeader(http.StatusNotFound) + return + } + switch r.Method { + case http.MethodGet: + _, _ = io.WriteString(w, stored) + case http.MethodPut: + puts++ + body, _ := io.ReadAll(r.Body) + if !json.Valid(body) { + w.WriteHeader(http.StatusBadRequest) + return + } + stored = string(body) + _, _ = w.Write(body) + } + })) + t.Cleanup(ts.Close) + return ts, &stored, &puts +} + +func TestSetDeviceWiringLaMVKeepsOtherFields(t *testing.T) { + ts, stored, puts := wiringServer(t, `{"uart_rx":5,"uart_baud":115200,"names":{"READY":3},"note":"bench A","la_mv":1800}`) + changed, err := New(ts.URL).SetDeviceWiringLaMV(context.Background(), "tok", "dev-1", 3300) + if err != nil || !changed || *puts != 1 { + t.Fatalf("changed %v err %v puts %d", changed, err, *puts) + } + var got map[string]any + if err := json.Unmarshal([]byte(*stored), &got); err != nil { + t.Fatal(err) + } + want := map[string]any{"uart_rx": 5.0, "uart_baud": 115200.0, "names": map[string]any{"READY": 3.0}, "note": "bench A", "la_mv": 3300.0} + if !reflect.DeepEqual(got, want) { + t.Fatalf("stored %s", *stored) + } + // Already that voltage: no write. + changed, err = New(ts.URL).SetDeviceWiringLaMV(context.Background(), "tok", "dev-1", 3300) + if err != nil || changed || *puts != 1 { + t.Fatalf("changed %v err %v puts %d", changed, err, *puts) + } +} + +func TestSetDeviceWiringLaMVOnAnEmptyProfile(t *testing.T) { + ts, stored, _ := wiringServer(t, `{}`) + if changed, err := New(ts.URL).SetDeviceWiringLaMV(context.Background(), "tok", "dev-1", 1800); err != nil || !changed { + t.Fatalf("changed %v err %v", changed, err) + } + if *stored != `{"la_mv":1800}` { + t.Fatalf("stored %s", *stored) + } +} + +func TestSetDeviceWiringLaMVErrors(t *testing.T) { + ts, _, puts := wiringServer(t, `{}`) + c := New(ts.URL) + if _, err := c.SetDeviceWiringLaMV(context.Background(), "tok", "dev-1", 2500); err == nil { + t.Fatal("accepted 2500 mV") + } + if _, err := c.SetDeviceWiringLaMV(context.Background(), "tok", "", 3300); err == nil { + t.Fatal("accepted an empty device id") + } + _, err := c.SetDeviceWiringLaMV(context.Background(), "tok", "other", 3300) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusNotFound || *puts != 0 { + t.Fatalf("got %v, puts %d", err, *puts) + } +}