diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd524c..cdaa94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +- **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, + which is why `get_camera_snapshot` and `prusactl camera` used to need + Connect. `prusactl setup --camera rtsp://...` (or `PRUSACTL_CAMERA_URL`) saves + the address and prusactl grabs a frame with ffmpeg. A PrusaLink that serves + `/api/v1/cameras/snap` is used first, and Connect is still the fallback. + `prusactl status` shows the saved camera. Only plain `rtsp://` addresses are +## Unreleased + + accepted (ffmpeg doesn't verify the certificate of `rtsps://`), and one with a + user or password is refused: ffmpeg would get it on its command line, readable + by any local user, and it would be stored in plain text. The Buddy3D takes none. + accepted (ffmpeg doesn't verify the certificate of `rtsps://`), and a password + in the address is masked wherever prusactl shows it. - `prusactl login --manual` signs in without a password passing through the terminal, so an AI agent can run it. It prints the Prusa Account link on its own line, then reads the address the browser ends on diff --git a/README.md b/README.md index 7319031..1ca0c19 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ `prusactl mcp` gives an agent such as Claude hands on your printer. It can: -- **Watch:** check state, temperatures, job progress, and (with Connect) the camera. +- **Watch:** check state, temperatures, job progress, and the camera (over RTSP with + `setup --camera`, or through Connect). - **Print:** upload files, start them, pause, resume, and stop, and (with Connect) queue them. - **Control:** heat, home, move, load and unload filament, and level the bed: @@ -41,8 +42,9 @@ It is one Go binary; no browser is needed. Setup is two terminal prompts. > > What did I print this week, and how many failed? -The camera, cancelling one object, reading a dialog, and print history go -through Prusa Connect, so those need `prusactl login`; the rest works directly. +Cancelling one object, reading a dialog, and print history go through Prusa +Connect, so those need `prusactl login`; the rest works directly. So does the +camera, once you tell prusactl where it is (see [The camera](#the-camera)). ## Install @@ -103,6 +105,7 @@ Linux, `%AppData%` on Windows); delete it too if you like. prusactl setup 192.168.1.50 # the printer's address; asks for its PrusaLink password prusactl status +prusactl setup --camera rtsp://192.168.1.60/live # optional: the camera, no Connect needed prusactl login # optional: Prusa Connect, for remote access, camera, dialogs ``` @@ -166,13 +169,44 @@ prusactl reaches the printer two ways, and each tool picks one: | Status, files, upload, print, pause/resume/stop | ✅ | ✅ | | Heat, move, filament, leveling | ✅ via `run_gcode` (printer idle) | ✅ via firmware commands | | Any G-code | ✅ | ❌ | -| Camera, on-screen dialogs, queue, history, events | ❌ | ✅ | +| Camera | ✅ via RTSP and ffmpeg, once its address is saved | ✅ | +| On-screen dialogs, queue, history, events | ❌ | ✅ | The direct route is used whenever the printer answers. Otherwise, or for Connect-only features, the tool goes through Connect. Results from tools that can take either route say which one they used (`via`); the rest always use the route their feature needs. +### The camera + +The Buddy3D camera is not part of the printer. It is a separate Wi-Fi device that +talks to Prusa Connect on its own and serves a video stream at +`rtsp:///live`; the printer's PrusaLink has no camera endpoint (Buddy +firmware serves none), so a direct connection alone can't show it. Tell prusactl +where the camera is and it grabs one frame at a time with +[ffmpeg](https://ffmpeg.org), which must be on your `PATH`: + +```sh +prusactl setup --camera rtsp://192.168.1.60/live # find the camera's IP in your router +prusactl camera snapshot.jpg +``` + +A snapshot comes from the first source that works: the printer's own +`/api/v1/cameras/snap`, if its PrusaLink has one (other PrusaLink builds do); +the saved RTSP address; then Prusa Connect. `via: "direct"` limits it to the +first two, `via: "connect"` to Connect. `PRUSACTL_CAMERA_URL` overrides the saved +address. An address with a user or password is refused: ffmpeg would get the +password on its command line, where any local user can read it with `ps`, and it +would sit in plain text in the config. The Buddy3D camera takes none. + +Only plain `rtsp://` addresses are accepted. `rtsps://` is refused on purpose: +FFmpeg 7.1.5 completes the TLS handshake with a self-signed certificate and sends +the camera's credentials even with `-tls_verify 1` and `-ca_file`, because its RTSP +code never hands those options to the TLS layer. The Buddy3D camera serves plain +RTSP, so this costs nothing there. Residual risk: plain RTSP sends the video unencrypted, so anyone on your network +can watch it; keep the camera off untrusted +networks. + ### Signing in to Prusa Connect `prusactl login` asks for your Prusa Account email and password, plus a 2FA @@ -219,7 +253,7 @@ The printer's PrusaLink password is kept in the same keychain. | `control_print` | both | Pause, resume, or stop | | `get_transfers` | both | File transfers in progress | | `run_gcode` | direct | Run G-code, such as heating, homing, moving, or filament changes, while the printer is idle | -| `get_camera_snapshot` | Connect | Latest camera image, with how old it is | +| `get_camera_snapshot` | both | A camera image: from the camera's RTSP stream if its address is saved, else Connect's latest, with how old it is | | `respond_to_dialog` | Connect | Press a button on the printer's screen | | `list_supported_commands`, `send_command`, `get_command` | Connect | Every firmware command Connect exposes, with arguments and allowed states | | `get_queue`, `add_to_queue`, `remove_from_queue` | Connect | The print queue | @@ -254,7 +288,7 @@ prusactl jobs [JOB-ID] print history, or one job prusactl events the printer's recent events prusactl telemetry recorded temperatures and speeds prusactl transfers file transfers in progress -prusactl camera [FILE] save a snapshot from the printer's camera +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 api [METHOD] PATH [JSON] /api/... to the printer, /app/... to Prusa Connect @@ -279,9 +313,10 @@ flags, and flags work before or after the arguments. Tab completion covers commands, flags, and their values: Homebrew installs it for bash, zsh, and fish, and `prusactl help completion` shows how to add it otherwise. -`prusactl setup --forget` removes the saved printer. `--api-key` uses a PrusaLink -API key instead of the password, and `--password-stdin` reads the secret from a -pipe. +`prusactl setup --forget` removes the saved printer and camera. `--api-key` uses +a PrusaLink API key instead of the password, `--password-stdin` reads the secret +from a pipe, and `--camera URL` saves the camera's RTSP address (on its own it +leaves the printer alone; `--no-camera` forgets just the camera). `prusactl api` masks API keys and tokens in responses (Connect's printer record carries the PrusaLink and Connect keys), so its output is safe to paste or hand @@ -317,6 +352,7 @@ to an agent. `--raw` shows them. | `PRUSACTL_HOST`, `PRUSACTL_USER`, `PRUSACTL_AUTH` | Override the saved printer address, username, or `digest`/`api-key` | | `PRUSACTL_PASSWORD`, `PRUSACTL_API_KEY` | Supply the printer secret instead of the keychain | | `PRUSACTL_KEYRING=file` | Keep credentials in `secrets.json` (mode 0600) instead of the OS keychain | +| `PRUSACTL_CAMERA_URL` | Override the saved camera's RTSP address, e.g. `rtsp://192.168.1.60/live` | | `PRUSACTL_CONFIG` | Alternate config file (default: `prusactl/config.json` in the OS config dir) | | `PRUSA_CONNECT_URL`, `PRUSA_ACCOUNT_URL` | Connect and Prusa Account origins | | `PRUSA_CLIENT_ID`, `PRUSA_REDIRECT_URI` | The OAuth client (default: the Connect web app's) | diff --git a/cmd/prusactl/help.go b/cmd/prusactl/help.go index ee20809..9bc2d9f 100644 --- a/cmd/prusactl/help.go +++ b/cmd/prusactl/help.go @@ -50,12 +50,19 @@ var commands = []*command{ password (or API key) in your keychain, or in a private file on machines without one. ADDRESS is its IP address or hostname, e.g. 192.168.1.50; you're asked for it if you leave it out. The password is on the printer under -Settings > Network > PrusaLink.`, +Settings > Network > PrusaLink. + +--camera saves the address of the printer's camera (rtsp:///live), so +the camera works without Prusa Connect. Frames are grabbed with ffmpeg, which +must be installed. Only plain rtsp:// is accepted. On its own, without an +ADDRESS, --camera only saves the camera and leaves the printer setup alone.`, Flags: []flagSpec{ {Name: "user", Value: "NAME", Default: "maker", Usage: "PrusaLink username"}, {Name: "api-key", Usage: "use a PrusaLink API key instead of the password"}, {Name: "password-stdin", Usage: "read the password or API key from stdin"}, - {Name: "forget", Usage: "remove the saved printer and its password"}, + {Name: "camera", Value: "URL", Usage: "save the camera's RTSP address, e.g. rtsp://192.168.1.60/live"}, + {Name: "no-camera", Usage: "forget the saved camera address and nothing else"}, + {Name: "forget", Usage: "remove the saved printer, its password, and the saved camera"}, }, Pos: []posArg{{Label: "printer address", Kind: "hosts"}}, }, @@ -263,7 +270,12 @@ used; with several, name one with --printer, or give --team directly.`, Args: "[FILE]", Summary: "Save a snapshot from the printer's camera", Help: `Writes snapshot.jpg unless you name a file, or pipe it somewhere. -Needs Prusa Connect and a camera.`, + +The picture comes from the first of these that works: the printer's own +camera API (Buddy firmware has none), the camera's RTSP stream if you saved +its address with ` + "`prusactl setup --camera`" + ` (or PRUSACTL_CAMERA_URL; needs ffmpeg), +or Prusa Connect. --via direct allows only the first two, --via connect only +the last.`, Flags: flags(printerFlags, []flagSpec{{Name: "camera", Value: "ID", Usage: "which camera, when there are several"}}), Pos: []posArg{{Label: "file", Kind: "files"}}, }, diff --git a/cmd/prusactl/main.go b/cmd/prusactl/main.go index b8c5f5c..6e64a6a 100644 --- a/cmd/prusactl/main.go +++ b/cmd/prusactl/main.go @@ -13,6 +13,7 @@ import ( "flag" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -26,6 +27,7 @@ import ( "golang.org/x/term" "github.com/trevin-lee/prusactl/internal/auth" + "github.com/trevin-lee/prusactl/internal/camera" "github.com/trevin-lee/prusactl/internal/compat" "github.com/trevin-lee/prusactl/internal/connect" "github.com/trevin-lee/prusactl/internal/link" @@ -211,23 +213,70 @@ func setup(ctx context.Context, args []string) error { return errors.New("setup: usage: prusactl setup [flags] [ADDRESS] (see `prusactl help setup`)") } user, apiKey, fromStdin := opts.str("user"), opts.on("api-key"), opts.on("password-stdin") + cameraURL := opts.str("camera") + if cameraURL != "" { + var err error + if cameraURL, err = camera.Validate(cameraURL); err != nil { + return err + } + if opts.on("forget") { + return errors.New("setup: --forget removes the camera too, so --camera with it would be lost; run them separately") + } + if len(pos) == 0 && (opts.given("user") || opts.given("api-key") || opts.given("password-stdin")) { + return errors.New("setup: --camera on its own saves only the camera; --user, --api-key and --password-stdin go with a printer ADDRESS") + } + } + if opts.on("no-camera") { + if cameraURL != "" || opts.on("forget") || len(pos) > 0 { + return errors.New("setup: --no-camera stands alone; it forgets the camera and changes nothing else") + } + if err := link.SaveCamera(""); err != nil { + return err + } + fmt.Println("Forgot the saved camera address.") + return nil + } + if cameraURL != "" && len(pos) == 0 && !opts.on("forget") { + // Only the camera: leave the printer, and its password, alone. + if err := link.SaveCamera(cameraURL); err != nil { + return err + } + reportCamera(cameraURL) + return nil + } if opts.on("forget") { cfg, err := link.SavedConfig() // what setup saved, not PRUSACTL_* overrides if errors.Is(err, link.ErrNotConfigured) { - fmt.Println("No printer is set up.") + // A camera saved on its own, with its credentials, goes too. + saved, cerr := link.LoadCamera() + if cerr != nil { + return cerr + } + if err := link.SaveCamera(""); err != nil { + return err + } + if saved != "" && os.Getenv("PRUSACTL_CAMERA_URL") == "" { + fmt.Println("No printer is set up; forgot the saved camera address.") + } else { + fmt.Println("No printer is set up.") + } return nil } if err != nil { return err } + savedCamera, _ := link.LoadCamera() if err := link.RemoveConfig(cfg); err != nil { return err } - kind := "password" + what := "its saved password" if cfg.Auth == link.AuthAPIKey { - kind = "API key" + what = "its saved API key" } - fmt.Printf("Forgot %s and its saved %s.\n", cfg.Host, kind) + if savedCamera != "" && os.Getenv("PRUSACTL_CAMERA_URL") == "" { + what += ", and the camera address" + } + fmt.Printf("Forgot %s and %s.\n", cfg.Host, what) return nil } @@ -299,9 +348,36 @@ func setup(ctx context.Context, args []string) error { if prevErr == nil && prev.Host != cfg.Host { fmt.Printf("This replaces %s: prusactl reaches one printer directly (others work through Prusa Connect).\n", prev.Host) } + if cameraURL != "" { + if err := link.SaveCamera(cameraURL); err != nil { + return err + } + reportCamera(cameraURL) + } return nil } +// reportCamera confirms a saved camera address, and warns about what would +// stop it working: ffmpeg missing, or nothing listening at the address. +func reportCamera(addr string) { + fmt.Printf("Saved the camera at %s.\n", camera.Mask(addr)) + if _, err := exec.LookPath("ffmpeg"); err != nil { + fmt.Fprintln(os.Stderr, "warning: ffmpeg isn't on PATH; it grabs the pictures, so install it before using the camera") + } + if u, err := url.Parse(addr); err == nil { + port := u.Port() + if port == "" { + port = "554" + } + c, err := net.DialTimeout("tcp", net.JoinHostPort(u.Hostname(), port), 3*time.Second) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: nothing answered at %s:%s: %v\n", u.Hostname(), port, err) + return + } + c.Close() + } +} + // terminalPrompter asks for Prusa Account details on the terminal. host is // where the password will be sent, which PRUSA_ACCOUNT_URL can change. type terminalPrompter struct{ host string } @@ -412,6 +488,14 @@ func status(ctx context.Context, srv *server.Server, lc *link.Client) error { fmt.Printf("Prusa Connect: not signed in (%v)\n", orText(cloud["error"], cloud["setup"])) } + if cam, _ := st["camera"].(map[string]any); cam["configured"] == true { + if e, ok := cam["error"]; ok { + fmt.Printf("Camera (RTSP): not usable: %v\n", e) + } else { + fmt.Printf("Camera (RTSP): %v\n", cam["rtsp"]) + } + } + // Without the direct route, show the printers as Prusa Connect sees them. if direct["reachable"] != true && cloud["signed_in"] == true { printers, err := srv.ConnectPrinters(ctx) diff --git a/cmd/prusactl/setup_test.go b/cmd/prusactl/setup_test.go index c296444..63058a3 100644 --- a/cmd/prusactl/setup_test.go +++ b/cmd/prusactl/setup_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/trevin-lee/prusactl/internal/appdir/appdirtest" + "github.com/trevin-lee/prusactl/internal/link" ) // standIn answers the PrusaLink info endpoint like a printer would. @@ -85,3 +86,75 @@ func contains(s, sub string) bool { } return false } + +// The camera is saved beside the printer, not instead of it: re-running setup +// keeps the camera, --camera alone keeps the printer, and PRUSACTL_CAMERA_URL +// wins over what was saved. +func TestSetupSavesTheCameraBesideThePrinter(t *testing.T) { + home := appdirtest.Use(t) + t.Setenv("PRUSACTL_KEYRING", "file") + t.Setenv("PRUSACTL_CONFIG", filepath.Join(home, "config.json")) + srv := standIn(t) + + // Only the camera: no printer address, no password asked. + if err := setup(context.Background(), []string{"--camera", "rtsp://127.0.0.1:1/live"}); err != nil { + t.Fatal(err) + } + if got, _ := link.LoadCamera(); got != "rtsp://127.0.0.1:1/live" { + t.Fatalf("saved camera %q", got) + } + if _, err := link.SavedConfig(); err == nil { + t.Fatal("saving only the camera set up a printer") + } + + runSetup(t, "pw", srv.URL) + if got, _ := link.LoadCamera(); got != "rtsp://127.0.0.1:1/live" { + t.Errorf("setup dropped the camera: %q", got) + } + if cfg, err := link.SavedConfig(); err != nil || cfg.Host != srv.URL { + t.Errorf("printer %v, %v", cfg, err) + } + + t.Setenv("PRUSACTL_CAMERA_URL", "rtsp://127.0.0.1:2/live") + if got, _ := link.LoadCamera(); got != "rtsp://127.0.0.1:2/live" { + t.Errorf("the environment should win: %q", got) + } + t.Setenv("PRUSACTL_CAMERA_URL", "") + + if err := setup(context.Background(), []string{"--no-camera"}); err != nil { + t.Fatal(err) + } + if got, _ := link.LoadCamera(); got != "" { + t.Errorf("--no-camera left %q", got) + } + if _, err := link.SavedConfig(); err != nil { + t.Errorf("--no-camera forgot the printer: %v", err) + } + + for _, bad := range [][]string{{"--camera", "http://x/live"}, {"--camera", "x", "--no-camera"}, {"--no-camera", srv.URL}} { + if err := setup(context.Background(), bad); err == nil { + t.Errorf("setup %v should be refused", bad) + } + } +} + +// --forget with no printer set up still removes a camera saved on its own. +func TestSetupForgetRemovesACameraSavedAlone(t *testing.T) { + home := appdirtest.Use(t) + t.Setenv("PRUSACTL_KEYRING", "file") + cfgPath := filepath.Join(home, "config.json") + t.Setenv("PRUSACTL_CONFIG", cfgPath) + + if err := setup(context.Background(), []string{"--camera", "rtsp://127.0.0.1:1/live"}); err != nil { + t.Fatal(err) + } + if err := setup(context.Background(), []string{"--forget"}); err != nil { + t.Fatal(err) + } + if got, _ := link.LoadCamera(); got != "" { + t.Errorf("--forget left the camera %q", got) + } + if _, err := os.Stat(cfgPath); !os.IsNotExist(err) { + t.Errorf("config file should be gone: %v", err) + } +} diff --git a/internal/camera/camera.go b/internal/camera/camera.go new file mode 100644 index 0000000..e0badcf --- /dev/null +++ b/internal/camera/camera.go @@ -0,0 +1,177 @@ +// Package camera grabs one picture from a camera that serves RTSP, such as the +// Prusa Buddy3D camera (rtsp:///live). The camera is a separate +// device on the network, not part of the printer, so PrusaLink can't hand out +// its pictures; ffmpeg does the grabbing. +package camera + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/trevin-lee/prusactl/internal/redact" +) + +// Timeout bounds one grab: connecting, waiting for a key frame, decoding. +const Timeout = 30 * time.Second + +// maxImage caps the picture read from ffmpeg. +const maxImage = 16 << 20 + +// ErrNoFFmpeg means the grab needs ffmpeg and it isn't installed. +var ErrNoFFmpeg = errors.New("grabbing a frame from the camera needs ffmpeg, which isn't on PATH; install it (e.g. `brew install ffmpeg` or `apt install ffmpeg`)") + +// Grabber runs ffmpeg. The zero value looks ffmpeg up on PATH. +type Grabber struct { + // Binary is the ffmpeg to run; empty means "ffmpeg" from PATH. Tests point + // it at a stand-in. + Binary string + // Env, when set, is the environment ffmpeg runs in (in addition to the + // parent's). + Env []string +} + +// ErrRTSPS means the address asks for TLS, which ffmpeg can't be made to check. +var ErrRTSPS = errors.New("rtsps:// cameras aren't supported: ffmpeg does not verify the camera's TLS certificate (its RTSP code never passes -tls_verify or -ca_file on to TLS, tested on 7.1.5), so anyone on the network could pose as the camera and read the password; use the camera's plain rtsp:// address") + +// ErrCredentials means the address carries a user or password. ffmpeg would get +// it on its command line, where any local user can read it with ps, and it +// would sit in plain text in prusactl's config; the Buddy3D camera takes none, +// so rather than handle a password carefully the address is refused. +var ErrCredentials = errors.New("credentials in the camera address aren't supported: ffmpeg would get the password on its command line, readable by any local user with ps, and it would be stored in plain text; the Buddy3D camera takes none") + +// Validate checks that raw is an rtsp:// address without credentials, and +// returns it with the scheme in lowercase. An rtsps:// address is refused, see +// ErrRTSPS; one with a user or password too, see ErrCredentials. +func Validate(raw string) (string, error) { + raw = strings.TrimSpace(raw) + u, err := url.Parse(raw) + if err == nil && u.Host != "" && strings.EqualFold(u.Scheme, "rtsps") { + return "", ErrRTSPS + } + if err != nil || u.Host == "" || !strings.EqualFold(u.Scheme, "rtsp") { + return "", fmt.Errorf("not a camera address: %q (expected rtsp:///live)", maskAny(raw)) + } + if u.User != nil { + return "", fmt.Errorf("%w; use rtsp://%s%s", ErrCredentials, u.Host, u.Path) + } + // ffmpeg only knows the lowercase scheme. + return "rtsp" + raw[len("rtsp"):], nil +} + +// userinfo finds everything between "rtsp://" and the last @ on the line. It is +// greedier than redact's pattern on purpose: an address url.Parse refuses (a +// space, a quote or a slash in the password) has no reliable end for the +// password, so all of it goes, at the cost of hiding some of the host. +var userinfo = regexp.MustCompile(`(?is)(rtsps?://).*@`) + +// anyUserinfo is userinfo for whatever the person typed: any scheme, or none. +var anyUserinfo = regexp.MustCompile(`(?is)^([a-z][a-z0-9+.-]*://)?.*@`) + +// maskAny hides what comes before the last @ in an address that may not be an +// RTSP address at all (a typo in the scheme must not leak the password). +func maskAny(s string) string { + return Mask(anyUserinfo.ReplaceAllString(s, "${1}"+redact.Mask+"@")) +} + +// Mask hides a password in an RTSP address, for anything shown to a person or +// a model. It works on text that isn't a valid URL too, since the address came +// from a person or a config file and may be malformed. +func Mask(s string) string { + return redact.Text(userinfo.ReplaceAllString(s, "${1}"+redact.Mask+"@")) +} + +// args is ffmpeg's command line: one frame over TCP (UDP loses packets on +// Wi-Fi), as a JPEG on stdout. It is a list, never a shell command. The address +// is plain rtsp://; rtsps:// never gets this far (see ErrRTSPS). +func args(rtsp string) []string { + return []string{ + "-nostdin", "-hide_banner", "-loglevel", "error", + "-rtsp_transport", "tcp", + "-i", rtsp, + "-frames:v", "1", "-q:v", "3", + "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1", + } +} + +// completeJPEG reports whether img starts with a JPEG's start marker and ends +// with its end marker, so a picture cut short isn't passed on. +func completeJPEG(img []byte) bool { + n := len(img) + return n >= 4 && img[0] == 0xFF && img[1] == 0xD8 && img[n-2] == 0xFF && img[n-1] == 0xD9 +} + +// Snapshot returns one JPEG frame from the RTSP stream at rtsp. +func (g Grabber) Snapshot(ctx context.Context, rtsp string) ([]byte, error) { + rtsp, err := Validate(rtsp) + if err != nil { + return nil, err + } + bin := g.Binary + if bin == "" { + if bin, err = exec.LookPath("ffmpeg"); err != nil { + return nil, ErrNoFFmpeg + } + } + ctx, cancel := context.WithTimeout(ctx, Timeout) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args(rtsp)...) + if len(g.Env) > 0 { + cmd.Env = append(cmd.Environ(), g.Env...) + } + var out, stderr bytes.Buffer + lw := &limitWriter{w: &out, n: maxImage} + cmd.Stdout = lw + cmd.Stderr = &limitWriter{w: &stderr, n: 4 << 10} + runErr := cmd.Run() + img := out.Bytes() + // ffmpeg complains about the frames before the first key frame yet still + // delivers the picture, so what counts is whether a whole JPEG came out. + if !lw.over && completeJPEG(img) { + return img, nil + } + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("the camera at %s sent no picture within %s", Mask(rtsp), Timeout) + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + msg := strings.TrimSpace(Mask(strings.ReplaceAll(stderr.String(), rtsp, Mask(rtsp)))) + if msg == "" && lw.over { + msg = fmt.Sprintf("the picture is over %d MB", maxImage>>20) + } + if msg == "" && len(img) > 1 && img[0] == 0xFF && img[1] == 0xD8 { + msg = "ffmpeg's picture was cut short" + } + if msg == "" && runErr != nil { + msg = runErr.Error() + } + if msg == "" { + msg = "ffmpeg produced no picture" + } + return nil, fmt.Errorf("couldn't grab a frame from %s: %s", Mask(rtsp), msg) +} + +// limitWriter stops accepting bytes after n, so a runaway child can't fill +// memory; the surplus is dropped without an error so the child isn't killed by +// a broken pipe mid-frame. +type limitWriter struct { + w *bytes.Buffer + n int + over bool // bytes were dropped +} + +func (l *limitWriter) Write(p []byte) (int, error) { + room := max(l.n-l.w.Len(), 0) + l.w.Write(p[:min(len(p), room)]) + if len(p) > room { + l.over = true + } + return len(p), nil +} diff --git a/internal/camera/camera_test.go b/internal/camera/camera_test.go new file mode 100644 index 0000000..3167027 --- /dev/null +++ b/internal/camera/camera_test.go @@ -0,0 +1,224 @@ +package camera + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" +) + +// The test binary doubles as a stand-in ffmpeg: Grabber.Binary points at it and +// FAKE_FFMPEG says how to behave, so the real exec path is what runs. +func TestMain(m *testing.M) { + switch os.Getenv("FAKE_FFMPEG") { + case "": + os.Exit(m.Run()) + case "ok": + // Record the arguments on stderr (ffmpeg's log channel) and answer a + // JPEG, with the noise real ffmpeg makes before the first key frame. + fmt.Fprintln(os.Stderr, "DecodeFrame failed") + os.Stdout.Write([]byte{0xFF, 0xD8, 0xFF, 0xE0, 'j', 'f', 'i', 'f', 0xFF, 0xD9}) + if os.Getenv("FAKE_ARGS_FILE") != "" { + os.WriteFile(os.Getenv("FAKE_ARGS_FILE"), []byte(strings.Join(os.Args[1:], "\n")), 0o600) + } + case "fail": + fmt.Fprintln(os.Stderr, "rtsp://cam:hunter2@10.0.0.9/live: Connection refused") + os.Exit(1) + case "garbage": + os.Stdout.Write([]byte("not an image")) + case "truncated": + os.Stdout.Write([]byte{0xFF, 0xD8, 0xFF, 0xE0, 'j', 'f', 'i', 'f'}) + case "huge": + os.Stdout.Write([]byte{0xFF, 0xD8}) + os.Stdout.Write(make([]byte, maxImage)) + os.Stdout.Write([]byte{0xFF, 0xD9}) + case "hang": + select {} + } + os.Exit(0) +} + +func TestSnapshotRunsFFmpegWithAnArgumentList(t *testing.T) { + argsFile := t.TempDir() + "/args" + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=ok", "FAKE_ARGS_FILE=" + argsFile}} + img, err := g.Snapshot(context.Background(), "rtsp://10.0.0.9/live") + if err != nil { + t.Fatal(err) + } + if len(img) < 3 || img[0] != 0xFF || img[1] != 0xD8 { + t.Fatalf("not a JPEG: %x", img) + } + raw, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + got := strings.Split(string(raw), "\n") + want := []string{"-rtsp_transport", "tcp", "-i", "rtsp://10.0.0.9/live", "-frames:v", "1"} + for i := range got { + if got[i] == want[0] { + if strings.Join(got[i:i+len(want)], " ") != strings.Join(want, " ") { + t.Errorf("args %v, want ...%v", got, want) + } + return + } + } + t.Errorf("args %v lack %v", got, want) +} + +func TestSnapshotErrors(t *testing.T) { + for _, tc := range []struct { + name, mode, url, want string + }{ + {"ffmpeg fails", "fail", "rtsp://10.0.0.9/live", "Connection refused"}, // the fake echoes a password; Mask must catch it + {"not an image", "garbage", "rtsp://10.0.0.9/live", "ffmpeg produced no picture"}, + {"cut short", "truncated", "rtsp://10.0.0.9/live", "cut short"}, + {"over the cap", "huge", "rtsp://10.0.0.9/live", "over 16 MB"}, + {"not rtsp", "ok", "http://10.0.0.9/live", "not a camera address"}, + {"flag lookalike", "ok", "-i rtsp://x", "not a camera address"}, + } { + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=" + tc.mode}} + _, err := g.Snapshot(context.Background(), tc.url) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: %v, want %q", tc.name, err, tc.want) + } + if err != nil && strings.Contains(err.Error(), "hunter2") { + t.Errorf("%s: the camera password leaked: %v", tc.name, err) + } + } +} + +func TestSnapshotNeedsFFmpeg(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := Grabber{}.Snapshot(context.Background(), "rtsp://10.0.0.9/live") + if !errors.Is(err, ErrNoFFmpeg) { + t.Fatalf("got %v, want ErrNoFFmpeg", err) + } +} + +func TestSnapshotGivesUp(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=hang"}} + if _, err := g.Snapshot(ctx, "rtsp://10.0.0.9/live"); !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want the cancellation", err) + } +} + +// FFmpeg 7.1.5 doesn't check the certificate of an rtsps:// camera even with +// -tls_verify 1 and -ca_file: its RTSP code doesn't pass them on to TLS. So +// rtsps:// is refused before ffmpeg runs. +func TestRTSPSIsRefused(t *testing.T) { + for _, in := range []string{"rtsps://10.0.0.9/live", "RTSPS://cam:pw@10.0.0.9/live"} { + if _, err := Validate(in); !errors.Is(err, ErrRTSPS) { + t.Errorf("Validate(%q) = %v, want ErrRTSPS", in, err) + } + if strings.Contains(fmt.Sprint(Validate(in)), "pw") { + t.Errorf("the refusal shows the password") + } + // The fake ffmpeg leaves its arguments in a file when it runs. + ran := t.TempDir() + "/ran" + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=ok", "FAKE_ARGS_FILE=" + ran}} + if _, err := g.Snapshot(context.Background(), in); !errors.Is(err, ErrRTSPS) { + t.Errorf("Snapshot(%q) = %v, want ErrRTSPS", in, err) + } + if _, err := os.Stat(ran); err == nil { + t.Errorf("Snapshot(%q) ran ffmpeg", in) + } + } + if _, err := Validate("rtsp://10.0.0.9/live"); err != nil { + t.Errorf("plain rtsp refused: %v", err) + } +} + +// ffmpeg doesn't know an uppercase scheme ("Protocol not found"), so Validate +// lowercases it and leaves the rest alone. +func TestValidateLowercasesTheScheme(t *testing.T) { + for in, want := range map[string]string{ + "RTSP://10.0.0.9/live": "rtsp://10.0.0.9/live", + " RtSp://10.0.0.9/Live ": "rtsp://10.0.0.9/Live", + } { + if got, err := Validate(in); err != nil || got != want { + t.Errorf("Validate(%q) = %q, %v, want %q", in, got, err, want) + } + } +} + +// An address url.Parse refuses must not leak its password through the error, +// whatever the password holds or the scheme is. A leak shows up as the secret +// itself or as its tail. +func TestValidateErrorsNeverShowAPassword(t *testing.T) { + for _, in := range []string{ + "rtsp://cam:my secret@10.0.0.9/live", + `rtsp://cam:pa"ss@10.0.0.9/live`, + "rtsp://cam:pa/ss@10.0.0.9/live", + "rtsp://cam:pa\x7fss@10.0.0.9/live", + "rtsps://cam:se cret@10.0.0.9", + "http://cam:secret@10.0.0.9/live", + "cam:secret@10.0.0.9/live", + "rtsp://cam:secret%zz@10.0.0.9/live", + "rtsp://cam:pa\nss@10.0.0.9/live", + "rtsp://cam:pa\r\nss@10.0.0.9/live", + "rtsp://cam:pa\rss@10.0.0.9/live", + } { + _, err := Validate(in) + if err == nil { + t.Errorf("Validate(%q) accepted a bad address", in) + continue + } + for _, leak := range []string{"secret", "my sec", "ss@", "pa\"", "pa/", "hun", "ter2", "cam:"} { + if strings.Contains(err.Error(), leak) { + t.Errorf("Validate(%q) error shows %q: %v", in, leak, err) + } + } + if !strings.Contains(err.Error(), "10.0.0.9") { + t.Errorf("Validate(%q) error lost the host: %v", in, err) + } + } +} + +// Mask is used on saved addresses and on ffmpeg's output, neither of which is +// guaranteed to be a clean URL. +func TestMaskHidesPasswordsInMalformedAddresses(t *testing.T) { + for in, want := range map[string]string{ + "rtsp://cam:my secret@10.0.0.9/live": "rtsp://[redacted]@10.0.0.9/live", + "rtsp://cam:pa/ss@10.0.0.9/live: failed": "rtsp://[redacted]@10.0.0.9/live: failed", + `rtsp://cam:pa"ss@10.0.0.9/live`: "rtsp://[redacted]@10.0.0.9/live", + "rtsp://10.0.0.9/live": "rtsp://10.0.0.9/live", + "rtsp://cam:pa\nss@10.0.0.9/live": "rtsp://[redacted]@10.0.0.9/live", + "rtsp://cam:pa\r\nss@10.0.0.9/live": "rtsp://[redacted]@10.0.0.9/live", + } { + if got := Mask(in); got != want { + t.Errorf("Mask(%q) = %q, want %q", in, got, want) + } + } +} + +// Credentials in the address are refused rather than handled: a password would +// reach ffmpeg as an argument, readable by any local user with ps, and sit in +// plain text in the config. The Buddy3D needs none. The refusal says what to +// use instead and never repeats the credentials. +func TestValidateRefusesCredentials(t *testing.T) { + for _, in := range []string{"rtsp://cam:hunter2@10.0.0.9/live", "rtsp://cam@10.0.0.9/live"} { + _, err := Validate(in) + if !errors.Is(err, ErrCredentials) { + t.Errorf("Validate(%q) = %v, want ErrCredentials", in, err) + continue + } + if strings.Contains(err.Error(), "hunter2") || strings.Contains(err.Error(), "cam:") || strings.Contains(err.Error(), "cam@") { + t.Errorf("the refusal shows the credentials: %v", err) + } + if !strings.Contains(err.Error(), "rtsp://10.0.0.9/live") { + t.Errorf("the refusal should say what to use instead: %v", err) + } + ran := t.TempDir() + "/ran" + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=ok", "FAKE_ARGS_FILE=" + ran}} + if _, err := g.Snapshot(context.Background(), in); !errors.Is(err, ErrCredentials) { + t.Errorf("Snapshot(%q) = %v, want ErrCredentials", in, err) + } + if _, err := os.Stat(ran); err == nil { + t.Errorf("Snapshot(%q) ran ffmpeg", in) + } + } +} diff --git a/internal/link/config.go b/internal/link/config.go index 163bcfe..1348b46 100644 --- a/internal/link/config.go +++ b/internal/link/config.go @@ -35,6 +35,66 @@ type Config struct { type fileFormat struct { Printer *Config `json:"printer,omitempty"` + // Camera is the RTSP address of the printer's camera, e.g. + // rtsp://192.168.8.60/live. It is a separate device, so it is saved apart + // from the printer. + Camera string `json:"camera,omitempty"` +} + +// readFile reads the config file; a missing one is empty. +func readFile() (fileFormat, string, error) { + var f fileFormat + path, err := configPath() + if err != nil { + return f, "", err + } + b, err := os.ReadFile(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return f, path, nil + case err != nil: + return f, path, err + } + if err := json.Unmarshal(b, &f); err != nil { + return f, path, fmt.Errorf("reading %s: %w", path, err) + } + return f, path, nil +} + +func writeFile(path string, f fileFormat) error { + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o600) +} + +// LoadCamera returns the camera's RTSP address: PRUSACTL_CAMERA_URL if set, +// else the one saved by `prusactl setup --camera`, else "". +func LoadCamera() (string, error) { + if v := env("PRUSACTL_CAMERA_URL"); v != "" { + return v, nil + } + f, _, err := readFile() + return f.Camera, err +} + +// SaveCamera saves the camera's address, or forgets it when addr is empty, +// leaving the saved printer alone. +func SaveCamera(addr string) error { + f, path, err := readFile() + if err != nil { + return err + } + f.Camera = addr + if f.Printer == nil && addr == "" { + // Nothing left to keep. + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil + } + return writeFile(path, f) } // NormalizeHost turns "192.168.8.162" or "prusa.local:8080" into an origin. @@ -77,24 +137,13 @@ func configPath() (string, error) { // LoadConfig reads the saved printer, applying PRUSACTL_HOST / PRUSACTL_USER / // PRUSACTL_AUTH overrides. It returns ErrNotConfigured if there is none. func LoadConfig() (Config, error) { - var cfg Config - path, err := configPath() + f, _, err := readFile() if err != nil { - return cfg, err + return Config{}, err } - b, err := os.ReadFile(path) - switch { - case errors.Is(err, fs.ErrNotExist): - case err != nil: - return cfg, err - default: - var f fileFormat - if err := json.Unmarshal(b, &f); err != nil { - return cfg, fmt.Errorf("reading %s: %w", path, err) - } - if f.Printer != nil { - cfg = *f.Printer - } + var cfg Config + if f.Printer != nil { + cfg = *f.Printer } if v := env("PRUSACTL_HOST"); v != "" { cfg.Host = v @@ -122,35 +171,23 @@ func LoadConfig() (Config, error) { // SaveConfig writes the printer address (never the secret). func SaveConfig(cfg Config) error { - path, err := configPath() - if err != nil { + f, path, err := readFile() + if path == "" { return err } - b, err := json.MarshalIndent(fileFormat{Printer: &cfg}, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(b, '\n'), 0o600) + // An unreadable file is replaced, as before; a readable one keeps the + // camera saved beside the printer. + f.Printer = &cfg + return writeFile(path, f) } // SavedConfig returns the printer saved by `prusactl setup`, ignoring the // PRUSACTL_* overrides, or ErrNotConfigured. func SavedConfig() (Config, error) { - path, err := configPath() - if err != nil { - return Config{}, err - } - b, err := os.ReadFile(path) - if errors.Is(err, fs.ErrNotExist) { - return Config{}, ErrNotConfigured - } + f, _, err := readFile() if err != nil { return Config{}, err } - var f fileFormat - if err := json.Unmarshal(b, &f); err != nil { - return Config{}, fmt.Errorf("reading %s: %w", path, err) - } if f.Printer == nil || f.Printer.Host == "" { return Config{}, ErrNotConfigured } diff --git a/internal/redact/redact.go b/internal/redact/redact.go index 7a1b975..1a56f4c 100644 --- a/internal/redact/redact.go +++ b/internal/redact/redact.go @@ -19,6 +19,13 @@ var key = regexp.MustCompile(`(?i)api_?key|token|password|secret`) // truncated response: group 1 is the name and colon, group 2 the value. var pair = regexp.MustCompile(`(?i)("[^"]*(?:api_?key|token|password|secret)[^"]*"\s*:\s*)("(?:[^"\\]|\\.)*"?|[^,}\]\s]+)`) +// rtspUser finds the user:password in a camera address such as +// rtsp://user:password@192.168.1.5/live. The password may hold an @, so the +// userinfo runs to the last @ before the path, and it may hold spaces, a line break or a +// JSON-escaped quote, which is how a malformed address that url.Parse refuses +// shows up in an error. +var rtspUser = regexp.MustCompile(`(?is)(rtsps?://)(?:[^/"\\]|\\.)+@`) + // Value masks credential fields in a decoded JSON value, in place, and // returns it. func Value(v any) any { @@ -67,8 +74,9 @@ func JSON(raw []byte) ([]byte, bool) { // error message. func Text(s string) string { if out, changed := JSON([]byte(s)); changed { - return string(out) + return rtspUser.ReplaceAllString(string(out), "${1}"+Mask+"@") } + s = rtspUser.ReplaceAllString(s, "${1}"+Mask+"@") if json.Valid([]byte(s)) { return s } diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go index 1e3cea1..1ac22f1 100644 --- a/internal/redact/redact_test.go +++ b/internal/redact/redact_test.go @@ -60,3 +60,26 @@ func TestTextCoversTruncatedAndEmbeddedJSON(t *testing.T) { t.Errorf("clean text changed: %s", Text(clean)) } } + +// A camera address can carry a password; the host and path stay readable. The +// exact output is compared, since a pattern that stops at the first @ leaves +// the tail of the password behind and still hides "hunter2" as a whole word. +func TestTextMasksCameraPassword(t *testing.T) { + for in, want := range map[string]string{ + "couldn't open rtsp://cam:hunter2@192.168.1.5/live: refused": "couldn't open rtsp://[redacted]@192.168.1.5/live: refused", + "couldn't open rtsp://cam:hun@ter2@192.168.1.5/live: refused": "couldn't open rtsp://[redacted]@192.168.1.5/live: refused", + "bad rtsp://cam:pa\nss@192.168.1.5/live": "bad rtsp://[redacted]@192.168.1.5/live", + "bad rtsp://cam:pa\r\nss@192.168.1.5/live": "bad rtsp://[redacted]@192.168.1.5/live", + "bad rtsp://cam:my secret@192.168.1.5/live": "bad rtsp://[redacted]@192.168.1.5/live", + `{"url":"rtsp://cam:hunter2@192.168.1.5/live","token":"x"}`: `{"token":"[redacted]","url":"rtsp://[redacted]@192.168.1.5/live"}`, + `{"url":"rtsp://cam:hun@ter2@192.168.1.5/live","e":"a@b.example"}`: `{"url":"rtsp://[redacted]@192.168.1.5/live","e":"a@b.example"}`, + `{"url":"rtsp://cam:pa\"ss@192.168.1.5/live"}`: `{"url":"rtsp://[redacted]@192.168.1.5/live"}`, + } { + if got := Text(in); got != want { + t.Errorf("Text(%q)\n got %q\nwant %q", in, got, want) + } + } + if got := Text("rtsp://192.168.1.5/live"); got != "rtsp://192.168.1.5/live" { + t.Errorf("an address without a password changed: %q", got) + } +} diff --git a/internal/server/camera.go b/internal/server/camera.go new file mode 100644 index 0000000..2c90989 --- /dev/null +++ b/internal/server/camera.go @@ -0,0 +1,201 @@ +package server + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/trevin-lee/prusactl/internal/camera" + "github.com/trevin-lee/prusactl/internal/hint" + "github.com/trevin-lee/prusactl/internal/link" +) + +// snapshotter grabs one picture from an RTSP camera; camera.Grabber, with +// ffmpeg, in production. +type snapshotter interface { + Snapshot(ctx context.Context, rtsp string) ([]byte, error) +} + +// cameraSnapshot picks where a picture comes from, the way route picks where +// any other call goes: the direct route when it can serve the picture, Prusa +// Connect otherwise or when asked (via=connect). +// +// Buddy firmware's PrusaLink has no camera endpoint: the Buddy3D camera is a +// separate Wi-Fi device that talks to Prusa Connect itself and serves RTSP at +// rtsp:///live. So locally there are two sources, tried in order: +// the printer's /api/v1/cameras/snap (other PrusaLink builds have one), then +// the saved RTSP address, grabbed with ffmpeg. +func (s *Server) cameraSnapshot(ctx context.Context, in snapshotInput) (*mcp.CallToolResult, error) { + via := strings.ToLower(strings.TrimSpace(in.Via)) + if via != "" && via != "direct" && via != "connect" { + return nil, fmt.Errorf("via must be direct or connect (got %q)", in.Via) + } + if via == "connect" { + return s.connectSnapshot(ctx, in) + } + + // Is the printer the user means the directly connected one? Resolve it as + // direct only, so a Connect lookup doesn't happen before it's needed. + ref := in.printerRef + ref.Via = "direct" + t, rerr := s.route(ctx, ref) + var rtsp string + if s.cameraURL != nil { + var cerr error + if rtsp, cerr = s.cameraURL(); cerr != nil { + return nil, cerr + } + // Checked here, not only when it was saved: PRUSACTL_CAMERA_URL is + // never checked at all otherwise, and what is allowed can change + // between versions. + if rtsp != "" { + if rtsp, cerr = camera.Validate(rtsp); cerr != nil { + return nil, cerr + } + } + } + + var reason error // why the local sources can't answer + switch { + case rerr == nil: + res, ok, err := s.printerCameraSnap(ctx, in.CameraID, t.name) + if ok { + return res, nil + } + // The camera is its own device: a printer that errors on its camera + // endpoint (rebooting, busy) says nothing about it. The error only + // ends the call when there is nothing else local to try. + if err != nil && (rtsp == "" || in.CameraID != "") { + return nil, err + } + if rtsp != "" && in.CameraID == "" { + return s.rtspOrConnect(ctx, in, via, rtsp, t.name) + } + reason = noLocalCamera(in.CameraID) + case rtsp != "" && in.Printer == "" && in.CameraID == "": + // The camera is its own device on the network: the printer being + // unreachable says nothing about it. + return s.rtspOrConnect(ctx, in, via, rtsp, "the printer") + default: + reason = rerr + } + + if via == "direct" { + return nil, fmt.Errorf("no local camera: %w", reason) + } + if !s.session.SignedIn() { + return nil, fmt.Errorf("no camera to use: %v; or sign in to Prusa Connect, which has the camera (%s)", reason, hint.Connect()) + } + return s.connectSnapshot(ctx, in) +} + +// rtspOrConnect grabs from the camera's RTSP stream and, when that fails and +// the caller didn't insist on a direct source, falls back to Prusa Connect: an +// offline camera or a missing ffmpeg shouldn't hide a picture Connect has. +func (s *Server) rtspOrConnect(ctx context.Context, in snapshotInput, via, rtsp, printerName string) (*mcp.CallToolResult, error) { + res, err := s.rtspSnapshot(ctx, rtsp, printerName) + if err == nil || via == "direct" || !s.session.SignedIn() || ctx.Err() != nil { + return res, err + } + res, cerr := s.connectSnapshot(ctx, in) + if cerr != nil { + return nil, fmt.Errorf("%w; Prusa Connect didn't have a picture either: %v", err, cerr) + } + // Say so. A camera that was skipped looks exactly like one that worked, + // and the likely cause (ffmpeg on the terminal's PATH but not on the MCP + // server's) would otherwise never be noticed. + prefixNote(res, fmt.Sprintf("The RTSP camera was skipped (%v), so this is Prusa Connect's picture. ", err)) + return res, nil +} + +// prefixNote puts text in front of the sentence that goes with a picture. +func prefixNote(res *mcp.CallToolResult, text string) { + for _, c := range res.Content { + if t, ok := c.(*mcp.TextContent); ok { + t.Text = text + t.Text + return + } + } + res.Content = append([]mcp.Content{&mcp.TextContent{Text: strings.TrimSpace(text)}}, res.Content...) +} + +// noLocalCamera says how to give prusactl a camera it can reach locally. +func noLocalCamera(cameraID string) error { + if cameraID != "" { + return fmt.Errorf("the printer has no camera API, and camera_id only names cameras on it or on Prusa Connect") + } + return errors.New("the printer has no camera API (Buddy firmware doesn't; the Buddy3D camera is a separate device) " + + "and no camera address is saved: " + cameraAdvice()) +} + +func cameraAdvice() string { + if hint.Managed() { + return "enter the camera's RTSP address (rtsp:///live) in the extension's settings; ffmpeg has to be installed where the app can find it" + } + return "run `prusactl setup --camera rtsp:///live`" +} + +// imageResult is a picture and the sentence that goes with it. +func imageResult(img []byte, mime, note string) *mcp.CallToolResult { + return &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.TextContent{Text: note}, + &mcp.ImageContent{Data: img, MIMEType: mime}, + }} +} + +// readImage reads a picture from a response, and names its type when the +// server doesn't. +func readImage(resp *http.Response) ([]byte, string, error) { + img, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return nil, "", err + } + mime := resp.Header.Get("Content-Type") + if mime == "" || mime == "application/octet-stream" { + mime = http.DetectContentType(img) + } + return img, mime, nil +} + +// printerCameraSnap asks PrusaLink for a picture. ok is false, without an +// error, when the printer has no such endpoint or no camera; an unreachable or +// unauthorized printer is an error. +func (s *Server) printerCameraSnap(ctx context.Context, cameraID, printerName string) (*mcp.CallToolResult, bool, error) { + lc := s.direct() + path := "/api/v1/cameras/snap" + if cameraID != "" { + path = "/api/v1/cameras/" + url.PathEscape(cameraID) + "/snap" + } + resp, err := lc.Do(ctx, link.Request{Method: http.MethodGet, Path: path, Header: http.Header{"Accept": {"image/jpeg"}}}) + switch { + case link.IsStatus(err, http.StatusNotFound), link.IsStatus(err, http.StatusMethodNotAllowed): + return nil, false, nil + case err != nil: + return nil, false, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent { + return nil, false, nil + } + img, mime, err := readImage(resp) + if err != nil { + return nil, false, err + } + return imageResult(img, mime, fmt.Sprintf("Camera on %s, from the printer's own camera API.", printerName)), true, nil +} + +// rtspSnapshot grabs one frame from the camera's RTSP stream. +func (s *Server) rtspSnapshot(ctx context.Context, rtsp, printerName string) (*mcp.CallToolResult, error) { + img, err := s.grabber.Snapshot(ctx, rtsp) + if err != nil { + return nil, err + } + note := fmt.Sprintf("Camera for %s, one frame just grabbed from %s with ffmpeg.", printerName, camera.Mask(rtsp)) + return imageResult(img, "image/jpeg", note), nil +} diff --git a/internal/server/camera_test.go b/internal/server/camera_test.go new file mode 100644 index 0000000..61ae1ae --- /dev/null +++ b/internal/server/camera_test.go @@ -0,0 +1,196 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/trevin-lee/prusactl/internal/camera" +) + +// fakeGrabber stands in for ffmpeg and records the address it was asked for. +type fakeGrabber struct { + urls []string + err error +} + +func (g *fakeGrabber) Snapshot(_ context.Context, rtsp string) ([]byte, error) { + g.urls = append(g.urls, rtsp) + if g.err != nil { + return nil, g.err + } + return []byte{0xFF, 0xD8, 0xFF, 0xD9}, nil +} + +func withCamera(g *fakeGrabber, rtsp string) func(*Server) { + return func(s *Server) { + s.grabber = g + s.cameraURL = func() (string, error) { return rtsp, nil } + } +} + +// snapshot calls the tool and splits the answer into its text and image. +func snapshot(t *testing.T, cs *mcp.ClientSession, args map[string]any) (text string, image []byte, isErr bool) { + t.Helper() + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_camera_snapshot", Arguments: args}) + if err != nil { + return err.Error(), nil, true + } + for _, c := range res.Content { + switch c := c.(type) { + case *mcp.TextContent: + text += c.Text + case *mcp.ImageContent: + image = c.Data + } + } + return text, image, res.IsError +} + +// The Buddy printer's PrusaLink has no camera endpoint, so a saved RTSP +// address is what serves the picture. +func TestSnapshotFromRTSPWhenThePrinterHasNoCameraAPI(t *testing.T) { + g := &fakeGrabber{} + fp := &fakePrinter{state: "IDLE"} + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://10.0.0.9/live")) + for i := 0; i < 2; i++ { + text, img, isErr := snapshot(t, cs, nil) + if isErr || len(img) == 0 { + t.Fatalf("call %d: %q (error=%v)", i, text, isErr) + } + if !strings.Contains(text, "10.0.0.9/live") { + t.Errorf("the note should name the camera: %q", text) + } + } + if len(g.urls) != 2 || g.urls[0] != "rtsp://10.0.0.9/live" { + t.Errorf("grabbed %v", g.urls) + } +} + +// An address with credentials is refused wherever it came from, the saved +// config or PRUSACTL_CAMERA_URL, and the refusal repeats neither. +func TestSnapshotRefusesASavedAddressWithCredentials(t *testing.T) { + g := &fakeGrabber{} + cs := connectToolsWith(t, &fakePrinter{state: "IDLE"}, withCamera(g, "rtsp://cam:hunter2@10.0.0.9/live")) + text, _, isErr := snapshot(t, cs, nil) + if !isErr || !strings.Contains(text, "credentials") || strings.Contains(text, "hunter2") { + t.Errorf("%q (error=%v)", text, isErr) + } + if len(g.urls) != 0 { + t.Errorf("the address reached ffmpeg: %v", g.urls) + } +} + +// A printer that errors on its camera endpoint (rebooting, busy) says nothing +// about the camera, which is its own device: the RTSP source is still tried. +func TestSnapshotFallsThroughWhenThePrinterCameraErrors(t *testing.T) { + g := &fakeGrabber{} + fp := &fakePrinter{state: "IDLE", handler: func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path != "/api/v1/cameras/snap" { + return false + } + w.WriteHeader(http.StatusServiceUnavailable) + return true + }} + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://10.0.0.9/live")) + if text, img, isErr := snapshot(t, cs, nil); isErr || len(img) == 0 { + t.Fatalf("%q (error=%v)", text, isErr) + } + if len(g.urls) != 1 { + t.Errorf("RTSP wasn't tried: %v", g.urls) + } +} + +// A PrusaLink that does serve pictures wins over the saved address. +func TestSnapshotPrefersThePrintersOwnCameraAPI(t *testing.T) { + g := &fakeGrabber{} + fp := &fakePrinter{state: "IDLE", handler: func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path != "/api/v1/cameras/snap" { + return false + } + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte{0xFF, 0xD8, 0x01, 0xFF, 0xD9}) + return true + }} + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://10.0.0.9/live")) + text, img, isErr := snapshot(t, cs, nil) + if isErr || len(img) != 5 || len(g.urls) != 0 { + t.Fatalf("%q image=%d grabbed=%v error=%v", text, len(img), g.urls, isErr) + } + if !strings.Contains(text, "printer's own camera API") { + t.Errorf("the note should say where the picture came from: %q", text) + } +} + +// With nothing local and no Connect, the error says how to get a camera, and +// via=direct says so without suggesting Connect. +func TestSnapshotWithoutACamera(t *testing.T) { + cs := connectTools(t, &fakePrinter{state: "IDLE"}) + for _, via := range []string{"", "direct"} { + text, _, isErr := snapshot(t, cs, map[string]any{"via": via}) + if !isErr || !strings.Contains(text, "--camera rtsp://") { + t.Errorf("via=%q: %q", via, text) + } + if via == "" && !strings.Contains(text, "Prusa Connect") { + t.Errorf("without a route, Connect should be offered: %q", text) + } + } + text, _, isErr := snapshot(t, cs, map[string]any{"via": "connect"}) + if !isErr || !strings.Contains(text, "Prusa Connect") { + t.Errorf("via=connect: %q", text) + } +} + +func TestSnapshotReportsFFmpegProblems(t *testing.T) { + g := &fakeGrabber{err: camera.ErrNoFFmpeg} + cs := connectToolsWith(t, &fakePrinter{state: "IDLE"}, withCamera(g, "rtsp://10.0.0.9/live")) + text, _, isErr := snapshot(t, cs, nil) + if !isErr || !strings.Contains(text, "needs ffmpeg") { + t.Errorf("%q", text) + } +} + +// The camera is its own device: the printer being off doesn't stop the picture. +func TestSnapshotWhenThePrinterIsUnreachable(t *testing.T) { + g := &fakeGrabber{} + fp := &fakePrinter{handler: func(w http.ResponseWriter, r *http.Request) bool { + w.WriteHeader(http.StatusServiceUnavailable) + return true + }} + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://10.0.0.9/live")) + if text, img, isErr := snapshot(t, cs, nil); isErr || len(img) == 0 { + t.Fatalf("%q", text) + } + // But a named printer isn't assumed to be the one the camera watches. + if text, _, isErr := snapshot(t, cs, map[string]any{"printer": "another"}); !isErr { + t.Errorf("another printer got the saved camera: %q", text) + } +} + +// A camera that can't be grabbed doesn't hide Prusa Connect's picture: the +// automatic source order goes on to Connect, and via=direct stops. +func TestSnapshotFallsBackToConnectWhenTheCameraFails(t *testing.T) { + g := &fakeGrabber{err: errors.New("Connection refused")} + cs := connectConnectTools(t, &fakeConnect{state: "IDLE"}, withCamera(g, "rtsp://10.0.0.9/live")) + text, img, isErr := snapshot(t, cs, nil) + if isErr || len(img) == 0 { + t.Fatalf("Connect's picture should have come through: %q", text) + } + // A camera that was skipped must not look like one that worked. + if !strings.Contains(text, "Connection refused") || !strings.Contains(text, "Prusa Connect") { + t.Errorf("the note should say the camera was skipped, and why: %q", text) + } + // With nothing on Connect either, both reasons show. + none := connectConnectTools(t, &fakeConnect{state: "IDLE", noCameras: true}, withCamera(g, "rtsp://10.0.0.9/live")) + if text, _, isErr := snapshot(t, none, nil); !isErr || !strings.Contains(text, "Connection refused") || !strings.Contains(text, "Prusa Connect didn't have a picture") { + t.Errorf("the local error and Connect's should both show: %q", text) + } + text, _, isErr = snapshot(t, cs, map[string]any{"via": "direct"}) + if !isErr || !strings.Contains(text, "Connection refused") || strings.Contains(text, "Prusa Connect didn't") { + t.Errorf("via=direct must not try Connect: %q", text) + } +} diff --git a/internal/server/connect_tools_test.go b/internal/server/connect_tools_test.go index 9be74a2..b81be74 100644 --- a/internal/server/connect_tools_test.go +++ b/internal/server/connect_tools_test.go @@ -50,6 +50,7 @@ type fakeConnect struct { serviceDown bool // GraphQL answers CameraServiceError snapshotURL string // overrides the snapshot URL GraphQL hands out snapshotAuths []string + noCameras bool // the account has no camera on this printer } func (f *fakeConnect) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -88,6 +89,10 @@ func (f *fakeConnect) ServeHTTP(w http.ResponseWriter, r *http.Request) { case r.URL.Path == "/app/printers/u1/queue": _ = json.NewEncoder(w).Encode(map[string]any{"queue": f.queue}) case r.URL.Path == "/app/printers/u1/cameras": + if f.noCameras { + fmt.Fprint(w, `{"cameras":[]}`) + return + } fmt.Fprint(w, `{"cameras":[{"id":601734,"name":"Buddy3D Camera","token":"cam-token"}]}`) case r.URL.Path == "/graphql" && r.Method == http.MethodPost: conn := map[string]any{"__typename": "CameraServiceError", "errorCode": "UNAVAILABLE"} @@ -136,7 +141,7 @@ func (f *fakeConnect) sentCommands() []string { return append([]string(nil), f.sent...) } -func connectConnectTools(t *testing.T, fc *fakeConnect) *mcp.ClientSession { +func connectConnectTools(t *testing.T, 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) @@ -145,9 +150,16 @@ func connectConnectTools(t *testing.T, fc *fakeConnect) *mcp.ClientSession { cc := connect.New(session, "test") cc.BaseURL = srv.URL cc.GraphQLURL = srv.URL + "/graphql" - fc.base = srv.URL + // The camera-service fake needs its own base URL; other handlers don't. + if f, ok := fc.(*fakeConnect); ok { + f.base = srv.URL + } s := New(session, cc, nil, link.ErrNotConfigured, "test") s.openLink = func() (*link.Client, error) { return nil, link.ErrNotConfigured } + s.cameraURL = func() (string, error) { return "", nil } + for _, tweak := range tweaks { + tweak(s) + } ct, st := mcp.NewInMemoryTransports() ctx := context.Background() diff --git a/internal/server/server.go b/internal/server/server.go index 1ea0ace..ddf6412 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,6 +15,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/trevin-lee/prusactl/internal/auth" + "github.com/trevin-lee/prusactl/internal/camera" "github.com/trevin-lee/prusactl/internal/connect" "github.com/trevin-lee/prusactl/internal/link" "github.com/trevin-lee/prusactl/internal/redact" @@ -59,6 +60,11 @@ type Server struct { mcp *mcp.Server + // The camera: its RTSP address (re-read on each use, like the direct + // route) and what grabs a frame from it. + cameraURL func() (string, error) + grabber snapshotter + probeMu sync.Mutex probed time.Time info *linkInfo @@ -68,7 +74,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} + s := &Server{session: session, connect: cc, link: lc, linkErr: lcErr, openLink: link.Open, cameraURL: link.LoadCamera, grabber: camera.Grabber{}} s.mcp = mcp.NewServer( &mcp.Implementation{Name: "prusactl", Title: "Prusa printer", Version: version}, &mcp.ServerOptions{Instructions: instructionsFor()}, diff --git a/internal/server/tools_printers.go b/internal/server/tools_printers.go index 7fea2ed..3d389b1 100644 --- a/internal/server/tools_printers.go +++ b/internal/server/tools_printers.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "net/url" "strconv" @@ -154,80 +153,17 @@ func (s *Server) addPrinterTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "get_camera_snapshot", - Description: "The latest image from the printer's camera (via Prusa Connect), so you can see the print, " + - "the bed, and the nozzle. Use it before starting a print or moving anything (is the plate clear?) and " + - "to watch a print for failures. Reports how old the image is.", + Description: "The latest image from the printer's camera, so you can see the print, the bed, and the nozzle. " + + "Use it before starting a print or moving anything (is the plate clear?) and to watch a print for " + + "failures. " + + "Where the picture comes from: the printer's own camera API if it has one; else " + + "the camera's RTSP stream, if its address is saved (prusactl setup --camera rtsp:///live, or " + + "PRUSACTL_CAMERA_URL), grabbed with ffmpeg, which must be installed; else Prusa Connect. via=direct " + + "forces the first two, via=connect forces Connect. Reports where the image came from and how old it is.", Annotations: readOnly("Camera snapshot"), }, func(ctx context.Context, _ *mcp.CallToolRequest, in snapshotInput) (*mcp.CallToolResult, any, error) { - p, err := s.connectPrinter(ctx, in.printerRef) - if err != nil { - return nil, nil, err - } - var cams struct { - Cameras *[]map[string]any `json:"cameras"` - } - if err := s.connect.Get(ctx, printerPath(p.UUID, "cameras"), nil, &cams); err != nil { - return nil, nil, err - } - if cams.Cameras == nil { - return nil, nil, connect.Missing("GET", printerPath(p.UUID, "cameras"), "cameras") - } - if len(*cams.Cameras) == 0 { - return nil, nil, fmt.Errorf("%s has no camera in Prusa Connect", p.Name) - } - cam := (*cams.Cameras)[0] - if in.CameraID != "" { - cam = nil - for _, c := range *cams.Cameras { - if fmt.Sprint(c["id"]) == in.CameraID || fmt.Sprint(c["name"]) == in.CameraID { - cam = c - } - } - if cam == nil { - return nil, nil, fmt.Errorf("no camera %q on %s", in.CameraID, p.Name) - } - } - // Since Prusa moved cameras to its camera service (2026-09), snapshots - // come from the URL Connect's GraphQL API gives for the camera, found - // by its token. The older endpoints stay as a fallback. - var resp *http.Response - if token, _ := cam["token"].(string); token != "" { - if urls, err := s.connect.SnapshotURLs(ctx, p.UUID); err == nil && urls[token] != "" { - if r, err := s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: urls[token]}); err == nil { - resp = r - } - } - } - if resp == nil { - id := connect.PathEscape(jsonID(cam["id"])) - query := url.Values{"printer_uuid": {p.UUID}} - resp, err = s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: "/app/cameras/" + id + "/snapshots/last", Query: query}) - if connect.IsStatus(err, http.StatusNotFound) { - // WebRTC cameras (Buddy3D) may never have pushed a full snapshot; - // the web app falls back to the thumbnail endpoint. - resp, err = s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: "/thumbnail/camera/" + id, Query: query}) - } - if err != nil { - return nil, nil, err - } - } - defer resp.Body.Close() - img, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) - if err != nil { - return nil, nil, err - } - mime := resp.Header.Get("Content-Type") - if mime == "" || mime == "application/octet-stream" { - mime = http.DetectContentType(img) - } - note := fmt.Sprintf("Camera %q on %s.", fmt.Sprint(cam["name"]), p.Name) - if lm, err := http.ParseTime(resp.Header.Get("Last-Modified")); err == nil { - note += fmt.Sprintf(" Taken %s (%s ago).", lm.Local().Format(time.DateTime), time.Since(lm).Round(time.Second)) - } - return &mcp.CallToolResult{Content: []mcp.Content{ - &mcp.TextContent{Text: note}, - &mcp.ImageContent{Data: img, MIMEType: mime}, - }}, nil, nil + res, err := s.cameraSnapshot(ctx, in) + return res, nil, err }) mcp.AddTool(s.mcp, &mcp.Tool{ @@ -336,3 +272,69 @@ func jsonID(v any) string { } return fmt.Sprint(v) } + +// connectSnapshot fetches the camera's last picture from Prusa Connect. +func (s *Server) connectSnapshot(ctx context.Context, in snapshotInput) (*mcp.CallToolResult, error) { + p, err := s.connectPrinter(ctx, in.printerRef) + if err != nil { + return nil, err + } + var cams struct { + Cameras *[]map[string]any `json:"cameras"` + } + if err := s.connect.Get(ctx, printerPath(p.UUID, "cameras"), nil, &cams); err != nil { + return nil, err + } + if cams.Cameras == nil { + return nil, connect.Missing("GET", printerPath(p.UUID, "cameras"), "cameras") + } + if len(*cams.Cameras) == 0 { + return nil, fmt.Errorf("%s has no camera in Prusa Connect", p.Name) + } + cam := (*cams.Cameras)[0] + if in.CameraID != "" { + cam = nil + for _, c := range *cams.Cameras { + if fmt.Sprint(c["id"]) == in.CameraID || fmt.Sprint(c["name"]) == in.CameraID { + cam = c + } + } + if cam == nil { + return nil, fmt.Errorf("no camera %q on %s", in.CameraID, p.Name) + } + } + // Since Prusa moved cameras to its camera service (2026-09), snapshots + // come from the URL Connect's GraphQL API gives for the camera, found + // by its token. The older endpoints stay as a fallback. + var resp *http.Response + if token, _ := cam["token"].(string); token != "" { + if urls, err := s.connect.SnapshotURLs(ctx, p.UUID); err == nil && urls[token] != "" { + if r, err := s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: urls[token]}); err == nil { + resp = r + } + } + } + if resp == nil { + id := connect.PathEscape(jsonID(cam["id"])) + query := url.Values{"printer_uuid": {p.UUID}} + resp, err = s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: "/app/cameras/" + id + "/snapshots/last", Query: query}) + if connect.IsStatus(err, http.StatusNotFound) { + // WebRTC cameras (Buddy3D) may never have pushed a full snapshot; + // the web app falls back to the thumbnail endpoint. + resp, err = s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: "/thumbnail/camera/" + id, Query: query}) + } + if err != nil { + return nil, err + } + } + defer resp.Body.Close() + img, mime, err := readImage(resp) + if err != nil { + return nil, err + } + note := fmt.Sprintf("Camera %q on %s.", fmt.Sprint(cam["name"]), p.Name) + if lm, err := http.ParseTime(resp.Header.Get("Last-Modified")); err == nil { + note += fmt.Sprintf(" Taken %s (%s ago).", lm.Local().Format(time.DateTime), time.Since(lm).Round(time.Second)) + } + return imageResult(img, mime, note), nil +} diff --git a/internal/server/tools_status.go b/internal/server/tools_status.go index 068f1b8..31921b4 100644 --- a/internal/server/tools_status.go +++ b/internal/server/tools_status.go @@ -8,6 +8,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/trevin-lee/prusactl/internal/auth" + "github.com/trevin-lee/prusactl/internal/camera" "github.com/trevin-lee/prusactl/internal/link" "github.com/trevin-lee/prusactl/internal/hint" @@ -45,7 +46,20 @@ func (s *Server) Status(ctx context.Context) map[string]any { if cloud["signed_in"] != true { cloud["setup"] = "optional, for remote access, camera, dialogs, queue and history: " + hint.Connect() } - return map[string]any{"direct": direct, "connect": cloud} + cam := map[string]any{"configured": false} + if s.cameraURL != nil { + if addr, err := s.cameraURL(); err == nil && addr != "" { + cam["configured"] = true + // Never the raw value: an environment variable with a typo in the + // scheme isn't an RTSP address, and the mask only knows those. + if valid, verr := camera.Validate(addr); verr != nil { + cam["error"] = verr.Error() + } else { + cam["rtsp"] = valid + } + } + } + return map[string]any{"direct": direct, "connect": cloud, "camera": cam} } // ConnectPrinters returns the account's printers as Prusa Connect lists them @@ -69,7 +83,8 @@ func (s *Server) addStatusTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "connection_status", Description: "How the printer can be reached right now: directly on the local network (PrusaLink) " + - "and/or through Prusa Connect, and which account is signed in. Says what to set up if neither works.", + "and/or through Prusa Connect, which account is signed in, and whether a local camera (RTSP) is saved. " + + "Says what to set up if neither route works.", Annotations: readOnly("Connection status"), }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { return jsonResult(s.Status(ctx)) diff --git a/internal/server/tools_test.go b/internal/server/tools_test.go index 21e8ac9..223e142 100644 --- a/internal/server/tools_test.go +++ b/internal/server/tools_test.go @@ -85,6 +85,13 @@ func (f *fakePrinter) called(prefix string) bool { // connectTools runs the real MCP server, direct route only, against fp and // returns a client session to call its tools with. func connectTools(t *testing.T, fp *fakePrinter) *mcp.ClientSession { + t.Helper() + return connectToolsWith(t, fp, nil) +} + +// connectToolsWith is connectTools with a chance to adjust the server before +// it starts, e.g. to give it a camera. +func connectToolsWith(t *testing.T, fp *fakePrinter, tweak func(*Server)) *mcp.ClientSession { t.Helper() srv := httptest.NewServer(fp) t.Cleanup(srv.Close) @@ -93,6 +100,11 @@ func connectTools(t *testing.T, fp *fakePrinter) *mcp.ClientSession { s := New(session, connect.New(session, "test"), lc, nil, "test") // Never read the real saved setup, which would point at a real printer. s.openLink = func() (*link.Client, error) { return lc, nil } + // Nor the saved camera. + s.cameraURL = func() (string, error) { return "", nil } + if tweak != nil { + tweak(s) + } ct, st := mcp.NewInMemoryTransports() ctx := context.Background() @@ -247,7 +259,7 @@ func TestViaIsRefusedWhenImpossible(t *testing.T) { // Absolute means something different on Windows, and the path is // checked before the route is. args: map[string]any{"path": "/usb/x.bgcode", "local_path": filepath.Join(t.TempDir(), "x.bgcode")}}, - {tool: "get_camera_snapshot", via: "direct", want: "only works through Prusa Connect"}, + {tool: "get_camera_snapshot", via: "direct", want: "no local camera"}, {tool: "get_queue", via: "direct", want: "only works through Prusa Connect"}, {tool: "respond_to_dialog", via: "direct", want: "only works through Prusa Connect", args: map[string]any{"button": "Yes"}}, {tool: "get_printer", via: "bogus", want: "via must be direct or connect"}, diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 4392cad..673434c 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -33,7 +33,8 @@ "env": { "PRUSACTL_HOST": "${user_config.printer_address}", "PRUSACTL_PASSWORD": "${user_config.printer_password}", - "PRUSACTL_MANAGED": "mcpb" + "PRUSACTL_MANAGED": "mcpb", + "PRUSACTL_CAMERA_URL": "${user_config.camera_url}" }, "platform_overrides": { "win32": { @@ -74,6 +75,12 @@ "description": "On the printer: Settings > Network > PrusaLink (username maker).", "sensitive": true, "required": false + }, + "camera_url": { + "type": "string", + "title": "Camera RTSP address", + "description": "The Buddy3D camera's stream, rtsp:///live, with no user or password. Needs ffmpeg installed where the app can find it. Leave empty to use Prusa Connect's snapshots.", + "required": false } }, "compatibility": {