Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ JSON field names and exit codes are part of the public API.
## [Unreleased]

### Added
- `preflight` and `print send` accept `--allow-low-filament` to turn an insufficient estimated amount into an explicit WARN when a mid-print runout and spool swap are acceptable. Works with `--strict`; all other gates and `--confirm` still apply. JSON records `preflight.allow_low_filament`.
- 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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Recipes name presets generically (`"0.20mm Standard"`, `"Bambu PLA Basic"`), and
- each slot has enough filament (unknown remaining is a WARN), and the temperatures are sane
- the printer's AI protections are on (WARN only) and FTPS login works
2. **`print send`** re-runs preflight and refuses on any FAIL. It requires `--confirm`, uploads over FTPS, reads the file back to verify its MD5, then starts the job with bed leveling, flow calibration, vibration compensation and first-layer inspection **on**.
Pass `--allow-low-filament` to `preflight` or `print send` only when you accept a runout and spool swap mid-print. It turns an insufficient estimated amount into an explicit `filament_amount` WARN, including with `--strict`. Empty slots and every other gate keep their existing behavior. The override applies to all mapped slots; unknown remaining stays a WARN. JSON records `preflight.allow_low_filament`.
3. **`print pause|resume|stop`** require `--confirm`. `resume` exits 0 only once the printer reports RUNNING. It refuses mid-filament-change, and with an empty toolhead on the X1 series.
4. **`filament load`** requires `--confirm` and works only while paused or idle. When paused, the toolhead must hold an AMS filament and the slot must hold the same material. It exits 0 only once the printer reports that slot in the toolhead.

Expand Down
13 changes: 10 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ Top-level verbs are kept for the high-frequency, single-purpose actions an opera
```
bambu status [--watch] # read-only; --watch streams NDJSON until Ctrl-C
bambu slice <model> --recipe R [--filament PRESET] [--set k=v]... [--name N] [--out DIR] [--auto-orient]
bambu preflight <file.gcode.3mf> --slot S [--slot S2]... [--no-ftp]
bambu print send <file.gcode.3mf> --slot S [--slot S2]... (--dry-run | --confirm) [--timelapse] [--wait 180s]
bambu preflight <file.gcode.3mf> --slot S [--slot S2]... [--no-ftp] [--strict] [--allow-low-filament]
bambu print send <file.gcode.3mf> --slot S [--slot S2]... (--dry-run | --confirm) [--timelapse] [--wait 180s] [--strict] [--allow-low-filament]
bambu print pause|stop --confirm
bambu print resume --confirm [--wait 30s] # exit 0 only once the printer reports RUNNING
bambu filament load --slot S --confirm [--timeout 5m] # paused or idle; exit 0 only once S is in the toolhead
Expand Down Expand Up @@ -235,6 +235,13 @@ publishing any new slice outputs. The JSON error includes the warning text and s
warnings remain in the successful slice summary. All slices reject unexpected tool changes and stale
output from a previous invocation.

`preflight` and `print send` accept `--allow-low-filament` (default false) when the operator accepts a runout and
spool swap mid-print. Only an insufficient known `filament_amount` changes from FAIL to WARN, preserving its
estimate and naming the override in its recovery text. It applies to every mapped filament independently of
`--strict`; unknown remaining and all other gates retain their existing behavior. `preflight.allow_low_filament`
records whether the flag was passed. A confirmed send emits the amount warning on stderr before uploading;
`--dry-run` still sends nothing. No configuration enables the override implicitly.

| gate | FAIL when | WARN when |
|---|---|---|
| printer_idle | gcode_state not IDLE/FINISH/FAILED | |
Expand All @@ -248,7 +255,7 @@ output from a previous invocation.
| | | nozzle_type: material differs |
| bed_type | 3MF plate ≠ configured plate | |
| tray | slot empty, or type ≠ file type | filament_id differs (e.g. GFA01 vs GFA00) |
| filament_amount | remain_g < used_g × 1.15 + 5 | remaining unknown |
| filament_amount | remain_g < used_g × 1.15 + 5, unless `--allow-low-filament` | remaining unknown, or insufficient with `--allow-low-filament` |
| nozzle_temp | outside filament range | |
| | tray to nozzle_temp run once per filament against its own slot; rows carry `"filament": N` on multi-filament jobs | |
| | | ai_protections: first_layer_inspector / spaghetti_detector / print_halt off |
Expand Down
172 changes: 172 additions & 0 deletions internal/cmd/low_filament_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package cmd

import (
"bytes"
"os"
"slices"
"strings"
"testing"

"github.com/voska/bambu/internal/errfmt"
"github.com/voska/bambu/internal/preflight"
"github.com/voska/bambu/internal/testutil"
)

func TestAllowLowFilament(t *testing.T) {
cases := []struct {
name string
remain int
allow bool
opts testutil.Opts
mut func(map[string]any)
failed string
amount string
}{
{name: "zero blocks by default", remain: 0, failed: "filament_amount", amount: preflight.Fail},
{name: "low blocks by default", remain: 1, failed: "filament_amount", amount: preflight.Fail},
{name: "zero overridden", remain: 0, allow: true, amount: preflight.Warn},
{name: "low overridden", remain: 1, allow: true, amount: preflight.Warn},
{name: "enough unchanged", remain: 33, allow: true, amount: preflight.Pass},
{name: "unknown unchanged", remain: -1, allow: true, amount: preflight.Warn},
{name: "empty still blocks", allow: true, mut: func(m map[string]any) { firstTray(m)["tray_type"] = "" }, failed: "tray"},
{name: "material still blocks", allow: true, opts: testutil.Opts{Filaments: []string{"PETG:GFG00:8"}}, failed: "filament_type", amount: preflight.Warn},
{name: "temperature still blocks", allow: true, opts: testutil.Opts{NozzleTemp: "300"}, failed: "nozzle_temp", amount: preflight.Warn},
{name: "integrity still blocks", allow: true, opts: testutil.Opts{CorruptMD5: true}, failed: "file_integrity", amount: preflight.Warn},
{name: "busy still blocks", allow: true, mut: func(m map[string]any) { m["gcode_state"] = "RUNNING" }, failed: "printer_idle", amount: preflight.Warn},
}
for _, c := range cases {
for _, command := range []string{"preflight", "send"} {
for _, strict := range []bool{false, true} {
t.Run(c.name+"/"+command+map[bool]string{false: "", true: "/strict"}[strict], func(t *testing.T) {
h := newHarness(t, func(m map[string]any) {
firstTray(m)["remain"] = c.remain
if c.mut != nil {
c.mut(m)
}
})
f := h.job(c.opts)
args := []string{"preflight", f, "--slot", "A1", "--json"}
if command == "send" {
args = []string{"print", "send", f, "--slot", "A1", "--dry-run", "--json"}
}
if c.allow {
args = append(args, "--allow-low-filament")
}
if strict {
args = append(args, "--strict")
}
want := 0
if c.failed != "" {
want = errfmt.ExitGate
}
if code := h.run(args...); code != want {
t.Fatalf("exit %d, want %d: %s %s", code, want, h.stdout.String(), h.stderr.String())
}
var r gatesData
h.json(&r)
if c.failed != "" && !slices.Contains(r.Preflight.Failed, c.failed) {
t.Fatalf("missing failure %s: %+v", c.failed, r.Preflight)
}
amount := ""
for _, g := range r.Preflight.Gates {
if g.Gate == "filament_amount" {
amount = g.Status
if c.allow && c.remain >= 0 && amount == preflight.Warn && (!strings.Contains(g.Fix, "--allow-low-filament") || !strings.Contains(g.Fix, "spool")) {
t.Fatalf("override must be explicit with a spool-swap warning: %+v", g)
}
}
}
if amount != c.amount {
t.Fatalf("amount %q, want %q", amount, c.amount)
}
if r.Preflight.AllowLowFilament != c.allow {
t.Fatal("preflight must record whether the override was requested")
}
if len(h.conn.Commands) != 0 {
t.Fatal("read-only command published")
}
if _, ok := h.ftp.File("/part.gcode.3mf"); ok {
t.Fatal("read-only command uploaded")
}
})
}
}
}
}

func firstTray(m map[string]any) map[string]any {
return m["ams"].(map[string]any)["ams"].([]any)[0].(map[string]any)["tray"].([]any)[0].(map[string]any)
}

func TestLowFilamentSendConfirmed(t *testing.T) {
h := newHarness(t, func(m map[string]any) { firstTray(m)["remain"] = 0 })
f := h.job(testutil.Opts{})
args := []string{"print", "send", f, "--slot", "A1", "--allow-low-filament", "--strict", "--json"}
if code := h.run(args...); code != errfmt.ExitGate || len(h.conn.Commands) != 0 {
t.Fatalf("override must still require --confirm: exit %d", code)
}
h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any {
f.Set("gcode_state", "PREPARE")
return map[string]any{"command": body["command"], "result": "SUCCESS"}
}
if code := h.run(append(args, "--confirm")...); code != 0 {
t.Fatalf("exit %d: %s %s", code, h.stdout.String(), h.stderr.String())
}
var r sendResult
h.json(&r)
if !r.Sent || r.State != "PREPARE" || !slices.Contains(r.Preflight.Warnings, "filament_amount") {
t.Fatalf("send not verified with amount warning: %+v", r)
}
if !strings.Contains(h.stderr.String(), "--allow-low-filament") || !strings.Contains(h.stderr.String(), "spool") {
t.Fatalf("missing visible override warning: %s", h.stderr.String())
}
local, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
remote, ok := h.ftp.File("/part.gcode.3mf")
if !ok || !bytes.Equal(local, remote) || len(h.conn.Commands) != 1 {
t.Fatal("confirmed send must upload intact file and publish once")
}
}

func TestAllowLowFilamentTwoSlots(t *testing.T) {
h := newHarness(t, func(m map[string]any) {
trays := m["ams"].(map[string]any)["ams"].([]any)[0].(map[string]any)["tray"].([]any)
trays[0].(map[string]any)["remain"] = 0
trays[2].(map[string]any)["remain"] = 0
})
f := h.job(testutil.Opts{Filaments: []string{"PLA:GFA01:8", "PLA:GFA00:8"}})
if code := h.run("preflight", f, "--slot", "A1", "--slot", "A3", "--allow-low-filament", "--strict", "--json"); code != 0 {
t.Fatalf("exit %d: %s %s", code, h.stdout.String(), h.stderr.String())
}
var r gatesData
h.json(&r)
var filaments []int
for _, g := range r.Preflight.Gates {
if g.Gate == "filament_amount" && g.Status == preflight.Warn {
filaments = append(filaments, g.Filament)
}
}
if !slices.Equal(filaments, []int{1, 2}) {
t.Fatalf("both filament amounts must warn with attribution: %+v", r.Preflight)
}
}

func TestLowFilamentSendQuietWarning(t *testing.T) {
h := newHarness(t, func(m map[string]any) { firstTray(m)["remain"] = 0 })
h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any {
f.Set("gcode_state", "PREPARE")
return map[string]any{"command": body["command"], "result": "SUCCESS"}
}
f := h.job(testutil.Opts{})
if code := h.run("print", "send", f, "--slot", "A1", "--allow-low-filament", "--confirm", "--quiet"); code != 0 {
t.Fatalf("exit %d: %s", code, h.stderr.String())
}
if strings.TrimSpace(h.stdout.String()) != "PREPARE" {
t.Fatalf("quiet stdout: %s", h.stdout.String())
}
if !strings.Contains(h.stderr.String(), "--allow-low-filament") || !strings.Contains(h.stderr.String(), "spool") {
t.Fatalf("quiet must not suppress the override warning: %s", h.stderr.String())
}
}
39 changes: 24 additions & 15 deletions internal/cmd/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@ import (

// PreflightCmd runs the safety gates.
type PreflightCmd struct {
File string `arg:"" help:"Sliced .gcode.3mf." type:"path"`
Slot []string `short:"s" required:"" sep:"none" help:"AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1)."`
NoFTP bool `name:"no-ftp" help:"Skip the FTPS login check."`
Strict bool `help:"Require exact filament material and valid temperature bounds (bambu-op operator gates)."`
File string `arg:"" help:"Sliced .gcode.3mf." type:"path"`
Slot []string `short:"s" required:"" sep:"none" help:"AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1)."`
NoFTP bool `name:"no-ftp" help:"Skip the FTPS login check."`
Strict bool `help:"Require exact filament material and valid temperature bounds (bambu-op operator gates)."`
AllowLowFilament bool `help:"Downgrade a filament_amount FAIL to WARN; the job may run out and pause for a spool swap. All other gates still apply."`
}

// Run executes the command.
func (c *PreflightCmd) Run(g *Globals) error {
pc, err := runPreflight(g, c.File, c.Slot, !c.NoFTP, c.Strict)
pc, err := runPreflight(g, c.File, c.Slot, !c.NoFTP, c.Strict, c.AllowLowFilament)
if err != nil {
return err
}
Expand All @@ -54,7 +55,7 @@ type preflightCtx struct {
code string
}

func runPreflight(g *Globals, file string, slots []string, checkFTP, strict bool) (*preflightCtx, error) {
func runPreflight(g *Globals, file string, slots []string, checkFTP, strict, allowLowFilament bool) (*preflightCtx, error) {
p, err := g.Target()
if err != nil {
return nil, err
Expand Down Expand Up @@ -88,7 +89,7 @@ func runPreflight(g *Globals, file string, slots []string, checkFTP, strict bool
conn.Close()
return nil, err
}
in := preflight.Input{Printer: p, Job: j, Status: printer.Summarize(raw), TrayIDs: trays, Strict: strict}
in := preflight.Input{Printer: p, Job: j, Status: printer.Summarize(raw), TrayIDs: trays, Strict: strict, AllowLowFilament: allowLowFilament}
if checkFTP {
in.FTPSCheck = func() error {
ctx, cancel := context.WithTimeout(g.Ctx, 30*time.Second)
Expand Down Expand Up @@ -148,13 +149,14 @@ type PrintCmd struct {

// SendCmd uploads and starts a job.
type SendCmd struct {
File string `arg:"" help:"Sliced .gcode.3mf." type:"path"`
Slot []string `short:"s" required:"" sep:"none" help:"AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1)."`
Confirm bool `help:"Required to actually print. Only pass it after a human approved this exact file and confirmed the plate is clear."`
DryRun bool `short:"n" name:"dry-run" help:"Run preflight and show the exact upload path and MQTT payload; send nothing."`
Timelapse bool `help:"Record a timelapse."`
Wait time.Duration `default:"180s" help:"How long to wait for the job to start."`
Strict bool `help:"Require exact filament material and valid temperature bounds (bambu-op operator gates)."`
File string `arg:"" help:"Sliced .gcode.3mf." type:"path"`
Slot []string `short:"s" required:"" sep:"none" help:"AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1)."`
Confirm bool `help:"Required to actually print. Only pass it after a human approved this exact file and confirmed the plate is clear."`
DryRun bool `short:"n" name:"dry-run" help:"Run preflight and show the exact upload path and MQTT payload; send nothing."`
Timelapse bool `help:"Record a timelapse."`
Wait time.Duration `default:"180s" help:"How long to wait for the job to start."`
Strict bool `help:"Require exact filament material and valid temperature bounds (bambu-op operator gates)."`
AllowLowFilament bool `help:"Downgrade a filament_amount FAIL to WARN; the job may run out and pause for a spool swap. All other gates still apply."`
}

type sendPlan struct {
Expand Down Expand Up @@ -190,7 +192,7 @@ func (c *SendCmd) Run(g *Globals) error {
return errfmt.New(errfmt.ExitGate, "refusing to print without --confirm").
WithHint("preview with --dry-run; pass --confirm only after a human approved this file and confirmed the plate is clear")
}
pc, err := runPreflight(g, c.File, c.Slot, true, c.Strict)
pc, err := runPreflight(g, c.File, c.Slot, true, c.Strict, c.AllowLowFilament)
if err != nil {
return err
}
Expand Down Expand Up @@ -233,6 +235,13 @@ func (c *SendCmd) Run(g *Globals) error {
return errfmt.New(errfmt.ExitGate, "preflight failed: %v", pc.result.Failed).WithHint("fix the failed gates (bambu preflight) and retry")
}

if c.AllowLowFilament {
for _, gate := range pc.result.Gates {
if gate.Gate == "filament_amount" && gate.Status == preflight.Warn {
g.Out.Warn("%s: %s; %s", gate.Gate, gate.Detail, gate.Fix)
}
}
}
g.Out.Hint("uploading %s to %s…", remote, pc.p.Name)
if err := upload(g.Ctx, pc, plan, g.FTPSPort); err != nil {
return err
Expand Down
Loading
Loading