Skip to content

Commit 4145920

Browse files
committed
feat(capabilities): pico.verify and per-device targeting
Add pico.verify so an operator can read back Pico flash and compare it byte-for-byte against a UF2/ELF/BIN on the host. Read-only on the device, so no risk-consent gate. Existing pico.* handlers now accept an optional serial arg (forwarded as `--id`) so a host with multiple Picos can pin a command to one board. pico.flash and pico.verify also take an optional family. The RP2350 reboot/bootsel paths gain cpu and partition options. Validation runs before the consent prompt so a typo never triggers a pointless popup.
1 parent 6850dc1 commit 4145920

2 files changed

Lines changed: 375 additions & 24 deletions

File tree

internal/capabilities/pico.go

Lines changed: 143 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func RegisterPico(r *dispatch.Router) {
2121
r.Register("pico.info", picoInfo)
2222
r.Register("pico.bootsel", picoBootsel)
2323
r.Register("pico.flash", picoFlash)
24+
r.Register("pico.verify", picoVerify)
2425
r.Register("pico.save", picoSave)
2526
r.Register("pico.reset", picoReset)
2627
}
@@ -42,6 +43,47 @@ func runPicotool(ctx context.Context, args ...string) (stdout, stderr string, er
4243
return out.String(), errb.String(), runErr
4344
}
4445

46+
// picoStringArg pulls a string off the JSON args map and trims it.
47+
// Missing keys, wrong types, and whitespace-only values all collapse
48+
// to the empty string so callers can do a single `if s == ""` check.
49+
func picoStringArg(args map[string]json.RawMessage, key string) string {
50+
v, ok := args[key]
51+
if !ok {
52+
return ""
53+
}
54+
var s string
55+
_ = json.Unmarshal(v, &s)
56+
return strings.TrimSpace(s)
57+
}
58+
59+
// withSerial appends `--id <ser>` to a picotool arg slice when a
60+
// serial number was supplied. Pins a command to a specific board
61+
// when multiple Picos are connected to the same host.
62+
func withSerial(args []string, serial string) []string {
63+
if serial == "" {
64+
return args
65+
}
66+
return append(args, "--id", serial)
67+
}
68+
69+
// picoFamilies are the picotool `--family` identifiers we accept on
70+
// load/verify. Validated up front so a typo doesn't reach picotool.
71+
var picoFamilies = map[string]struct{}{
72+
"rp2040": {},
73+
"rp2350-arm-s": {},
74+
"rp2350-arm-ns": {},
75+
"rp2350-riscv": {},
76+
"absolute": {},
77+
"data": {},
78+
}
79+
80+
// picoCPUs are the architectures picotool can switch to on RP2350
81+
// via `reboot -c`. RP2040 has only one core arch and will reject it.
82+
var picoCPUs = map[string]struct{}{
83+
"arm": {},
84+
"riscv": {},
85+
}
86+
4587
func picoList(ctx context.Context, _ map[string]json.RawMessage) (interface{}, error) {
4688
out, errb, err := runPicotool(ctx, "info", "-a")
4789
if err != nil {
@@ -60,26 +102,37 @@ func picoList(ctx context.Context, _ map[string]json.RawMessage) (interface{}, e
60102
}
61103

62104
func picoInfo(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
63-
cmdArgs := []string{"info", "-a", "-m", "-d", "-l"}
64-
if v, ok := args["serial"]; ok {
65-
var s string
66-
_ = json.Unmarshal(v, &s)
67-
if s != "" {
68-
cmdArgs = append(cmdArgs, "--id", s)
69-
}
70-
}
105+
cmdArgs := withSerial([]string{"info", "-a", "-m", "-d", "-l"}, picoStringArg(args, "serial"))
71106
out, errb, err := runPicotool(ctx, cmdArgs...)
72107
if err != nil {
73108
return map[string]interface{}{"ok": false, "reason": err.Error(), "stderr": strings.TrimSpace(errb)}, nil
74109
}
75110
return map[string]interface{}{"ok": true, "raw": out}, nil
76111
}
77112

78-
func picoBootsel(ctx context.Context, _ map[string]json.RawMessage) (interface{}, error) {
113+
func picoBootselArgs(args map[string]json.RawMessage) ([]string, error) {
114+
cmdArgs := []string{"reboot", "-f", "-u"}
115+
if cpu := picoStringArg(args, "cpu"); cpu != "" {
116+
if _, ok := picoCPUs[cpu]; !ok {
117+
return nil, fmt.Errorf("pico.bootsel: cpu must be 'arm' or 'riscv'")
118+
}
119+
cmdArgs = append(cmdArgs, "-c", cpu)
120+
}
121+
if part := picoStringArg(args, "partition"); part != "" {
122+
cmdArgs = append(cmdArgs, "-g", part)
123+
}
124+
return withSerial(cmdArgs, picoStringArg(args, "serial")), nil
125+
}
126+
127+
func picoBootsel(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
128+
cmdArgs, err := picoBootselArgs(args)
129+
if err != nil {
130+
return nil, err
131+
}
79132
if err := requireRiskConsent(ctx, "pico.bootsel", "Reboots an attached Pico into BOOTSEL mode. Connected software may lose its current device connection."); err != nil {
80133
return nil, err
81134
}
82-
out, errb, err := runPicotool(ctx, "reboot", "-f", "-u")
135+
out, errb, err := runPicotool(ctx, cmdArgs...)
83136
if err != nil {
84137
return map[string]interface{}{
85138
"ok": false,
@@ -91,47 +144,113 @@ func picoBootsel(ctx context.Context, _ map[string]json.RawMessage) (interface{}
91144
return map[string]interface{}{"ok": true, "raw": out}, nil
92145
}
93146

94-
func picoFlash(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
95-
var uf2 string
96-
if v, ok := args["uf2_path"]; ok {
97-
_ = json.Unmarshal(v, &uf2)
98-
}
147+
func picoFlashArgs(args map[string]json.RawMessage) (cmdArgs []string, uf2 string, err error) {
148+
uf2 = picoStringArg(args, "uf2_path")
99149
if uf2 == "" {
100-
return nil, fmt.Errorf("pico.flash: 'uf2_path' is required")
150+
return nil, "", fmt.Errorf("pico.flash: 'uf2_path' is required")
151+
}
152+
cmdArgs = []string{"load", "-fx"}
153+
if family := picoStringArg(args, "family"); family != "" {
154+
if _, ok := picoFamilies[family]; !ok {
155+
return nil, "", fmt.Errorf("pico.flash: unknown family %q", family)
156+
}
157+
cmdArgs = append(cmdArgs, "--family", family)
158+
}
159+
cmdArgs = append(cmdArgs, uf2)
160+
return withSerial(cmdArgs, picoStringArg(args, "serial")), uf2, nil
161+
}
162+
163+
func picoFlash(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
164+
cmdArgs, uf2, err := picoFlashArgs(args)
165+
if err != nil {
166+
return nil, err
101167
}
102168
if err := requireRiskConsent(ctx, "pico.flash", "Flashes firmware to an attached Pico from a UF2 file. Bad firmware can make the device stop working until it is reflashed."); err != nil {
103169
return nil, err
104170
}
105-
out, errb, err := runPicotool(ctx, "load", "-fx", uf2)
171+
out, errb, err := runPicotool(ctx, cmdArgs...)
106172
if err != nil {
107173
return map[string]interface{}{"ok": false, "reason": err.Error(), "stderr": strings.TrimSpace(errb), "stdout": strings.TrimSpace(out)}, nil
108174
}
109175
return map[string]interface{}{"ok": true, "uf2": uf2, "raw": out}, nil
110176
}
111177

112-
func picoSave(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
113-
var out string
114-
if v, ok := args["out_path"]; ok {
115-
_ = json.Unmarshal(v, &out)
178+
func picoVerifyArgs(args map[string]json.RawMessage) (cmdArgs []string, path string, err error) {
179+
path = picoStringArg(args, "file_path")
180+
if path == "" {
181+
return nil, "", fmt.Errorf("pico.verify: 'file_path' is required")
182+
}
183+
cmdArgs = []string{"verify", "-f"}
184+
if family := picoStringArg(args, "family"); family != "" {
185+
if _, ok := picoFamilies[family]; !ok {
186+
return nil, "", fmt.Errorf("pico.verify: unknown family %q", family)
187+
}
188+
cmdArgs = append(cmdArgs, "--family", family)
116189
}
190+
cmdArgs = append(cmdArgs, path)
191+
return withSerial(cmdArgs, picoStringArg(args, "serial")), path, nil
192+
}
193+
194+
// picoVerify reads back flash from the device and compares it byte
195+
// for byte to a UF2/ELF/BIN on disk. Read-only on the device, so no
196+
// risk-consent gate -- the host is not changed by a verify.
197+
func picoVerify(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
198+
cmdArgs, path, err := picoVerifyArgs(args)
199+
if err != nil {
200+
return nil, err
201+
}
202+
out, errb, runErr := runPicotool(ctx, cmdArgs...)
203+
if runErr != nil {
204+
return map[string]interface{}{
205+
"ok": false,
206+
"reason": runErr.Error(),
207+
"stderr": strings.TrimSpace(errb),
208+
"stdout": strings.TrimSpace(out),
209+
"file_path": path,
210+
}, nil
211+
}
212+
return map[string]interface{}{"ok": true, "file_path": path, "raw": out}, nil
213+
}
214+
215+
func picoSave(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
216+
out := picoStringArg(args, "out_path")
117217
if out == "" {
118218
return nil, fmt.Errorf("pico.save: 'out_path' is required")
119219
}
120220
if err := requireRiskConsent(ctx, "pico.save", "Writes a Pico flash dump to a file on this computer. Existing files may be replaced by picotool behavior."); err != nil {
121221
return nil, err
122222
}
123-
stdout, errb, err := runPicotool(ctx, "save", "-a", out)
223+
cmdArgs := withSerial([]string{"save", "-a", out}, picoStringArg(args, "serial"))
224+
stdout, errb, err := runPicotool(ctx, cmdArgs...)
124225
if err != nil {
125226
return map[string]interface{}{"ok": false, "reason": err.Error(), "stderr": strings.TrimSpace(errb), "stdout": strings.TrimSpace(stdout)}, nil
126227
}
127228
return map[string]interface{}{"ok": true, "out_path": out, "raw": stdout}, nil
128229
}
129230

130-
func picoReset(ctx context.Context, _ map[string]json.RawMessage) (interface{}, error) {
231+
func picoResetArgs(args map[string]json.RawMessage) ([]string, error) {
232+
cmdArgs := []string{"reboot"}
233+
if cpu := picoStringArg(args, "cpu"); cpu != "" {
234+
if _, ok := picoCPUs[cpu]; !ok {
235+
return nil, fmt.Errorf("pico.reset: cpu must be 'arm' or 'riscv'")
236+
}
237+
cmdArgs = append(cmdArgs, "-c", cpu)
238+
}
239+
if part := picoStringArg(args, "partition"); part != "" {
240+
cmdArgs = append(cmdArgs, "-g", part)
241+
}
242+
return withSerial(cmdArgs, picoStringArg(args, "serial")), nil
243+
}
244+
245+
func picoReset(ctx context.Context, args map[string]json.RawMessage) (interface{}, error) {
246+
cmdArgs, err := picoResetArgs(args)
247+
if err != nil {
248+
return nil, err
249+
}
131250
if err := requireRiskConsent(ctx, "pico.reset", "Reboots an attached Pico. Connected software may lose its current device connection."); err != nil {
132251
return nil, err
133252
}
134-
out, errb, err := runPicotool(ctx, "reboot")
253+
out, errb, err := runPicotool(ctx, cmdArgs...)
135254
if err != nil {
136255
return map[string]interface{}{"ok": false, "reason": err.Error(), "stderr": strings.TrimSpace(errb), "stdout": strings.TrimSpace(out)}, nil
137256
}

0 commit comments

Comments
 (0)