From 46be9b19a2c3efce6f6a06b8392a4cbbcb24983d Mon Sep 17 00:00:00 2001 From: Matt Voska Date: Fri, 9 Oct 2026 07:45:50 -0300 Subject: [PATCH] feat: complete bambu-op parity for Print Operator cutover --- CHANGELOG.md | 8 + README.md | 2 + SPEC.md | 9 +- docs/operator-migration.md | 71 ++++++++ internal/auth/auth.go | 30 ++++ internal/auth/ntfy_test.go | 30 ++++ internal/cmd/auth.go | 41 ++++- internal/cmd/camera.go | 52 +++++- internal/cmd/light.go | 117 +++++++++++++ internal/cmd/monitor.go | 138 ++++++++++++++-- internal/cmd/parity_test.go | 236 +++++++++++++++++++++++++++ internal/cmd/print.go | 55 ++++--- internal/cmd/root.go | 1 + internal/cmd/slice.go | 16 +- internal/cmd/status.go | 6 + internal/config/config.go | 12 +- internal/notify/notify.go | 71 ++++++++ internal/notify/notify_test.go | 52 ++++++ internal/preflight/preflight.go | 28 +++- internal/preflight/preflight_test.go | 14 ++ internal/printer/status.go | 39 +++-- internal/slicer/parity_test.go | 144 ++++++++++++++++ internal/slicer/slice.go | 89 +++++++--- internal/slicer/slicer_test.go | 5 +- internal/slicer/step.go | 41 +++++ 25 files changed, 1205 insertions(+), 102 deletions(-) create mode 100644 docs/operator-migration.md create mode 100644 internal/auth/ntfy_test.go create mode 100644 internal/cmd/light.go create mode 100644 internal/cmd/parity_test.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go create mode 100644 internal/slicer/parity_test.go create mode 100644 internal/slicer/step.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da0cff..000388a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ JSON field names and exit codes are part of the public API. ## [Unreleased] ### Added +- Operator parity with `bambu-op`: verified `light status|on|off`, chamber-light telemetry, and `--light` snapshots that restore the previous light mode even on failure or interruption. +- Monitor `--notify` with optional `[ntfy]` configuration and keychain/stdin token storage (`auth set --ntfy`), `--watch` for continuous monitoring, configurable `--interval`/`--pushall-every`, and full final status/snapshot/exit-code data. +- STEP/STP slicing through a CadQuery interpreter (`[slicer] step_python`), object footprints including brim, and `slice --slot` suggested-slot metadata. +- Opt-in `--strict` preflight/send gates for exact filament material and valid known temperature bounds; `slice --strict` requires a preview. The JSON preflight result records which gate mode ran. + - Two-filament jobs with an automatic AMS colour change at a layer height: - `slice --filament P1 --color C1 --filament P2 --color C2 --filament-change-z Z` (`--filament` and `--color` repeat once per filament; `--colour` is an alias). Filament 2 starts on the first layer above Z, which must be a layer top. `slice` reads the change back from the G-code and exits 11 without writing to the output dir if Bambu Studio didn't place it. - `preflight`/`print send` take one `--slot` per filament, in filament order. Each filament's type, profile, amount and temperature gates run against its own slot. `ams_mapping` maps each filament to its slot (e.g. `[3, 0]`). The same slot given twice exits 2. @@ -21,6 +26,9 @@ JSON field names and exit codes are part of the public API. - `preflight` `single_filament` now fails only when the number of `--slot`s differs from the number of filaments in the plate. Single-filament jobs send the same payload as before. ### Fixed +- `print pause` and `print stop` now verify PAUSE or an idle state before exiting 0; `--wait` defaults to 30s and must be positive. +- A failed retry cannot reuse a prior successful slice. Every slice verifies its requested filament changes, including an empty change list. +- Continuous monitoring preserves FAILED/PAUSE outcome codes on timeout and reports final state on interruption. Footprints correspond to plate 1, matching the inspected job and preview. - `print resume --confirm` exited 0 even when the printer was still paused 3 s later. It now exits 0 only once the printer reports RUNNING (`--wait`, default 30s, must be positive; otherwise exit 14). It also refuses (exit 9) during a filament change, and on X1-series printers when the AMS reports an empty toolhead. - Per-filament temperatures in multi-filament 3MFs are read through `filament_self_index`/`filament_extruder_variant` instead of plain indexing, which reads the wrong row whenever an earlier filament has more than one nozzle-variant row. diff --git a/README.md b/README.md index 3070ee8..1df1ef6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ # bambu +Migrating the Python Print Operator? See the [command and configuration guide](docs/operator-migration.md). + Slice, check, send and monitor **Bambu Lab** prints from the terminal over your LAN. It's built for humans and for AI agents. - **Headless slicing:** it drives the Bambu Studio CLI with the correct system presets for your printer and nozzle, plus recipes and `--set` overrides. diff --git a/SPEC.md b/SPEC.md index 6862926..f9b245f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -24,9 +24,8 @@ In v0.1: Out of scope for v0.1: - Cloud API and Bambu Connect. - Per-object or painted multi-colour slicing: `slice` makes multi-filament jobs only from layer-height changes. -- STEP input (convert to STL/3MF first). - The P1/A1 camera, which uses the port-6000 JPEG protocol: a clear error for now. -- Changing any printer setting. `bambu` never changes printer settings: no xcam/AI toggles, no lights, no temperatures. +- Changing persistent printer settings: no xcam/AI toggles, network configuration, or temperature controls. The chamber light is an explicit, verified control. - OrcaSlicer (see §8). ## 2. Settled decisions @@ -228,6 +227,12 @@ All recipes use `brim_type = no_brim`. **Brims are opt-in** (`--set brim_type=ou ## 10. Preflight gates (FAIL blocks send; WARN informs) +`preflight` and `print send` accept `--strict`, retaining the Python operator's gates while keeping the existing Go +default: material variants must match exactly, temperature bounds must both be finite and valid, and unknown nozzle +material produces a WARN rather than an assumed match. `preflight.strict` records the selected mode. `slice --strict` +requires an extracted plate preview before publishing the new 3MF. All slices reject unexpected tool changes and stale +output from a previous invocation. + | gate | FAIL when | WARN when | |---|---|---| | printer_idle | gcode_state not IDLE/FINISH/FAILED | | diff --git a/docs/operator-migration.md b/docs/operator-migration.md new file mode 100644 index 0000000..09e270f --- /dev/null +++ b/docs/operator-migration.md @@ -0,0 +1,71 @@ +## Moving from bambu-op to bambu + +`bambu` covers the Python operator's printer controls, slicing and monitoring. The Python source can be retained for +rollback; new printer features belong in this repository. CAD dependencies and models remain separate from the CLI. + +| Python command | Go command | +|---|---| +| `status --json` / `status --raw --json` | Same | +| `recipes` | `recipe list` | +| `slice MODEL --recipe R` | `slice MODEL --recipe R --strict` | +| `slice --slot A4` | Same suggested-slot metadata; send still requires `--slot` | +| `snapshot --light -o image.jpg` | `camera snapshot --light --output image.jpg` | +| `light status`, `light on`, `light off` | Same; on/off must be verified by telemetry | +| `preflight FILE --slot A4` | `preflight FILE --slot A4 --strict` | +| `send FILE --slot A4 --dry-run` | `print send FILE --slot A4 --strict --dry-run` | +| `send … --approved` | `print send … --strict --confirm` | +| `pause`, `resume --yes`, `stop --yes` | `print pause/resume/stop --confirm` | +| `filament load --slot A1 --yes` | `filament load --slot A1 --confirm` | +| `monitor --until-done --snapshot-at-layer 2 --light -o DIR` | `monitor --snapshot-at-layer 2 --light --snapshot-dir DIR` | +| `monitor` without `--until-done` | `monitor --watch` | +| `monitor --timeout 45` (minutes) | `monitor --timeout 45m` | +| `monitor --interval 10 --pushall-every 300` (seconds) | `monitor --interval 10s --pushall-every 5m` | +| `monitor --notify` | Same, with optional `[ntfy]` configuration | +| `auth set` | `auth set PRINTER` (stdin) | +| `auth set --ntfy` | Same (stdin, no printer argument) | + +The repeated `--filament`, `--color` (`--colour` alias), `--filament-change-z`, and send/preflight `--slot` flags retain +filament order. A two-colour height-change slice embeds the AMS switch in G-code; remote filament loading is the +separate control for an approved paused job. The stricter Go guards against empty/unknown paused toolhead material +remain in place. A multi-filament `slice --slot` records one suggested slot; actual mapping is always passed to send. + +Use `--strict` for operator slice/preflight/send. Default Go gate behavior stays compatible, including same-family +material warnings and omitted temperature bounds. Strict mode fails unequal material types and unknown or malformed +temperature bounds. A different product/profile remains a WARN in both tools. The strict slice requires a preview. + +Go's slice JSON is the summary directly, without Python's `slice` wrapper. It includes +`objects[].footprint_incl_brim_mm`, the first plate's dimensions, and `suggested_slot`. `status` keeps a `status` wrapper +and adds its `lights` map. Access-code source is available through `auth status`. + +Monitor emits NDJSON with `event`. The final event adds the complete `final` status, the last saved `snapshot`, and +`exit_code`. Interruption emits `interrupted: true`; it retains the existing successful interruption exit rather than +claiming the print finished. Continuous watch reports terminal transitions and continues; its deadline still returns +the current print outcome. Exit 12 means FAILED, 13 PAUSE, and 14 an active-job timeout. Slice failures use 11, gates 9, +usage 2. Existing `--exec` error hooks retain the blocking-error transition trigger; ntfy can notify new fault codes. +Notification failures are warnings and message events; they never change a print's outcome. Missing ntfy configuration +warns and disables notifications, matching the Python tool. + +## Configuration and secrets + +Go uses `~/.config/bambu/config.toml`, with named `[printers.NAME]` entries. Preserve the printer host, serial, installed +plate, output directory, and any custom Bambu Studio binary path when migrating the Python `[printer]` configuration. +Back up the old configuration first. Access codes use Keychain service `bambu`, account printer serial; ntfy tokens use +service `bambu-ntfy`, account `token`, or `BAMBU_NTFY_TOKEN`. Never put either token in configuration, output, or argv. + +```toml +output_dir = "~/prints/out" + +[slicer] +step_python = "/path/to/cadquery/venv/bin/python" + +[ntfy] +url = "https://ntfy.example.com" +topic = "printer" +``` + +STEP/STP conversion uses the same CadQuery tolerances as Python: linear 0.01 mm and angular 0.1. The interpreter is +optional for STL/OBJ/AMF/3MF input; STEP requires CadQuery. A converter or slicer failure cannot reuse old output. + +The migration changes tooling, not print approval. Starting a print, loading filament, pausing, resuming, and stopping +remain gated by the operator's existing approval policy. A dry run uploads and publishes nothing. Real print outcomes +and filament changes still need hardware verification on an approved job. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b02ab1e..3c32606 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,6 +18,36 @@ import ( // Service is the keychain service name; the account is the printer serial. const Service = "bambu" +// NtfyService stores the optional notification token (account "token"). +const NtfyService = "bambu-ntfy" + +// NtfyToken resolves the optional token without exposing its value. +func NtfyToken(kr Keyring) string { + if token := strings.TrimSpace(os.Getenv("BAMBU_NTFY_TOKEN")); token != "" { + return token + } + if kr != nil { + token, _ := kr.Get(NtfyService, "token") + return token + } + return "" +} + +// StoreNtfyToken stores a token; it never enters config, output or argv. +func StoreNtfyToken(kr Keyring, token string) error { + if token == "" || strings.ContainsAny(token, "\r\n\x00") { + return errfmt.New(errfmt.ExitUsage, "invalid ntfy token") + } + if err := kr.Set(NtfyService, "token", token); err != nil { + return errfmt.Wrap(errfmt.ExitConfig, err, "store ntfy token in OS keychain") + } + stored, err := kr.Get(NtfyService, "token") + if err != nil || stored != token { + return errfmt.New(errfmt.ExitConfig, "ntfy token storage could not be verified") + } + return nil +} + // Sources of an access code, in resolution order. const ( SourceKeychain = "keychain" diff --git a/internal/auth/ntfy_test.go b/internal/auth/ntfy_test.go new file mode 100644 index 0000000..5e1177c --- /dev/null +++ b/internal/auth/ntfy_test.go @@ -0,0 +1,30 @@ +package auth + +import ( + "testing" +) + +type droppingKeyring struct{ memKeyring } + +func (k droppingKeyring) Set(string, string, string) error { return nil } + +func TestNtfyTokenStorageVerified(t *testing.T) { + if err := StoreNtfyToken(droppingKeyring{memKeyring{}}, "TEST_TOKEN"); err == nil { + t.Fatal("token was not persisted but storage claimed success") + } +} + +func TestNtfyTokenResolution(t *testing.T) { + kr := memKeyring{} + t.Setenv("BAMBU_NTFY_TOKEN", "") + if err := StoreNtfyToken(kr, "TEST_TOKEN"); err != nil { + t.Fatal(err) + } + if NtfyToken(kr) != "TEST_TOKEN" { + t.Fatal("keychain token not resolved") + } + t.Setenv("BAMBU_NTFY_TOKEN", "ENV_TOKEN") + if NtfyToken(kr) != "ENV_TOKEN" { + t.Fatal("environment must take precedence") + } +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index e93763e..6abbf36 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -23,12 +23,19 @@ type AuthCmd struct { // AuthSetCmd stores an access code. type AuthSetCmd struct { - Name string `arg:"" help:"Printer name."` + Name string `arg:"" optional:"" help:"Printer name (required unless --ntfy)."` NoCheck bool `name:"no-check" help:"Don't verify the code against the printer."` + Ntfy bool `help:"Store an ntfy token instead of a printer access code (no printer argument)."` } // Run executes the command. func (c *AuthSetCmd) Run(g *Globals) error { + if c.Ntfy { + return c.storeNtfy(g) + } + if c.Name == "" { + return errfmt.New(errfmt.ExitUsage, "auth set needs a printer name, or --ntfy") + } cfg, err := g.ConfigFile() if err != nil { return err @@ -89,6 +96,38 @@ func (c *AuthSetCmd) Run(g *Globals) error { }) } +func (c *AuthSetCmd) storeNtfy(g *Globals) error { + if c.Name != "" || c.NoCheck { + return errfmt.New(errfmt.ExitUsage, "--ntfy cannot be combined with a printer name or --no-check") + } + var token string + if term.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // fd fits in int + if g.CLI.NoInput { + return errfmt.New(errfmt.ExitUsage, "no ntfy token on stdin and --no-input set") + } + fmt.Fprint(os.Stderr, "ntfy token: ") + b, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // fd fits in int + fmt.Fprintln(os.Stderr) + if err != nil { + return errfmt.New(errfmt.ExitUsage, "read ntfy token failed") + } + token = string(b) + } else { + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return errfmt.New(errfmt.ExitUsage, "no ntfy token on stdin") + } + token = line + } + if err := auth.StoreNtfyToken(g.Keyring, strings.TrimSpace(token)); err != nil { + return err + } + return g.Out.Print(output.View{ + Data: map[string]any{"stored": true, "keychain_service": auth.NtfyService, "keychain_account": "token"}, + Human: func(h *output.Human) { h.Line("stored ntfy token in the OS keychain") }, Plain: [][]string{{"stored"}, {"true"}}, Quiet: "ok", + }) +} + // AuthStatusCmd reports access-code sources. type AuthStatusCmd struct { Check bool `help:"Connect to each printer (read-only) to verify the code."` diff --git a/internal/cmd/camera.go b/internal/cmd/camera.go index 2c34df9..151dd37 100644 --- a/internal/cmd/camera.go +++ b/internal/cmd/camera.go @@ -1,6 +1,8 @@ package cmd import ( + "context" + "errors" "fmt" "path/filepath" "time" @@ -19,6 +21,7 @@ type CameraCmd struct { // SnapshotCmd grabs one frame. type SnapshotCmd struct { Output string `short:"o" help:"Output .jpg (default ./-