Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<camera-ip>/live`; Buddy firmware's PrusaLink has no camera endpoint,
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions cmd/prusactl/firmware.go
Original file line number Diff line number Diff line change
@@ -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.")
}
}
22 changes: 22 additions & 0 deletions cmd/prusactl/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions cmd/prusactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions cmd/prusactl/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions cmd/prusactl/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
7 changes: 6 additions & 1 deletion internal/server/connect_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
9 changes: 7 additions & 2 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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()},
Expand All @@ -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
Expand Down
100 changes: 100 additions & 0 deletions internal/server/storage_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading