diff --git a/CHANGELOG.md b/CHANGELOG.md index dcec82a..d30c47f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- `prusactl firmware` and the `get_firmware_status` and `update_firmware` tools, + through Prusa Connect only. Status shows the running and the latest firmware + and whether they differ. `update` has Connect copy its hosted `.bbf` to the + printer's USB drive, then sends `FLASH` to install it. While a print is running + it refuses before copying anything. Whether an update is due is Prusa Connect's + own verdict, so a printer newer than Connect's latest is never downgraded by + accident, and `installed` is true only when the printer accepted `FLASH`. - **The camera works without Prusa Connect.** The Buddy3D camera is a separate Wi-Fi device that talks to Connect itself and serves RTSP at `rtsp:///live`; Buddy firmware's PrusaLink has no camera endpoint, diff --git a/README.md b/README.md index 1ca0c19..5b27f58 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,8 @@ The printer's PrusaLink password is kept in the same keychain. | `get_telemetry`, `list_events` | Connect | Telemetry history and the event log | | `list_connect_files` | Connect | Connect cloud storage | | `delete_connect_files` | Connect | Delete from Connect cloud storage, freeing the team's quota | +| `get_firmware_status` | Connect | The firmware the printer runs, the latest Connect offers, and whether they differ | +| `update_firmware` | Connect | Copy the firmware to the printer's USB drive and install it with `FLASH` (see [Firmware](#firmware)) | | `api_request` | both | Any other endpoint: `/api/...` goes to the printer, `/app/...` to Connect | Every `printer` argument accepts a name, serial number, or Connect UUID. With one @@ -290,6 +292,7 @@ prusactl telemetry recorded temperatures and speeds prusactl transfers file transfers in progress prusactl camera [FILE] save a snapshot from the printer's camera (RTSP or Connect) prusactl cmd ls|send|status run a firmware command through Prusa Connect +prusactl firmware [update] the printer's firmware; update installs the latest via Connect prusactl api [METHOD] PATH [JSON] /api/... to the printer, /app/... to Prusa Connect prusactl completion bash|zsh|fish print a shell completion script @@ -322,6 +325,26 @@ leaves the printer alone; `--no-camera` forgets just the camera). carries the PrusaLink and Connect keys), so its output is safe to paste or hand to an agent. `--raw` shows them. +## Firmware + +`prusactl firmware` shows the firmware the printer runs and the latest one Prusa +Connect offers for it. `prusactl firmware update` has Connect copy its hosted +`.bbf` to the printer's USB drive and then installs it with the `FLASH` command, +which restarts the printer. It waits for the copy. Without `--version` it goes by +Connect's own verdict on the firmware, and refuses to decide when there is none. + +```sh +prusactl firmware # running vs latest +prusactl firmware update [--version X] # copy, then install +``` + +`FLASH` is accepted only while the printer is idle, ready, finished or stopped. +While a print is running it refuses before copying anything; run it again when the +print has ended. Whether an update is due is Prusa Connect's own verdict, so a printer +newer than Connect's latest is never downgraded unless you pass `--version`. The +printer may ask for a confirmation on its screen. +Firmware goes through Prusa Connect only, so you need `prusactl login`. + ## Limits - **It has no hands.** It can't clear the build plate, swap a spool, or fix a diff --git a/cmd/prusactl/firmware.go b/cmd/prusactl/firmware.go new file mode 100644 index 0000000..5d6aebe --- /dev/null +++ b/cmd/prusactl/firmware.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +// firmwareCmd is the terminal side of get_firmware_status and update_firmware. +func firmwareCmd(ctx context.Context, o options, pos []string) error { + act, rest, err := action("firmware", pos, "status", "update") + if err != nil { + return err + } + if err := atMost("firmware "+act, rest, 0, "prusactl firmware "+act); err != nil { + return err + } + args := printerArgs(o) + if act == "status" { + if err := notForAction(o, "firmware", act, "version"); err != nil { + return err + } + return runTool(ctx, "get_firmware_status", args, o.on("json"), func(raw json.RawMessage) error { + renderFirmwareStatus(os.Stdout, decodeMap(raw)) + return nil + }) + } + if v := o.str("version"); v != "" { + args["version"] = v + } + return runTool(ctx, "update_firmware", args, o.on("json"), func(raw json.RawMessage) error { + m := decodeMap(raw) + fmt.Println(field(m, "note")) + return nil + }) +} + +// renderFirmwareStatus prints a get_firmware_status result. +func renderFirmwareStatus(w io.Writer, m map[string]any) { + fmt.Fprintf(w, "%-9s %s\n", "Printer:", field(m, "printer")) + fmt.Fprintf(w, "%-9s %s\n", "Running:", field(m, "current")) + fmt.Fprintf(w, "%-9s %s (%s)\n", "Latest:", field(m, "latest"), field(m, "state")) + switch { + case field(m, "latest") == "": + // Checked before the verdict on purpose: with no latest version there + // is nothing to update to whatever the verdict says. + fmt.Fprintln(w, "\nPrusa Connect doesn't say which firmware is the latest for this printer.") + case field(m, "update_available") == "": // no verdict: the tool said why + fmt.Fprintf(w, "\n%s.\n", strings.TrimSuffix(field(m, "note"), ".")) + case field(m, "update_available") != "true": + fmt.Fprintln(w, "\nUp to date.") + case field(m, "update_supported") == "true": + fmt.Fprintln(w, "\nAn update is available: `prusactl firmware update` installs it.") + default: + fmt.Fprintln(w, "\nAn update is available, but Prusa Connect doesn't offer it for this printer.") + } +} diff --git a/cmd/prusactl/help.go b/cmd/prusactl/help.go index 9bc2d9f..a3ae59c 100644 --- a/cmd/prusactl/help.go +++ b/cmd/prusactl/help.go @@ -279,6 +279,28 @@ the last.`, Flags: flags(printerFlags, []flagSpec{{Name: "camera", Value: "ID", Usage: "which camera, when there are several"}}), Pos: []posArg{{Label: "file", Kind: "files"}}, }, + { + Name: "firmware", + Args: "[status | update]", + Summary: "Check the printer's firmware, and install an update through Prusa Connect", + Help: `status (the default) shows the firmware the printer runs and the latest one +Prusa Connect offers for it. + +update has Prusa Connect copy the firmware file to the printer's USB drive, +then installs it with the FLASH command, which restarts the printer. It waits +for the copy (a few minutes at most). Without --version it goes by Prusa +Connect's own verdict: it installs the latest when Connect says the firmware is +outdated, does nothing when Connect says it is current, and refuses when there +is no verdict, so a printer newer than Connect's latest is never downgraded by +accident. It refuses before copying anything unless the printer is idle, ready, +finished or stopped; a print that starts during the copy leaves the file on the +drive, not installed, and the result says so. The printer may ask you to +confirm on its screen. Both go through Prusa Connect only.`, + Flags: flags(printerFlags, []flagSpec{ + {Name: "version", Value: "X.Y.Z", Usage: "for update, a version other than the latest, e.g. 6.5.7"}, + }), + Pos: []posArg{{Label: "action", Kind: "status update"}}, + }, { Name: "cmd", Args: "ls | send NAME [key=value ...] | status COMMAND-ID", diff --git a/cmd/prusactl/main.go b/cmd/prusactl/main.go index 6e64a6a..d2c924e 100644 --- a/cmd/prusactl/main.go +++ b/cmd/prusactl/main.go @@ -171,6 +171,7 @@ func run(ctx context.Context, args []string) error { "transfers": transfersCmd, "camera": cameraCmd, "cmd": cmdCmd, + "firmware": firmwareCmd, } if h, ok := handlers[name]; ok { opts, pos, err := cmd.parse(rest) diff --git a/cmd/prusactl/tools.go b/cmd/prusactl/tools.go index 374e093..f0fe00f 100644 --- a/cmd/prusactl/tools.go +++ b/cmd/prusactl/tools.go @@ -38,7 +38,12 @@ func openTools(ctx context.Context) (*toolClient, error) { if _, err := srv.Connect(ctx, serverT); err != nil { return nil, err } - cs, err := mcp.NewClient(&mcp.Implementation{Name: "prusactl-cli", Version: buildVersion()}, nil).Connect(ctx, clientT, nil) + cs, err := mcp.NewClient(&mcp.Implementation{Name: "prusactl-cli", Version: buildVersion()}, &mcp.ClientOptions{ + // A tool that waits on the printer says how far it is. + ProgressNotificationHandler: func(_ context.Context, r *mcp.ProgressNotificationClientRequest) { + fmt.Fprintln(os.Stderr, r.Params.Message) + }, + }).Connect(ctx, clientT, nil) if err != nil { return nil, err } @@ -48,7 +53,9 @@ func openTools(ctx context.Context) (*toolClient, error) { // call runs a tool and returns its JSON result. A tool that reports an error // returns it as an error, so the CLI exits non-zero with the tool's own message. func (t *toolClient) call(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { - res, err := t.session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + params := &mcp.CallToolParams{Name: name, Arguments: args} + params.SetProgressToken("prusactl") + res, err := t.session.CallTool(ctx, params) if err != nil { return nil, err } @@ -132,6 +139,7 @@ var spellings = []spelling{ "call again with plate_clear=true", "run it again with --plate-clear"}, {"; pass printer as one of", "pass printer as one of", "pass --printer as one of"}, + {"; pass version", "pass version", "pass --version"}, {"or delete it through Prusa Connect with", `delete it through Prusa Connect with via="connect"`, "delete it through Prusa Connect with --via connect"}, // via=connect and via=direct reach the reader interpolated, so they are diff --git a/cmd/prusactl/tools_test.go b/cmd/prusactl/tools_test.go index 23b979f..85d89e0 100644 --- a/cmd/prusactl/tools_test.go +++ b/cmd/prusactl/tools_test.go @@ -202,3 +202,30 @@ func TestRewritesReachTheReaderAsFlags(t *testing.T) { } } } + +func TestRenderFirmwareStatus(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {`{"printer":"p","current":"6.5.7+1","latest":"6.8.1","state":"outdated","update_available":true,"update_supported":true}`, "`prusactl firmware update`"}, + {`{"printer":"p","current":"6.8.1+2","latest":"6.8.1","state":"supported","update_available":false,"update_supported":true}`, "Up to date."}, + {`{"printer":"p","current":"6.8.1","update_available":false}`, "doesn't say which firmware is the latest"}, + } { + var b strings.Builder + renderFirmwareStatus(&b, decodeMap(json.RawMessage(tc.in))) + if !strings.Contains(b.String(), tc.want) { + t.Errorf("%s: missing %q in:\n%s", tc.in, tc.want, b.String()) + } + } +} + +// With no verdict from Connect the status tool leaves update_available out and +// says why; the terminal must print that reason, not "Up to date". +func TestRenderFirmwareStatusWithoutAVerdict(t *testing.T) { + var out strings.Builder + renderFirmwareStatus(&out, map[string]any{ + "printer": "Core One", "current": "6.5.7", "latest": "6.8.1", "state": "unsupported", + "note": "Prusa Connect gives no verdict on this firmware (state \"unsupported\"), so whether an update is due is unknown; an update with an explicit version is still accepted", + }) + if strings.Contains(out.String(), "Up to date") || !strings.Contains(out.String(), "no verdict") { + t.Errorf("rendered:\n%s", out.String()) + } +} diff --git a/internal/server/connect_tools_test.go b/internal/server/connect_tools_test.go index b81be74..e2c861f 100644 --- a/internal/server/connect_tools_test.go +++ b/internal/server/connect_tools_test.go @@ -142,6 +142,11 @@ func (f *fakeConnect) sentCommands() []string { } func connectConnectTools(t *testing.T, fc http.Handler, tweaks ...func(*Server)) *mcp.ClientSession { + t.Helper() + return connectConnectToolsWith(t, nil, fc, tweaks...) +} + +func connectConnectToolsWith(t *testing.T, opts *mcp.ClientOptions, fc http.Handler, tweaks ...func(*Server)) *mcp.ClientSession { t.Helper() appdirtest.Use(t) // the refresh lock file lives under the config dir srv := httptest.NewServer(fc) @@ -166,7 +171,7 @@ func connectConnectTools(t *testing.T, fc http.Handler, tweaks ...func(*Server)) if _, err := s.mcp.Connect(ctx, st, nil); err != nil { t.Fatal(err) } - cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, ct, nil) + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, opts).Connect(ctx, ct, nil) if err != nil { t.Fatal(err) } diff --git a/internal/server/server.go b/internal/server/server.go index ddf6412..7df2d0f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -25,7 +25,7 @@ import ( const instructions = `Controls the user's Prusa 3D printer. -Two routes reach it. Direct: the printer's own API on the local network (PrusaLink), used whenever the printer is reachable. Prusa Connect: Prusa's cloud service, used when the printer isn't reachable directly (e.g. the user is away) and for things only Connect offers: camera, on-screen dialogs, the print queue, history, events, and firmware commands such as heating and moving. Tools that can take either route say which one they used ("via"); the rest are named above and always use the route they need. +Two routes reach it. Direct: the printer's own API on the local network (PrusaLink), used whenever the printer is reachable. Prusa Connect: Prusa's cloud service, used when the printer isn't reachable directly (e.g. the user is away) and for things only Connect offers: camera, on-screen dialogs, the print queue, history, events, and commands such as heating, moving and installing firmware. Tools that can take either route say which one they used ("via"); the rest are named above and always use the route they need. Start with connection_status or get_printer. Printer arguments accept a name, serial number, or Connect UUID, and can be omitted with a single printer. @@ -65,6 +65,10 @@ type Server struct { cameraURL func() (string, error) grabber snapshotter + // How often update_firmware polls the copy to the printer, and how long it + // waits for a sign of progress. + fwPoll, fwStall time.Duration + probeMu sync.Mutex probed time.Time info *linkInfo @@ -74,7 +78,7 @@ type Server struct { // New builds the MCP server and registers every tool. lc may be nil, with // lcErr explaining why. func New(session *auth.Session, cc *connect.Client, lc *link.Client, lcErr error, version string) *Server { - s := &Server{session: session, connect: cc, link: lc, linkErr: lcErr, openLink: link.Open, cameraURL: link.LoadCamera, grabber: camera.Grabber{}} + s := &Server{session: session, connect: cc, link: lc, linkErr: lcErr, openLink: link.Open, cameraURL: link.LoadCamera, grabber: camera.Grabber{}, fwPoll: 2 * time.Second, fwStall: 2 * time.Minute} s.mcp = mcp.NewServer( &mcp.Implementation{Name: "prusactl", Title: "Prusa printer", Version: version}, &mcp.ServerOptions{Instructions: instructionsFor()}, @@ -83,6 +87,7 @@ func New(session *auth.Session, cc *connect.Client, lc *link.Client, lcErr error s.addPrinterTools() s.addControlTools() s.addFileTools() + s.addFirmwareTools() s.addJobTools() s.addAPITool() return s diff --git a/internal/server/storage_test.go b/internal/server/storage_test.go new file mode 100644 index 0000000..88f1aa2 --- /dev/null +++ b/internal/server/storage_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/trevin-lee/prusactl/internal/appdir/appdirtest" + "github.com/trevin-lee/prusactl/internal/auth" + "github.com/trevin-lee/prusactl/internal/connect" + "github.com/trevin-lee/prusactl/internal/link" +) + +// connectServer returns a Server whose Connect client talks to h. +func connectServer(t *testing.T, h http.Handler) *Server { + t.Helper() + appdirtest.Use(t) // the refresh lock file lives under the config dir + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + session := &auth.Session{Store: signedIn{}} + cc := connect.New(session, "test") + cc.BaseURL = srv.URL + s := New(session, cc, nil, link.ErrNotConfigured, "test") + s.openLink = func() (*link.Client, error) { return nil, link.ErrNotConfigured } + return s +} + +func TestDefaultStorage(t *testing.T) { + tests := []struct { + name string + storages string + want string + wantErr string + }{ + {"prefers USB over the first writable", `[{"path":"/local","type":"LOCAL"},{"path":"/usb","type":"USB"}]`, "/usb/", ""}, + {"USB type is case-insensitive", `[{"path":"/local","type":"local"},{"path":"/usb","type":"usb"}]`, "/usb/", ""}, + {"skips read-only USB", `[{"path":"/local","type":"LOCAL"},{"path":"/usb","type":"USB","read_only":true}]`, "/local/", ""}, + {"skips corrupted USB", `[{"path":"/usb","type":"USB","corrupted":true},{"path":"/local","type":"LOCAL"}]`, "/local/", ""}, + {"falls back to the first writable", `[{"path":"/a","type":"LOCAL"},{"path":"/b","type":"SD"}]`, "/a/", ""}, + {"uses mountpoint without a path", `[{"mountpoint":"/usb/","type":"USB"}]`, "/usb/", ""}, + {"skips entries without a path", `[{"type":"USB"},{"path":"/local","type":"LOCAL"}]`, "/local/", ""}, + {"no writable storage", `[{"path":"/usb","type":"USB","read_only":true}]`, "", "no writable storage"}, + {"no storage at all", `[]`, "", "no writable storage"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := connectServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/app/printers/u1/storages" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"storages":%s}`, tt.storages) + })) + got, err := s.defaultStorage(context.Background(), "u1") + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want one containing %q", err, tt.wantErr) + } + return + } + if err != nil || got != tt.want { + t.Errorf("got %q, %v; want %q", got, err, tt.want) + } + }) + } +} + +// A command refused for the printer's state is a *stateError, so a caller can +// tell it from any other failure. +func TestRunCommandStateError(t *testing.T) { + tests := []struct { + state string + wantType bool + }{ + {"PRINTING", true}, + {"IDLE", false}, // allowed: it is sent, and the fake accepts it + } + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + s := connectServer(t, &fakeConnect{state: tt.state}) + p := printerSummary{UUID: "u1", Name: "Core One", TeamID: 1} + _, err := s.runCommand(context.Background(), p, "HOME", nil, false, 0) + var se *stateError + if got := errors.As(err, &se); got != tt.wantType { + t.Fatalf("errors.As(%v) = %v, want %v", err, got, tt.wantType) + } + if tt.wantType && !strings.Contains(err.Error(), "can't run HOME while PRINTING") { + t.Errorf("message = %q", err) + } + if !tt.wantType && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} diff --git a/internal/server/tools_control.go b/internal/server/tools_control.go index 7742f1e..c7d772d 100644 --- a/internal/server/tools_control.go +++ b/internal/server/tools_control.go @@ -69,6 +69,11 @@ func connectState(p map[string]any, uuid string) (string, error) { return "", connect.Missing("GET", printerPath(uuid), "connect_state") } +// stateError is a command refused because of the printer's current state. +type stateError struct{ msg string } + +func (e *stateError) Error() string { return e.msg } + type commandResult struct { Printer string `json:"printer"` Command string `json:"command"` @@ -107,7 +112,7 @@ func commandFacts(raw json.RawMessage) (int64, string) { // runCommand validates a command against what the printer supports and its // current state, then sends it. Synchronous commands wait for the printer to // acknowledge; asynchronous ones return the queued command record. -func (s *Server) runCommand(ctx context.Context, p printerSummary, command string, kwargs map[string]any, async bool, timeout int) (*commandResult, error) { +func (s *Server) runCommand(ctx context.Context, p printerSummary, command string, kwargs map[string]any, async bool, timeout int, onSend ...func()) (*commandResult, error) { command = strings.ToUpper(strings.TrimSpace(command)) if kwargs == nil { kwargs = map[string]any{} @@ -146,11 +151,14 @@ func (s *Server) runCommand(ctx context.Context, p printerSummary, command strin return nil, err } if !slices.Contains(match.ExecutableFromState, st) { - return nil, fmt.Errorf("%s can't run %s while %s; allowed in: %s", - p.Name, command, st, strings.Join(match.ExecutableFromState, ", ")) + return nil, &stateError{fmt.Sprintf("%s can't run %s while %s; allowed in: %s", + p.Name, command, st, strings.Join(match.ExecutableFromState, ", "))} } } + for _, f := range onSend { // the command passed every check and goes out now + f() + } body := map[string]any{"kwargs": kwargs} path := printerPath(p.UUID, "commands") q := url.Values{} diff --git a/internal/server/tools_files.go b/internal/server/tools_files.go index 3f630c1..1e3932f 100644 --- a/internal/server/tools_files.go +++ b/internal/server/tools_files.go @@ -860,26 +860,36 @@ func (s *Server) uploadViaConnect(ctx context.Context, p printerSummary, localPa return &uploadResult{Hash: hash, Path: path.Join(destination, filename), Upload: created, File: fileJSON}, nil } -// defaultStorage returns the path of the printer's first storage. +// defaultStorage returns the path of the printer's first writable storage, +// preferring a USB drive. func (s *Server) defaultStorage(ctx context.Context, uuid string) (string, error) { var resp struct { Storages []struct { Path string `json:"path"` Mountpoint string `json:"mountpoint"` + Type string `json:"type"` ReadOnly bool `json:"read_only"` + Corrupted bool `json:"corrupted"` } `json:"storages"` } if err := s.connect.Get(ctx, printerPath(uuid, "storages"), nil, &resp); err != nil { return "", err } + best, bestUSB := "", false for _, st := range resp.Storages { path := st.Path if path == "" { path = st.Mountpoint } - if path != "" && !st.ReadOnly { - return strings.TrimRight(path, "/") + "/", nil + if path == "" || st.ReadOnly || st.Corrupted { + continue } + if usb := strings.EqualFold(st.Type, "USB"); best == "" || (usb && !bestUSB) { + best, bestUSB = path, usb + } + } + if best == "" { + return "", errors.New("the printer reports no writable storage (is a USB drive inserted?)") } - return "", errors.New("the printer reports no writable storage (is a USB drive inserted?)") + return strings.TrimRight(best, "/") + "/", nil } diff --git a/internal/server/tools_firmware.go b/internal/server/tools_firmware.go new file mode 100644 index 0000000..36dabbf --- /dev/null +++ b/internal/server/tools_firmware.go @@ -0,0 +1,329 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "slices" + "strconv" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/trevin-lee/prusactl/internal/connect" +) + +// Firmware goes through Prusa Connect only: Connect hosts the .bbf files, +// copies one to the printer's USB drive (the download queue), and tells the +// printer to install it (the FLASH command). + +type updateInput struct { + printerRef + Version string `json:"version,omitempty" jsonschema:"firmware version to install, e.g. 6.8.1; default is the latest one Connect offers for this printer"` +} + +// stripBuild drops the +build suffix, so 6.8.1+16182 equals 6.8.1. +func stripBuild(v string) string { + v, _, _ = strings.Cut(strings.TrimSpace(v), "+") + return v +} + +// firmwareFacts is what Prusa Connect's printer record says about firmware. +type firmwareFacts struct { + Current, Latest, State string + CanUpdate bool +} + +func firmwareOf(detail map[string]any) firmwareFacts { + f := firmwareFacts{ + Current: str(detail, "support", "current"), + Latest: str(detail, "support", "latest"), + State: str(detail, "support", "state"), + } + if f.Current == "" { + f.Current = str(detail, "firmware") + } + funcs, _ := detail["allowed_functionalities"].([]any) + f.CanUpdate = slices.Contains(funcs, any("fw_update")) + return f +} + +// verdict is Prusa Connect's own reading of the running firmware, the one its +// update button goes by: "outdated" means an update is due, "current" that +// Connect calls it supported, and "unknown" anything else, including no state +// at all, which nothing is decided on. Comparing version strings instead would +// call a printer running something newer than Connect's "latest" (a release +// candidate, say) out of date and flash the older build over it. +func (f firmwareFacts) verdict() string { + switch f.State { + case "outdated": + return "outdated" + case "supported": + return "current" + } + return "unknown" +} + +// explicitVersionHint finishes a note that couldn't decide, with what is +// still possible: an update by explicit version is accepted (not "installs": +// the printer can still refuse the flash), and only where Connect offers +// updates for the printer at all. +func explicitVersionHint(canUpdate bool) string { + if canUpdate { + return "; an update with an explicit version is still accepted" + } + return "; and Prusa Connect doesn't offer firmware updates for this printer" +} + +// firmwareRecord looks up a firmware file Connect hosts, by version or, when +// the version is empty, as the latest for the printer. The same lookup by hash +// says where the file is on the printer (path) or how its copy is going. +func (s *Server) firmwareRecord(ctx context.Context, uuid string, q url.Values) (map[string]any, error) { + var rec map[string]any + if err := s.connect.Get(ctx, printerPath(uuid, "firmware"), q, &rec); err != nil { + return nil, err + } + if str(rec, "hash") == "" { + return nil, connect.Missing("GET", printerPath(uuid, "firmware"), "hash") + } + return rec, nil +} + +// progressFunc tells a client that asked (it passed a progress token) how a +// long step is going. +func progressFunc(ctx context.Context, req *mcp.CallToolRequest) func(string) { + step, last := 0, "" + return func(msg string) { + if msg == last { // a poll that shows nothing new + return + } + last = msg + step++ + if tok := req.Params.GetProgressToken(); tok != nil { + _ = req.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ProgressToken: tok, Progress: float64(step), Message: msg}) + } + } +} + +// copyFirmware has Connect copy the file to the printer and waits until the +// printer reports where it is. It gives up when nothing changes for fwStall. +func (s *Server) copyFirmware(ctx context.Context, progress func(string), p printerSummary, rec map[string]any) (string, error) { + name := str(rec, "name") + if name == "" { + name = str(rec, "display_name") + } + dir, err := s.defaultStorage(ctx, p.UUID) + if err != nil { + return "", err + } + var team int64 + if n := num(rec, "team_id"); n != nil { + team = int64(*n) + } + hash := str(rec, "hash") + err = s.connect.JSON(ctx, connect.Request{ + Method: http.MethodPost, + Path: printerPath(p.UUID, "download-queue"), + JSON: map[string]any{"team_id": team, "hash": hash, "path": path.Join(dir, name)}, + }, nil) + if err != nil { + return "", err + } + + q := url.Values{"hash": {hash}, "team_id": {strconv.FormatInt(team, 10)}} + last, changed := "", time.Now() + copying := false // Connect reported bytes moving at some point + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(s.fwPoll): + } + cur, err := s.firmwareRecord(ctx, p.UUID, q) + if err != nil { + return "", err + } + tr, _ := cur["transferring"].(map[string]any) + // A path with a transfer still running would be a file the printer is + // still writing; FLASH on that is a truncated image for the bootloader + // to refuse. + if fp := str(cur, "path"); fp != "" && tr == nil { + return fp, nil + } + if planned, _ := cur["planned"].(map[string]any); planned != nil { + if why := str(planned, "abort_reason"); why != "" || planned["rejected"] == true { + return "", fmt.Errorf("Prusa Connect could not copy the firmware to the printer (%s)", orDefault(why, "rejected")) + } + } + state := fmt.Sprintf("%v/%v", cur["planned"] != nil, tr["transferred"]) + msg := "waiting for the printer to start the download" + switch { + case tr != nil: + copying = true + msg = fmt.Sprintf("copying to the printer: %.0f%%", derefZero(num(tr, "progress"))) + case copying: // the transfer is over, the printer hasn't reported the file yet + msg = "finishing the copy" + } + progress(msg) + if state != last { + last, changed = state, time.Now() + } else if time.Since(changed) > s.fwStall { + return "", fmt.Errorf("the printer made no progress on the download for %s; Connect may still have it queued (see get_transfers), and it is not installed", s.fwStall) + } + } +} + +func derefZero(f *float64) float64 { + if f == nil { + return 0 + } + return *f +} + +func orDefault(s, def string) string { + if s == "" { + return def + } + return s +} + +func (s *Server) addFirmwareTools() { + mcp.AddTool(s.mcp, &mcp.Tool{ + Name: "get_firmware_status", + Description: "The firmware the printer runs, the latest one Prusa Connect offers for it, and whether they differ " + + "(state: supported, outdated or unsupported). Read-only; Prusa Connect only.", + Annotations: readOnly("Firmware status"), + }, func(ctx context.Context, _ *mcp.CallToolRequest, in printerRef) (*mcp.CallToolResult, any, error) { + p, err := s.connectPrinter(ctx, in) + if err != nil { + return nil, nil, err + } + detail, err := s.printerDetail(ctx, p.UUID) + if err != nil { + return nil, nil, err + } + f := firmwareOf(detail) + out := map[string]any{"printer": p.Name, "current": f.Current, "latest": f.Latest, "state": f.State, "update_supported": f.CanUpdate} + switch f.verdict() { + case "outdated": + if f.Latest == "" { + // Outdated, but no version to update to: not "no update", so + // update_available is left out, the same way as below. + out["note"] = "Prusa Connect says the firmware is outdated but doesn't say which version is the latest" + explicitVersionHint(f.CanUpdate) + } else { + out["update_available"] = true + } + case "current": + out["update_available"] = false + default: + // Not "up to date": nobody has said so. update_available is left + // out rather than guessed, and update_firmware refuses to guess too. + out["note"] = fmt.Sprintf("Prusa Connect gives no verdict on this firmware (state %q), so whether an update is due is unknown", orDefault(f.State, "none")) + explicitVersionHint(f.CanUpdate) + } + return jsonResult(out) + }) + + mcp.AddTool(s.mcp, &mcp.Tool{ + Name: "update_firmware", + Description: "Install firmware through Prusa Connect: Connect copies the .bbf it hosts (the latest, or version) to the " + + "printer's USB drive, then sends the FLASH command, and the printer installs it and restarts. Waits for the copy " + + "(a few minutes at most). Without version it goes by Prusa Connect's own verdict: installs the latest when " + + "Connect says the firmware is outdated, does nothing when Connect says it is current, and refuses when there " + + "is no verdict, so a printer newer than Connect's latest is never downgraded by accident. Refuses before copying " + + "anything unless the printer is idle, ready, finished or stopped; a print that starts during the copy leaves " + + "the file on the USB drive, not installed, and the result says so. installed is true only when the printer " + + "confirmed FLASH. Run it only when the user asked for a firmware update: it restarts the printer, which may " + + "ask for a confirmation on its screen.", + Annotations: mutating("Update firmware", true), + }, func(ctx context.Context, req *mcp.CallToolRequest, in updateInput) (*mcp.CallToolResult, any, error) { + p, err := s.connectPrinter(ctx, in.printerRef) + if err != nil { + return nil, nil, err + } + detail, err := s.printerDetail(ctx, p.UUID) + if err != nil { + return nil, nil, err + } + f := firmwareOf(detail) + if !f.CanUpdate { + return nil, nil, fmt.Errorf("Prusa Connect doesn't offer firmware updates for %s", p.Name) + } + // Refuse before anything is copied, not only before FLASH: a .bbf + // written to the USB drive a print is reading from, followed by two + // minutes of polling and a success exit, is not a refusal. + // Through connectState, not stateOf: a record with no state is Prusa + // changing its API, to be reported as such, not a printer to refuse. + st, err := connectState(detail, p.UUID) + if err != nil { + return nil, nil, err + } + if !canStart(st) { + return nil, nil, &stateError{msg: fmt.Sprintf("%s is %s; firmware installs only while the printer is idle, ready, finished or stopped, so nothing was copied. Run this again when it is", p.Name, st)} + } + version := strings.TrimSpace(in.Version) + if version == "" { + switch f.verdict() { + case "unknown": + return nil, nil, fmt.Errorf("Prusa Connect gives no verdict on this printer's firmware (state %q), so whether an update is due is unknown; pass version", orDefault(f.State, "none")) + case "current": + return jsonResult(map[string]any{"printer": p.Name, "current": f.Current, "up_to_date": true, "note": "Prusa Connect says this firmware is current; nothing done."}) + } + if f.Latest == "" { + return nil, nil, errors.New("Prusa Connect doesn't say which firmware is the latest for this printer; pass version") + } + version = stripBuild(f.Latest) + } + rec, err := s.firmwareRecord(ctx, p.UUID, url.Values{"version": {version}}) + if connect.IsStatus(err, http.StatusNotFound) { + return nil, nil, fmt.Errorf("Prusa Connect has no firmware %s for %s", version, p.Name) + } + if err != nil { + return nil, nil, err + } + + progress := progressFunc(ctx, req) + out := map[string]any{"printer": p.Name, "current": f.Current, "version": version} + file := str(rec, "path") + out["copied"] = file == "" // false: it was on the printer already + if file == "" { + if file, err = s.copyFirmware(ctx, progress, p, rec); err != nil { + return nil, nil, err + } + } + out["file"] = file + + res, err := s.runCommand(ctx, p, "FLASH", map[string]any{"path": file}, false, 0, + func() { progress("installing (the printer restarts)") }) + var blocked *stateError + switch { + case errors.As(err, &blocked): + out["installed"] = false + out["note"] = fmt.Sprintf("Firmware %s is on the printer at %s but not installed: %s. Run this again when the printer is idle.", version, file, blocked) + case err != nil: + return nil, nil, fmt.Errorf("the firmware is on the printer at %s, but installing it failed: %w", file, err) + case res.State != "FINISHED": + // Connect answered the sync call, but the event that ended the wait + // isn't the printer accepting the command. Saying "installed" here + // would report a refusal as a success. + out["installed"] = false + out["command_state"] = res.State + if res.State == "REJECTED" { + out["note"] = fmt.Sprintf("Firmware %s is on the printer at %s, but the printer rejected FLASH and did not install it. Check its screen.", version, file) + } else { + // CREATED or EMIT is a command still on its way, and the + // printer may well be flashing; what is true is only that + // nothing confirmed it. + out["note"] = fmt.Sprintf("Firmware %s is on the printer at %s. FLASH was sent (%s) but the printer hasn't confirmed installing it: check its screen, and the firmware version once it is back.", version, file, orDefault(res.State, "no outcome reported")) + } + default: + out["installed"] = true + out["command_state"] = res.State + out["note"] = fmt.Sprintf("Firmware %s is on the printer at %s. FLASH accepted: the printer installs it and restarts. Check its screen.", version, file) + } + return jsonResult(out) + }) +} diff --git a/internal/server/tools_firmware_test.go b/internal/server/tools_firmware_test.go new file mode 100644 index 0000000..67be9c1 --- /dev/null +++ b/internal/server/tools_firmware_test.go @@ -0,0 +1,341 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// fwConnect is a stand-in Prusa Connect for a CORE One L, shaped like the +// answers the real one gave. The firmware file shows up on the printer +// (path) after pollsToCopy polls of its record, or never when that is 0 +// and onPrinter is false. +type fwConnect struct { + support string // support.state Connect reports; "outdated" when empty + noSupportState bool // Connect gives no verdict at all + flashEvent string // what the sync FLASH answers with; FINISHED when empty + printAfterCopy bool // a print starts the moment the file lands, for the blocked path + noState bool // the printer record carries no connect_state (an API change) + mu sync.Mutex + state string + current string + latest string + onPrinter bool + pollsToCopy int + abort string + + polls int + queued []map[string]any + commands []map[string]any +} + +const fwName = "COREONE_L_COEONE_L+_firmware_6.8.1.bbf" + +func (f *fwConnect) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + switch r.URL.Path { + case "/app/printers": + fmt.Fprint(w, `{"printers":[{"uuid":"u1","name":"Core One","team_id":1}]}`) + case "/app/printers/u1": + rec := map[string]any{ + "uuid": "u1", "name": "Core One", "connect_state": f.state, "firmware": f.current, + "allowed_functionalities": []string{"files", "fw_update", "fw_update_path"}, + "support": map[string]any{"current": f.current, "latest": f.latest, "stable": f.latest, "state": f.supportState()}, + } + if f.noState { + delete(rec, "connect_state") + } + _ = json.NewEncoder(w).Encode(rec) + case "/app/printers/u1/storages": + fmt.Fprint(w, `{"storages":[{"mountpoint":"/sd","type":"SD_CARD","read_only":true},{"mountpoint":"/usb","name":"usb","type":"USB","read_only":false}]}`) + case "/app/printers/u1/supported-commands": + _ = json.NewEncoder(w).Encode(map[string]any{"commands": []map[string]any{ + {"command": "FLASH", "executable_from_state": []string{"IDLE", "READY", "STOPPED", "FINISHED"}}, + }}) + case "/app/printers/u1/firmware": + q := r.URL.Query() + if v := q.Get("version"); v != "" && v != "6.8.1" { + http.Error(w, `{"code":"NOT_FOUND_FILE"}`, http.StatusNotFound) + return + } + rec := map[string]any{"type": "FIRMWARE", "name": fwName, "hash": "H4SH.", "team_id": 0, "meta": map[string]any{"version": "6.8.1"}} + if q.Get("hash") != "" { + f.polls++ + } + switch { + case f.onPrinter || (f.pollsToCopy > 0 && f.polls >= f.pollsToCopy): + rec["path"] = "/usb/COREON~3.BBF" + if f.printAfterCopy { + f.state = "PRINTING" + } + case f.pollsToCopy >= 4 && f.polls == f.pollsToCopy-1: // transfer over, file not reported yet + case f.abort != "": + rec["planned"] = map[string]any{"id": 7, "abort_reason": f.abort} + case f.polls > 0 && f.pollsToCopy > 0: + rec["transferring"] = map[string]any{"progress": 40.0, "transferred": f.polls} + case f.polls > 0: + rec["planned"] = map[string]any{"id": 7} + } + _ = json.NewEncoder(w).Encode(rec) + case "/app/printers/u1/download-queue": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.queued = append(f.queued, body) + w.WriteHeader(http.StatusCreated) + case "/app/printers/u1/commands/sync": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.commands = append(f.commands, body) + fmt.Fprintf(w, `{"event":{"event":%q}}`, orDefault(f.flashEvent, "FINISHED")) + default: + http.NotFound(w, r) + } +} + +func (f *fwConnect) supportState() string { + if f.noSupportState { + return "" + } + return orDefault(f.support, "outdated") +} + +func fwTools(t *testing.T, fc *fwConnect, stall time.Duration) *mcp.ClientSession { + t.Helper() + return connectConnectTools(t, fc, func(s *Server) { s.fwPoll, s.fwStall = time.Millisecond, stall }) +} + +func TestFirmwareStatus(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7+10473", latest: "6.8.1"} + text, isErr := call(t, fwTools(t, fc, time.Second), "get_firmware_status", nil) + if isErr { + t.Fatal(text) + } + var got struct { + Current, Latest, State string + Update bool `json:"update_available"` + Supported bool `json:"update_supported"` + } + if err := json.Unmarshal([]byte(text), &got); err != nil { + t.Fatal(err) + } + if got.Current != "6.5.7+10473" || got.Latest != "6.8.1" || got.State != "outdated" || !got.Update || !got.Supported { + t.Errorf("%s", text) + } + fc.current, fc.support = "6.8.1+16182", "supported" // Connect's verdict says it's current + if text, _ := call(t, fwTools(t, fc, time.Second), "get_firmware_status", nil); !strings.Contains(text, `"update_available":false`) { + t.Errorf("%s", text) + } +} + +func TestUpdateFirmwareWhenUpToDateDoesNothing(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.8.1+16182", latest: "6.8.1", support: "supported"} + text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", nil) + if isErr || !strings.Contains(text, `"up_to_date":true`) { + t.Fatalf("%q (error=%v)", text, isErr) + } + if len(fc.queued)+len(fc.commands) != 0 { + t.Errorf("sent something: %v %v", fc.queued, fc.commands) + } +} + +func TestUpdateFirmwareCopiesThenInstalls(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7+10473", latest: "6.8.1", pollsToCopy: 3} + text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", nil) + if isErr { + t.Fatal(text) + } + if len(fc.queued) != 1 || fc.queued[0]["hash"] != "H4SH." || fc.queued[0]["path"] != "/usb/"+fwName || fc.queued[0]["team_id"] != float64(0) { + t.Errorf("download-queue: %v", fc.queued) + } + if len(fc.commands) != 1 || fc.commands[0]["command"] != "FLASH" || + fc.commands[0]["kwargs"].(map[string]any)["path"] != "/usb/COREON~3.BBF" { + t.Errorf("commands: %v", fc.commands) + } + if !strings.Contains(text, `"installed":true`) || !strings.Contains(text, `"copied":true`) { + t.Errorf("%s", text) + } +} + +// A file already on the printer is not copied again, and --version installs +// even when the printer already runs it. +func TestUpdateFirmwareUsesTheFileAlreadyOnThePrinter(t *testing.T) { + fc := &fwConnect{state: "FINISHED", current: "6.8.1", latest: "6.8.1", onPrinter: true} + text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", map[string]any{"version": "6.8.1"}) + if isErr || !strings.Contains(text, `"copied":false`) { + t.Fatalf("%q (error=%v)", text, isErr) + } + if len(fc.queued) != 0 || len(fc.commands) != 1 { + t.Errorf("queued %v, commands %v", fc.queued, fc.commands) + } + if text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", map[string]any{"version": "6.4.0"}); !isErr || !strings.Contains(text, "no firmware 6.4.0") { + t.Errorf("an unknown version: %q", text) + } +} + +// FLASH is refused while printing: the file is copied and left there. +// A printer that is printing is refused before anything is copied, the way +// upload_file then=print refuses before a long upload: the alternative wrote a +// file to the drive the print was reading from and then reported success. +func TestUpdateFirmwareRefusesWhilePrinting(t *testing.T) { + fc := &fwConnect{state: "PRINTING", current: "6.5.7", latest: "6.8.1", pollsToCopy: 1} + text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", nil) + if !isErr || !strings.Contains(text, "PRINTING") || !strings.Contains(text, "nothing was copied") { + t.Fatalf("%q (error=%v)", text, isErr) + } + if len(fc.queued)+len(fc.commands) != 0 { + t.Errorf("queued %v, commands %v", fc.queued, fc.commands) + } +} + +// A print that starts while the file is on its way can't be helped; then the +// file stays on the drive and the result must not claim more than that. +func TestUpdateFirmwareLeavesTheFileStagedWhenAPrintStartsDuringTheCopy(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1", pollsToCopy: 1, printAfterCopy: true} + text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", nil) + if isErr { + t.Fatal(text) + } + if !strings.Contains(text, `"installed":false`) || !strings.Contains(text, "/usb/COREON~3.BBF but not installed") || !strings.Contains(text, "PRINTING") { + t.Errorf("%s", text) + } + if len(fc.queued) != 1 || len(fc.commands) != 0 { + t.Errorf("queued %v, commands %v", fc.queued, fc.commands) + } +} + +func TestUpdateFirmwareGivesUpWhenTheCopyStalls(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1"} + text, isErr := call(t, fwTools(t, fc, 30*time.Millisecond), "update_firmware", nil) + if !isErr || !strings.Contains(text, "no progress") || !strings.Contains(text, "not installed") { + t.Fatalf("%q (error=%v)", text, isErr) + } + if len(fc.commands) != 0 { + t.Errorf("installed anyway: %v", fc.commands) + } + + fc = &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1", abort: "READ_ONLY"} + if text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", nil); !isErr || !strings.Contains(text, "READ_ONLY") { + t.Errorf("an aborted copy: %q", text) + } +} + +// progressOf runs update_firmware and returns the progress messages it sent. +func progressOf(t *testing.T, fc *fwConnect) []string { + t.Helper() + var mu sync.Mutex + var msgs []string + opts := &mcp.ClientOptions{ProgressNotificationHandler: func(_ context.Context, r *mcp.ProgressNotificationClientRequest) { + mu.Lock() + msgs = append(msgs, r.Params.Message) + mu.Unlock() + }} + cs := connectConnectToolsWith(t, opts, fc, func(s *Server) { s.fwPoll, s.fwStall = time.Millisecond, time.Second }) + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "update_firmware", + Meta: mcp.Meta{"progressToken": "p"}}) + if err != nil || res.IsError { + t.Fatalf("%v %+v", err, res) + } + time.Sleep(50 * time.Millisecond) // notifications arrive asynchronously + mu.Lock() + defer mu.Unlock() + return append([]string(nil), msgs...) +} + +// Repeated polls say nothing new, and the gap after the transfer is the copy +// finishing, not the download starting. +func TestUpdateFirmwareProgressIsDeduplicatedAndAccurate(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1", pollsToCopy: 6} + got := strings.Join(progressOf(t, fc), "|") + want := "copying to the printer: 40%|finishing the copy|installing (the printer restarts)" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// Nothing says "installing" when FLASH is refused. +func TestUpdateFirmwareDoesNotClaimToInstallWhenBlocked(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1", pollsToCopy: 1, printAfterCopy: true} + for _, m := range progressOf(t, fc) { + if strings.Contains(m, "installing") { + t.Errorf("progress %q while FLASH was refused", m) + } + } +} + +// The record Connect returns for a command it waited for says CREATED; the +// event that ended the wait says what the printer did. A FLASH the printer +// turned down was reported as installed. +func TestUpdateFirmwareReportsARejectedFlash(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1", pollsToCopy: 1, flashEvent: "REJECTED"} + text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", nil) + if isErr { + t.Fatal(text) + } + if !strings.Contains(text, `"installed":false`) || !strings.Contains(text, "REJECTED") || strings.Contains(text, "restarts") { + t.Errorf("a rejected FLASH was reported as: %s", text) + } +} + +// "Up to date" is Connect's verdict, not a string comparison: a printer running +// something newer than Connect's latest used to be flashed back to it. +func TestUpdateFirmwareNeverDowngradesByAccident(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "7.0.0-RC1+17000", latest: "6.8.1", support: "supported"} + cs := fwTools(t, fc, time.Second) + text, isErr := call(t, cs, "update_firmware", nil) + if isErr || !strings.Contains(text, `"up_to_date":true`) { + t.Fatalf("%q (error=%v)", text, isErr) + } + if len(fc.queued)+len(fc.commands) != 0 { + t.Errorf("flashed an older build over a newer one: %v %v", fc.queued, fc.commands) + } + if text, _ := call(t, cs, "get_firmware_status", nil); !strings.Contains(text, `"update_available":false`) { + t.Errorf("status offered a downgrade: %s", text) + } + + // Without a verdict there is nothing safe to decide on. + fc = &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1", noSupportState: true} + if text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", nil); !isErr || !strings.Contains(text, "pass version") { + t.Errorf("no verdict, yet: %q (error=%v)", text, isErr) + } +} + +// A verdict Connect might use that prusactl doesn't know ("unsupported", or a +// new one) is not "up to date": nobody said so. Status leaves update_available +// out and says why; update refuses to decide. +func TestFirmwareUnknownVerdictDecidesNothing(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1", support: "unsupported", pollsToCopy: 1} + cs := fwTools(t, fc, time.Second) + text, isErr := call(t, cs, "update_firmware", nil) + if !isErr || !strings.Contains(text, "pass version") || !strings.Contains(text, "unsupported") { + t.Errorf("update: %q (error=%v)", text, isErr) + } + if len(fc.queued)+len(fc.commands) != 0 { + t.Errorf("acted on an unknown verdict: %v %v", fc.queued, fc.commands) + } + text, _ = call(t, cs, "get_firmware_status", nil) + if strings.Contains(text, "update_available") || !strings.Contains(text, "no verdict") { + t.Errorf("status decided for Connect: %s", text) + } + // An explicit version still installs. + if text, isErr := call(t, cs, "update_firmware", map[string]any{"version": "6.8.1"}); isErr || !strings.Contains(text, `"installed":true`) { + t.Errorf("explicit version: %q (error=%v)", text, isErr) + } +} + +// A printer record with no state is Prusa changing its API, which is reported +// as such; it is not a printer in some state that refuses to install. +func TestUpdateFirmwareReportsAMissingStateAsAnAPIChange(t *testing.T) { + fc := &fwConnect{state: "IDLE", current: "6.5.7", latest: "6.8.1", noState: true} + text, isErr := call(t, fwTools(t, fc, time.Second), "update_firmware", nil) + if !isErr || !strings.Contains(text, "connect_state") || strings.Contains(text, "nothing was copied") { + t.Errorf("%q (error=%v)", text, isErr) + } +}