diff --git a/CHANGELOG.md b/CHANGELOG.md index e66cb8e..c3cb5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 1df1ef6..2934cf5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 7eb776f..d58ea24 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 --recipe R [--filament PRESET] [--set k=v]... [--name N] [--out DIR] [--auto-orient] -bambu preflight --slot S [--slot S2]... [--no-ftp] -bambu print send --slot S [--slot S2]... (--dry-run | --confirm) [--timelapse] [--wait 180s] +bambu preflight --slot S [--slot S2]... [--no-ftp] [--strict] [--allow-low-filament] +bambu print send --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 @@ -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 | | @@ -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 | diff --git a/internal/cmd/low_filament_test.go b/internal/cmd/low_filament_test.go new file mode 100644 index 0000000..33b17c3 --- /dev/null +++ b/internal/cmd/low_filament_test.go @@ -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()) + } +} diff --git a/internal/cmd/print.go b/internal/cmd/print.go index e581cc0..3cbbc65 100644 --- a/internal/cmd/print.go +++ b/internal/cmd/print.go @@ -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 } @@ -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 @@ -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) @@ -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 { @@ -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 } @@ -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 diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index d165575..7348c2b 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -30,24 +30,26 @@ type Gate struct { // Result is the preflight outcome. Slot and TrayID are the first filament's. type Result struct { - Strict bool `json:"strict"` - Result string `json:"result"` - Failed []string `json:"failed"` - Warnings []string `json:"warnings"` - Slot string `json:"slot"` - TrayID int `json:"tray_id"` - Slots []string `json:"slots"` - TrayIDs []int `json:"tray_ids"` - Gates []Gate `json:"gates"` + Strict bool `json:"strict"` + AllowLowFilament bool `json:"allow_low_filament"` + Result string `json:"result"` + Failed []string `json:"failed"` + Warnings []string `json:"warnings"` + Slot string `json:"slot"` + TrayID int `json:"tray_id"` + Slots []string `json:"slots"` + TrayIDs []int `json:"tray_ids"` + Gates []Gate `json:"gates"` } // Input bundles everything the gates look at. type Input struct { - Printer *config.Printer - Job *job.Job - Status printer.Status - TrayIDs []int // the AMS tray feeding each job filament, in filament order - Strict bool // retain bambu-op's exact material and known temperature requirements + Printer *config.Printer + Job *job.Job + Status printer.Status + TrayIDs []int // the AMS tray feeding each job filament, in filament order + Strict bool // retain bambu-op's exact material and known temperature requirements + AllowLowFilament bool // accept a runout and spool swap when estimated remaining is insufficient // FTPSCheck, if set, performs a read-only FTPS login + listing and returns its error. FTPSCheck func() error } @@ -122,7 +124,7 @@ func Run(in Input) Result { if n >= len(in.TrayIDs) { break } - fg := filamentGates(s, need, in.TrayIDs[n], in.Strict) + fg := filamentGates(s, need, in.TrayIDs[n], in.Strict, in.AllowLowFilament) if len(needs) > 1 { for i := range fg { fg[i].Filament = n + 1 @@ -144,7 +146,7 @@ func Run(in Input) Result { g.add("ftps_login", err == nil, Fail, detail, "check the access code (bambu auth status --check) and Developer Mode") } - r := Result{Strict: in.Strict, Result: Pass, Gates: g, Failed: []string{}, Warnings: []string{}, TrayIDs: in.TrayIDs, Slots: []string{}} + r := Result{Strict: in.Strict, AllowLowFilament: in.AllowLowFilament, Result: Pass, Gates: g, Failed: []string{}, Warnings: []string{}, TrayIDs: in.TrayIDs, Slots: []string{}} for _, t := range in.TrayIDs { r.Slots = append(r.Slots, printer.TrayLabel(t)) } @@ -164,7 +166,7 @@ func Run(in Input) Result { } // filamentGates checks one job filament against the AMS tray mapped to it. -func filamentGates(s printer.Status, need job.Filament, trayID int, strict bool) gates { +func filamentGates(s printer.Status, need job.Filament, trayID int, strict, allowLowFilament bool) gates { var g gates label := printer.TrayLabel(trayID) var tray *printer.Tray @@ -205,8 +207,12 @@ func filamentGates(s printer.Status, need job.Filament, trayID int, strict bool) "confirm there is enough filament on the spool") } else { ok := float64(*tray.RemainG) >= need.UsedG*1.15+5 - g.add("filament_amount", ok, Fail, fmt.Sprintf("slot %s ~%d g (%d%%); job needs %.1f g (+15%%, +5 g)", label, *tray.RemainG, *tray.RemainPct, need.UsedG), - "load a fuller spool into slot "+label) + amountStatus, fix := Fail, "load a fuller spool into slot "+label + if !ok && allowLowFilament { + amountStatus = Warn + fix = "allowed by --allow-low-filament: the job may run out and pause; be ready to swap the spool in slot " + label + } + g.add("filament_amount", ok, amountStatus, fmt.Sprintf("slot %s ~%d g (%d%%); job needs %.1f g (+15%%, +5 g)", label, *tray.RemainG, *tray.RemainPct, need.UsedG), fix) } var rng [2]string copy(rng[:], need.NozzleTempRange)