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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<camera-ip>/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
Expand Down
54 changes: 45 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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://<camera-ip>/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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
18 changes: 15 additions & 3 deletions cmd/prusactl/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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://<camera-ip>/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"}},
},
Expand Down Expand Up @@ -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"}},
},
Expand Down
92 changes: 88 additions & 4 deletions cmd/prusactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
Expand All @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading