From 4ec5086b1bf23ff1d24b9f855767ad843405b791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0tetiar?= Date: Thu, 8 Oct 2026 07:36:32 +0200 Subject: [PATCH 1/3] refactor: prefer a USB drive for the default storage, and type the refused-state error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The next commit needs two small changes. They are separate, so that the feature commit is easy to read. `defaultStorage` returned the first writable storage of the printer. It now skips a corrupted storage and prefers a USB drive over other storages, as the Connect web app does. A USB drive is the only place where a printer keeps a file that it must install. The refusal in `runCommand` for the current printer state is now a `stateError`. Before, it was an anonymous `fmt.Errorf`. A caller can now tell "the printer is in the wrong state for this" from any other failure. The message is unchanged. `TestDefaultStorage` covers the storage choice. `TestRunCommandStateError` covers the error type. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Petr Štetiar --- internal/server/storage_test.go | 100 +++++++++++++++++++++++++++++++ internal/server/tools_control.go | 9 ++- internal/server/tools_files.go | 18 ++++-- 3 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 internal/server/storage_test.go 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..9f93396 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"` @@ -146,8 +151,8 @@ 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, ", "))} } } 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 } From 70d1922b7ce34d583e482f4ae8639c087f64d1ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0tetiar?= Date: Thu, 8 Oct 2026 07:36:33 +0200 Subject: [PATCH 2/3] feat: check and install firmware updates through Prusa Connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, you could not ask the printer which firmware it runs, or update it, without a USB stick and a browser. `prusactl firmware` (`get_firmware_status`) shows the running firmware and the latest firmware that Prusa Connect offers for the model of the printer. It also shows whether they differ. `prusactl firmware update` (`update_firmware`) does these steps: 1. Connect copies its hosted `.bbf` file to the USB drive of the printer through a `download-queue` entry. 2. prusactl waits for the copy. 3. It installs the file with the `FLASH` command. This restarts the printer. The command does nothing when the printer already runs the latest version and no `--version` is given. The printer accepts `FLASH` only when it is idle, ready, finished or stopped. During a print, the file stays on the drive and the result says so. You can run `update` again after the print. The result says "installing" only after prusactl has sent `FLASH`. A tool that waits can now report progress. The CLI prints these lines on stderr as they arrive. `runCommand` takes an `onSend` callback. It runs once a command has passed every check. All of this goes through Prusa Connect only, so it needs `prusactl login`. Tested on a CORE One L: `prusactl firmware update` updated the printer from 6.8.1 to 7.0.0 in one command. The copy to the USB drive took less than 1 minute. The printer installed the file, restarted and came back idle on 7.0.0+16903 about 4 minutes after `FLASH`. Connect then reported the firmware as up to date. Nobody had to touch the printer during the update. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Petr Štetiar --- CHANGELOG.md | 6 + README.md | 21 ++ cmd/prusactl/firmware.go | 55 ++++++ cmd/prusactl/help.go | 19 ++ cmd/prusactl/main.go | 1 + cmd/prusactl/tools.go | 11 +- cmd/prusactl/tools_test.go | 14 ++ internal/server/connect_tools_test.go | 7 +- internal/server/server.go | 9 +- internal/server/tools_control.go | 5 +- internal/server/tools_firmware.go | 256 +++++++++++++++++++++++++ internal/server/tools_firmware_test.go | 236 +++++++++++++++++++++++ 12 files changed, 634 insertions(+), 6 deletions(-) create mode 100644 cmd/prusactl/firmware.go create mode 100644 internal/server/tools_firmware.go create mode 100644 internal/server/tools_firmware_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dcec82a..5fdf909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 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 the printer is + printing `FLASH` is refused, so the file is left on the drive and the result + says so. Nothing happens when the printer already runs the latest. - **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..f39feb0 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,24 @@ 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 and does nothing if the +printer already runs the latest. + +```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. +During a print the file is copied and left on the drive, not installed; run +`update` again afterwards. 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..5f9a14e --- /dev/null +++ b/cmd/prusactl/firmware.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" +) + +// 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") == "": + fmt.Fprintln(w, "\nPrusa Connect doesn't say which firmware is the latest for this printer.") + 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..20f5fa7 100644 --- a/cmd/prusactl/help.go +++ b/cmd/prusactl/help.go @@ -279,6 +279,25 @@ 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) and does nothing if the printer already +runs the latest and you give no --version. FLASH is accepted only while the +printer is idle, ready, finished or stopped: during a print the file is left +on the drive, not installed, and you run update again afterwards. 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..af96b13 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 } diff --git a/cmd/prusactl/tools_test.go b/cmd/prusactl/tools_test.go index 23b979f..06e9aad 100644 --- a/cmd/prusactl/tools_test.go +++ b/cmd/prusactl/tools_test.go @@ -202,3 +202,17 @@ 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()) + } + } +} 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/tools_control.go b/internal/server/tools_control.go index 9f93396..c7d772d 100644 --- a/internal/server/tools_control.go +++ b/internal/server/tools_control.go @@ -112,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{} @@ -156,6 +156,9 @@ func (s *Server) runCommand(ctx context.Context, p printerSummary, command strin } } + 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_firmware.go b/internal/server/tools_firmware.go new file mode 100644 index 0000000..061c50f --- /dev/null +++ b/internal/server/tools_firmware.go @@ -0,0 +1,256 @@ +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 +} + +func (f firmwareFacts) upToDate() bool { + return f.Latest != "" && stripBuild(f.Current) == stripBuild(f.Latest) +} + +// 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 + } + if fp := str(cur, "path"); fp != "" { + 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")) + } + } + tr, _ := cur["transferring"].(map[string]any) + 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_available": f.Latest != "" && !f.upToDate(), "update_supported": 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). Does nothing when the printer is already on the latest and no version is given. FLASH " + + "runs only while the printer is idle, ready, finished or stopped: otherwise the file is left on the USB drive, " + + "not installed, and the result says so; run this again when the print has ended. A printer that is installing " + + "firmware 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) + } + version := strings.TrimSpace(in.Version) + if version == "" { + if f.Latest == "" { + return nil, nil, errors.New("Prusa Connect doesn't say which firmware is the latest for this printer; pass version") + } + if f.upToDate() { + return jsonResult(map[string]any{"printer": p.Name, "current": f.Current, "up_to_date": true, "note": "Already on the latest firmware; nothing done."}) + } + 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) + default: + out["installed"] = true + out["command_state"] = res.State + out["note"] = fmt.Sprintf("Firmware %s is on the printer at %s. FLASH sent: 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..79044dc --- /dev/null +++ b/internal/server/tools_firmware_test.go @@ -0,0 +1,236 @@ +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 { + 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": + _ = json.NewEncoder(w).Encode(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": "outdated"}, + }) + 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" + 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.Fprint(w, `{"event":{"event":"FINISHED"}}`) + default: + http.NotFound(w, r) + } +} + +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 = "6.8.1+16182" // the build suffix doesn't make it differ + 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"} + 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. +func TestUpdateFirmwareWhilePrintingLeavesTheFileStaged(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 { + 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: "PRINTING", current: "6.5.7", latest: "6.8.1", pollsToCopy: 1} + for _, m := range progressOf(t, fc) { + if strings.Contains(m, "installing") { + t.Errorf("progress %q while FLASH was refused", m) + } + } +} From 8201b3fb3c4586261ce4273ba24719c19600ea36 Mon Sep 17 00:00:00 2001 From: Trevin Lee <134039857+trevin-lee@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:54:51 -0700 Subject: [PATCH 3/3] fix: refuse a firmware update up front, and never call a refused flash installed Three things in update_firmware said more than they knew. "installed" was true whatever the printer answered: a FLASH the printer REJECTED came back as installed, with a note saying it restarts. The event that ended the wait decides it now: FINISHED is installed, REJECTED is the printer refusing, and anything else (CREATED, EMIT, no event) is reported as not confirmed rather than as either. "Up to date" was string equality, so a printer running something newer than Connect's latest (a release candidate) was flashed back to the older build with no --version given. Connect's own verdict on the firmware decides now, the one its update button goes by: "outdated" installs, "supported" does nothing, and any other state, or none, is a refusal to decide, in both tools; --version still installs a specific one where Connect offers updates at all. The state check sat before FLASH only, so a call during a print wrote the .bbf to the drive the print was reading from, polled for two minutes and exited 0. It refuses before copying anything, the way upload_file then=print does, and a record with no state at all is reported as Prusa changing its API rather than as a printer to refuse; a print that starts during the copy still leaves the file staged, and the result says so. Also: a path reported while a transfer is still running is not a file to flash, and "pass version" reaches the terminal as --version. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 7 +- README.md | 10 +- cmd/prusactl/firmware.go | 5 + cmd/prusactl/help.go | 13 ++- cmd/prusactl/tools.go | 1 + cmd/prusactl/tools_test.go | 13 +++ internal/server/tools_firmware.go | 101 +++++++++++++++--- internal/server/tools_firmware_test.go | 135 ++++++++++++++++++++++--- 8 files changed, 244 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fdf909..d30c47f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,10 @@ - `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 the printer is - printing `FLASH` is refused, so the file is left on the drive and the result - says so. Nothing happens when the printer already runs the latest. + 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 f39feb0..5b27f58 100644 --- a/README.md +++ b/README.md @@ -330,8 +330,8 @@ to an agent. `--raw` shows them. `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 and does nothing if the -printer already runs the latest. +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 @@ -339,8 +339,10 @@ prusactl firmware update [--version X] # copy, then install ``` `FLASH` is accepted only while the printer is idle, ready, finished or stopped. -During a print the file is copied and left on the drive, not installed; run -`update` again afterwards. The printer may ask for a confirmation on its screen. +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 diff --git a/cmd/prusactl/firmware.go b/cmd/prusactl/firmware.go index 5f9a14e..5d6aebe 100644 --- a/cmd/prusactl/firmware.go +++ b/cmd/prusactl/firmware.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strings" ) // firmwareCmd is the terminal side of get_firmware_status and update_firmware. @@ -44,7 +45,11 @@ func renderFirmwareStatus(w io.Writer, m map[string]any) { 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": diff --git a/cmd/prusactl/help.go b/cmd/prusactl/help.go index 20f5fa7..a3ae59c 100644 --- a/cmd/prusactl/help.go +++ b/cmd/prusactl/help.go @@ -288,11 +288,14 @@ 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) and does nothing if the printer already -runs the latest and you give no --version. FLASH is accepted only while the -printer is idle, ready, finished or stopped: during a print the file is left -on the drive, not installed, and you run update again afterwards. The printer -may ask you to confirm on its screen. Both go through Prusa Connect only.`, +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"}, }), diff --git a/cmd/prusactl/tools.go b/cmd/prusactl/tools.go index af96b13..f0fe00f 100644 --- a/cmd/prusactl/tools.go +++ b/cmd/prusactl/tools.go @@ -139,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 06e9aad..85d89e0 100644 --- a/cmd/prusactl/tools_test.go +++ b/cmd/prusactl/tools_test.go @@ -216,3 +216,16 @@ func TestRenderFirmwareStatus(t *testing.T) { } } } + +// 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/tools_firmware.go b/internal/server/tools_firmware.go index 061c50f..36dabbf 100644 --- a/internal/server/tools_firmware.go +++ b/internal/server/tools_firmware.go @@ -52,8 +52,31 @@ func firmwareOf(detail map[string]any) firmwareFacts { return f } -func (f firmwareFacts) upToDate() bool { - return f.Latest != "" && stripBuild(f.Current) == stripBuild(f.Latest) +// 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 @@ -124,7 +147,11 @@ func (s *Server) copyFirmware(ctx context.Context, progress func(string), p prin if err != nil { return "", err } - if fp := str(cur, "path"); fp != "" { + 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 { @@ -132,7 +159,6 @@ func (s *Server) copyFirmware(ctx context.Context, progress func(string), p prin return "", fmt.Errorf("Prusa Connect could not copy the firmware to the printer (%s)", orDefault(why, "rejected")) } } - tr, _ := cur["transferring"].(map[string]any) state := fmt.Sprintf("%v/%v", cur["planned"] != nil, tr["transferred"]) msg := "waiting for the printer to start the download" switch { @@ -181,8 +207,23 @@ func (s *Server) addFirmwareTools() { return nil, nil, err } f := firmwareOf(detail) - out := map[string]any{"printer": p.Name, "current": f.Current, "latest": f.Latest, "state": f.State, - "update_available": f.Latest != "" && !f.upToDate(), "update_supported": f.CanUpdate} + 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) }) @@ -190,10 +231,13 @@ func (s *Server) addFirmwareTools() { 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). Does nothing when the printer is already on the latest and no version is given. FLASH " + - "runs only while the printer is idle, ready, finished or stopped: otherwise the file is left on the USB drive, " + - "not installed, and the result says so; run this again when the print has ended. A printer that is installing " + - "firmware may ask for a confirmation on its screen.", + "(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) @@ -208,14 +252,29 @@ func (s *Server) addFirmwareTools() { 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") } - if f.upToDate() { - return jsonResult(map[string]any{"printer": p.Name, "current": f.Current, "up_to_date": true, "note": "Already on the latest firmware; nothing done."}) - } version = stripBuild(f.Latest) } rec, err := s.firmwareRecord(ctx, p.UUID, url.Values{"version": {version}}) @@ -246,10 +305,24 @@ func (s *Server) addFirmwareTools() { 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 sent: the printer installs it and restarts. Check its screen.", version, file) + 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 index 79044dc..67be9c1 100644 --- a/internal/server/tools_firmware_test.go +++ b/internal/server/tools_firmware_test.go @@ -18,13 +18,18 @@ import ( // (path) after pollsToCopy polls of its record, or never when that is 0 // and onPrinter is false. type fwConnect struct { - mu sync.Mutex - state string - current string - latest string - onPrinter bool - pollsToCopy int - abort string + 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 @@ -40,11 +45,15 @@ func (f *fwConnect) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "/app/printers": fmt.Fprint(w, `{"printers":[{"uuid":"u1","name":"Core One","team_id":1}]}`) case "/app/printers/u1": - _ = json.NewEncoder(w).Encode(map[string]any{ + 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": "outdated"}, - }) + "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": @@ -64,6 +73,9 @@ func (f *fwConnect) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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} @@ -82,12 +94,19 @@ func (f *fwConnect) ServeHTTP(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.commands = append(f.commands, body) - fmt.Fprint(w, `{"event":{"event":"FINISHED"}}`) + 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 }) @@ -110,14 +129,14 @@ func TestFirmwareStatus(t *testing.T) { 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 = "6.8.1+16182" // the build suffix doesn't make it differ + 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"} + 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) @@ -162,9 +181,25 @@ func TestUpdateFirmwareUsesTheFileAlreadyOnThePrinter(t *testing.T) { } // FLASH is refused while printing: the file is copied and left there. -func TestUpdateFirmwareWhilePrintingLeavesTheFileStaged(t *testing.T) { +// 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) } @@ -227,10 +262,80 @@ func TestUpdateFirmwareProgressIsDeduplicatedAndAccurate(t *testing.T) { // Nothing says "installing" when FLASH is refused. func TestUpdateFirmwareDoesNotClaimToInstallWhenBlocked(t *testing.T) { - fc := &fwConnect{state: "PRINTING", current: "6.5.7", latest: "6.8.1", pollsToCopy: 1} + 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) + } +}