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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 | |
Expand Down
71 changes: 71 additions & 0 deletions docs/operator-migration.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/auth/ntfy_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
41 changes: 40 additions & 1 deletion internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."`
Expand Down
52 changes: 50 additions & 2 deletions internal/cmd/camera.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"context"
"errors"
"fmt"
"path/filepath"
"time"
Expand All @@ -19,6 +21,7 @@ type CameraCmd struct {
// SnapshotCmd grabs one frame.
type SnapshotCmd struct {
Output string `short:"o" help:"Output .jpg (default ./<printer>-<time>.jpg)." type:"path"`
Light bool `help:"Turn the chamber light on for the frame, then restore its reported mode."`
}

// Run executes the command.
Expand All @@ -44,13 +47,58 @@ func (c *SnapshotCmd) Run(g *Globals) error {
out = fmt.Sprintf("%s-%s.jpg", p.Name, time.Now().Format("20060102-150405"))
}
out, _ = filepath.Abs(out)
if err := camera.Snapshot(g.Ctx, p.Host, code, out); err != nil {
light, err := capture(g, p.Host, code, out, c.Light, nil)
if err != nil {
return err
}
data := map[string]any{"printer": p.Name, "snapshot": out}
if light != nil {
data["light"] = light
}
return g.Out.Print(output.View{
Data: map[string]any{"printer": p.Name, "snapshot": out},
Data: data,
Human: func(h *output.Human) { h.Line("%s", out) },
Plain: [][]string{{"snapshot"}, {out}},
Quiet: out,
})
}

// capture shares the same light restoration path for camera and monitor frames.
func capture(g *Globals, host, code, out string, lit bool, conn printer.Conn) (light map[string]string, err error) {
if !lit {
return nil, camera.Snapshot(g.Ctx, host, code, out)
}
if conn == nil {
p, targetErr := g.Target()
if targetErr != nil {
return nil, targetErr
}
conn, err = g.Connect(p)
if err != nil {
return nil, err
}
defer conn.Close()
}
before, err := readLight(g.Ctx, conn)
if err != nil {
return nil, err
}
light = map[string]string{"node": chamberLight, "before": before, "during": "on", "after": before}
if before != "on" {
// Cleanup also runs if switching on failed after publishing, or capture was interrupted.
defer func() {
ctx, cancel := context.WithTimeout(context.WithoutCancel(g.Ctx), 15*time.Second)
defer cancel()
r, restoreErr := setLight(ctx, conn, before, 10*time.Second)
light["after"] = r.After
if restoreErr != nil {
err = errfmt.Wrap(errfmt.As(restoreErr).Code, errors.Join(err, restoreErr), "could not restore chamber light to %s", before).
WithData("light", light).WithData("snapshot", out).WithHint("check `bambu light status` and restore the light explicitly")
}
}()
if _, err = setLight(g.Ctx, conn, "on", 10*time.Second); err != nil {
return light, err
}
}
return light, camera.Snapshot(g.Ctx, host, code, out)
}
Loading
Loading