diff --git a/CHANGELOG.md b/CHANGELOG.md index e66cb8e..038d4cf 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 +- Repeatable `--filament-remaining A2=50g` on `preflight` and `print send` for a checked spool when AMS remaining telemetry is wrong. Sufficient estimates WARN with the stated amount and original AMS reading; insufficient estimates still FAIL against the existing 15% + 5 g reserve, including with `--strict`. Estimates apply only to the current invocation. JSON amount gates include an optional `operator_estimate` with both sources and the required grams. - 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..fd4eeb3 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,14 @@ Recipes name presets generically (`"0.20mm Standard"`, `"Bambu PLA Basic"`), and `bambu` has no command that changes printer settings. +If the AMS remaining estimate is wrong, check the spool and pass +`--filament-remaining A2=50g` to `preflight` or `print send`. Repeat the flag for +each checked slot. The estimate applies only to that invocation and must cover the +job's filament use plus 15% and 5 g. A sufficient estimate reports `filament_amount` +as WARN with both the stated amount and AMS reading, including with `--strict`. +An insufficient estimate stays FAIL. The slot must still be loaded with the right +material, and all other gates and `--confirm` still apply. + ### Exit codes `bambu exit-codes` diff --git a/SPEC.md b/SPEC.md index 7eb776f..996d091 100644 --- a/SPEC.md +++ b/SPEC.md @@ -235,6 +235,17 @@ 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. +Both commands accept repeatable `--filament-remaining SLOT=AMOUNTg`, e.g. `A2=50g`, +for a spool checked by the operator when AMS telemetry is inaccurate. Amounts must +be finite nonnegative decimal grams with the `g` suffix, and slots must be mapped +by `--slot`; duplicate slots (including aliases) are usage errors (exit 2), checked +before connecting. The estimate applies only to the current invocation. It must +cover `used_g * 1.15 + 5`; insufficient estimates FAIL, sufficient estimates WARN +even with `--strict`. Other gates are unchanged. The amount gate's optional +`operator_estimate` JSON object records `stated_g`, `ams_remain_g`, +`ams_remain_pct`, and `required_g`; unknown AMS values remain null. Human detail +shows both sources, and confirmed sends also warn on stderr before uploading. + | gate | FAIL when | WARN when | |---|---|---| | printer_idle | gcode_state not IDLE/FINISH/FAILED | | diff --git a/internal/cmd/filament_remaining_test.go b/internal/cmd/filament_remaining_test.go new file mode 100644 index 0000000..93e26a2 --- /dev/null +++ b/internal/cmd/filament_remaining_test.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "slices" + "strings" + "testing" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/preflight" + "github.com/voska/bambu/internal/testutil" +) + +func zeroRemaining(m map[string]any) { + ams := m["ams"].(map[string]any)["ams"].([]any)[0].(map[string]any) + tray := ams["tray"].([]any)[0].(map[string]any) + tray["remain"] = float64(0) +} + +func TestFilamentRemainingEstimate(t *testing.T) { + for _, command := range [][]string{{"preflight"}, {"print", "send"}} { + t.Run(strings.Join(command, "_"), func(t *testing.T) { + h := newHarness(t, zeroRemaining) + f := h.job(testutil.Opts{Filaments: []string{"PLA:GFA01:12.3"}}) + args := append(slices.Clone(command), f, "--slot", "A1", "--strict", "--json") + if len(command) > 1 { + args = append(args, "--dry-run") + } + if code := h.run(args...); code != errfmt.ExitGate { + t.Fatalf("zero AMS reading must fail: %d %s", code, h.stdout.String()) + } + if code := h.run(append(args, "--filament-remaining", "1=50g")...); code != 0 { + t.Fatalf("checked amount must allow strict preflight: %d %s %s", code, h.stdout.String(), h.stderr.String()) + } + var r struct{ Preflight preflight.Result } + h.json(&r) + if r.Preflight.Result != preflight.Pass || !r.Preflight.Strict || !slices.Contains(r.Preflight.Warnings, "filament_amount") { + t.Fatalf("estimate must remain visible as WARN: %+v", r.Preflight) + } + for _, gate := range r.Preflight.Gates { + if gate.Gate == "filament_amount" && (gate.Status != preflight.Warn || !strings.Contains(gate.Detail, "operator estimate 50 g") || !strings.Contains(gate.Detail, "AMS ~0 g (0%)")) { + t.Fatalf("missing estimate/telemetry provenance: %+v", gate) + } + if gate.Gate == "filament_amount" && (gate.OperatorEstimate == nil || gate.OperatorEstimate.StatedG != 50 || gate.OperatorEstimate.AMSRemainG == nil || *gate.OperatorEstimate.AMSRemainG != 0 || gate.OperatorEstimate.RequiredG < 19.14 || gate.OperatorEstimate.RequiredG > 19.15) { + t.Fatalf("missing structured amount evidence: %+v", gate) + } + } + // Estimates apply to this invocation only. + if code := h.run(args...); code != errfmt.ExitGate { + t.Fatalf("estimate leaked into next invocation: %d", code) + } + if len(h.conn.Commands) != 0 { + t.Fatal("read-only check published a command") + } + if _, ok := h.ftp.File("/part.gcode.3mf"); ok { + t.Fatal("read-only check uploaded a file") + } + }) + } +} + +func TestFilamentRemainingInsufficientAndOtherGates(t *testing.T) { + for _, tc := range []struct { + name, amount, state, material, failed string + }{ + {"insufficient reserve", "19g", "FINISH", "PLA", "filament_amount"}, + {"zero", "0g", "FINISH", "PLA", "filament_amount"}, + {"busy", "50g", "RUNNING", "PLA", "printer_idle"}, + {"wrong material", "50g", "FINISH", "PETG", "filament_type"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, func(m map[string]any) { zeroRemaining(m); m["gcode_state"] = tc.state }) + f := h.job(testutil.Opts{Filaments: []string{tc.material + ":GFA01:12.3"}}) + if code := h.run("print", "send", f, "--slot", "A1", "--strict", "--confirm", "--filament-remaining", "A1="+tc.amount, "--json"); code != errfmt.ExitGate { + t.Fatalf("want gate failure, got %d: %s %s", code, h.stdout.String(), h.stderr.String()) + } + if !strings.Contains(h.stdout.String(), tc.failed) || len(h.conn.Commands) != 0 { + t.Fatalf("gate %s must still block: %s", tc.failed, h.stdout.String()) + } + if _, ok := h.ftp.File("/part.gcode.3mf"); ok { + t.Fatal("failed gate allowed upload") + } + }) + } +} + +func TestFilamentRemainingValidation(t *testing.T) { + for _, values := range [][]string{ + {"A1=50"}, + {"A1=50kg"}, + {"A1=50%"}, + {"A1=-1g"}, + {"A1=NaNg"}, + {"A1=Infg"}, + {"A1=1e999g"}, + {"A1=" + strings.Repeat("9", 400) + "g"}, + {"A1=g"}, + {"A1=50g=60g"}, + {"=50g"}, + {"E1=50g"}, + {"A2=50g"}, + {"A1=50g", "1=60g"}, + } { + t.Run(strings.Join(values, ","), func(t *testing.T) { + h := newHarness(t, nil) + f := h.job(testutil.Opts{}) + args := []string{"preflight", f, "--slot", "A1", "--json"} + for _, value := range values { + args = append(args, "--filament-remaining", value) + } + if code := h.run(args...); code != errfmt.ExitUsage { + t.Fatalf("invalid estimate %v: exit %d %s %s", values, code, h.stdout.String(), h.stderr.String()) + } + }) + } +} + +func TestFilamentRemainingMultipleSlots(t *testing.T) { + h := newHarness(t, zeroRemaining) + f := h.job(testutil.Opts{Filaments: []string{"PLA:GFA01:12.3", "PLA:GFL99:12.3"}}) + args := []string{"print", "send", f, "--slot", "A4", "--slot", "A1", "--dry-run", "--strict", "--json", "--filament-remaining", "A4=30.5g"} + if code := h.run(args...); code != errfmt.ExitGate { + t.Fatalf("unoverridden zero slot must still fail: %d", code) + } + if code := h.run(append(args, "--filament-remaining", "A1=50g")...); code != 0 { + t.Fatalf("both amounts should allow dry run: %d %s %s", code, h.stdout.String(), h.stderr.String()) + } + var r sendResult + h.json(&r) + amounts := map[int]float64{1: 30.5, 2: 50} + for _, gate := range r.Preflight.Gates { + if gate.Gate == "filament_amount" { + if gate.Status != preflight.Warn || gate.OperatorEstimate == nil || gate.OperatorEstimate.StatedG != amounts[gate.Filament] { + t.Fatalf("estimate must apply to its own filament: %+v", gate) + } + delete(amounts, gate.Filament) + } + } + if len(amounts) != 0 { + t.Fatalf("missing amount gates: %v", amounts) + } +} + +func TestFilamentRemainingConfirmedSend(t *testing.T) { + h := newHarness(t, zeroRemaining) + f := h.job(testutil.Opts{Filaments: []string{"PLA:GFA01:12.3"}}) + args := []string{"print", "send", f, "--slot", "A1", "--strict", "--filament-remaining", "A1=50g"} + if code := h.run(args...); code != errfmt.ExitGate || len(h.conn.Commands) != 0 { + t.Fatalf("estimate must not replace confirmation: %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("confirmed send: %d %s %s", code, h.stdout.String(), h.stderr.String()) + } + if len(h.conn.Commands) != 1 { + t.Fatalf("expected one start: %v", h.conn.Commands) + } + if _, ok := h.ftp.File("/part.gcode.3mf"); !ok { + t.Fatal("confirmed send did not upload") + } + if !strings.Contains(h.stderr.String(), "operator estimate 50 g") || !strings.Contains(h.stderr.String(), "AMS ~0 g (0%)") { + t.Fatalf("confirmed send must show estimate warning: %s", h.stderr.String()) + } +} diff --git a/internal/cmd/print.go b/internal/cmd/print.go index e581cc0..0261226 100644 --- a/internal/cmd/print.go +++ b/internal/cmd/print.go @@ -7,8 +7,11 @@ import ( "errors" "fmt" "io" + "math" "os" + "regexp" "slices" + "strconv" "strings" "time" @@ -23,15 +26,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)."` + FilamentRemaining []string `sep:"none" placeholder:"SLOT=Ng" help:"Grams left on a spool you checked, e.g. A2=50g, for this invocation only. Repeat per slot; sufficient estimates WARN (also with --strict), insufficient estimates FAIL."` } // 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.FilamentRemaining, !c.NoFTP, c.Strict) if err != nil { return err } @@ -54,11 +58,7 @@ type preflightCtx struct { code string } -func runPreflight(g *Globals, file string, slots []string, checkFTP, strict bool) (*preflightCtx, error) { - p, err := g.Target() - if err != nil { - return nil, err - } +func runPreflight(g *Globals, file string, slots, estimates []string, checkFTP, strict bool) (*preflightCtx, error) { trays := make([]int, 0, len(slots)) for _, slot := range slots { tray, err := printer.ParseSlot(slot) @@ -71,6 +71,14 @@ func runPreflight(g *Globals, file string, slots []string, checkFTP, strict bool } trays = append(trays, tray) } + remaining, err := parseFilamentRemaining(estimates, trays) + if err != nil { + return nil, err + } + p, err := g.Target() + if err != nil { + return nil, err + } j, err := job.Inspect(file, 1) if err != nil { return nil, err @@ -88,7 +96,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, Remaining: remaining} if checkFTP { in.FTPSCheck = func() error { ctx, cancel := context.WithTimeout(g.Ctx, 30*time.Second) @@ -105,6 +113,38 @@ func runPreflight(g *Globals, file string, slots []string, checkFTP, strict bool return &preflightCtx{p: p, job: j, status: in.Status, result: preflight.Run(in), conn: conn, code: code}, nil } +var gramsPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?g$`) + +func parseFilamentRemaining(values []string, trays []int) (map[int]float64, error) { + remaining := make(map[int]float64, len(values)) + for _, value := range values { + slot, amount, ok := strings.Cut(value, "=") + if !ok || !gramsPattern.MatchString(amount) { + return nil, errfmt.New(errfmt.ExitUsage, "bad --filament-remaining %q", value). + WithHint("state nonnegative grams, e.g. --filament-remaining A2=50g") + } + tray, err := printer.ParseSlot(slot) + if err != nil { + return nil, errfmt.Wrap(errfmt.ExitUsage, err, "bad --filament-remaining slot") + } + if !slices.Contains(trays, tray) { + return nil, errfmt.New(errfmt.ExitUsage, "--filament-remaining slot %s is not mapped by --slot", printer.TrayLabel(tray)). + WithHint("add the matching --slot, or drop this estimate") + } + if _, exists := remaining[tray]; exists { + return nil, errfmt.New(errfmt.ExitUsage, "--filament-remaining slot %s given more than once", printer.TrayLabel(tray)). + WithHint("pass one --filament-remaining per slot") + } + grams, err := strconv.ParseFloat(strings.TrimSuffix(amount, "g"), 64) + if err != nil || math.IsInf(grams, 0) || math.IsNaN(grams) { + return nil, errfmt.New(errfmt.ExitUsage, "bad --filament-remaining amount %q", amount). + WithHint("state finite nonnegative grams, e.g. A2=50g") + } + remaining[tray] = grams + } + return remaining, nil +} + type gatesData struct { Printer string `json:"printer"` Preflight preflight.Result `json:"preflight"` @@ -148,13 +188,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)."` + FilamentRemaining []string `sep:"none" placeholder:"SLOT=Ng" help:"Grams left on a spool you checked, e.g. A2=50g, for this invocation only. Repeat per slot; sufficient estimates WARN (also with --strict), insufficient estimates FAIL."` } type sendPlan struct { @@ -190,7 +231,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, c.FilamentRemaining, true, c.Strict) if err != nil { return err } @@ -232,6 +273,11 @@ func (c *SendCmd) Run(g *Globals) error { _ = g.Out.Print(gatesView(pc)) return errfmt.New(errfmt.ExitGate, "preflight failed: %v", pc.result.Failed).WithHint("fix the failed gates (bambu preflight) and retry") } + for _, gt := range pc.result.Gates { + if gt.OperatorEstimate != nil { + g.Out.Hint("WARN filament_amount: %s", gt.Detail) + } + } g.Out.Hint("uploading %s to %s…", remote, pc.p.Name) if err := upload(g.Ctx, pc, plan, g.FTPSPort); err != nil { diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index d165575..d89f0fc 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -21,11 +21,20 @@ const ( // Gate is one check. type Gate struct { - Gate string `json:"gate"` - Status string `json:"status"` - Detail string `json:"detail"` - Fix string `json:"fix,omitempty"` - Filament int `json:"filament,omitempty"` // 1-based job filament, on multi-filament jobs only + Gate string `json:"gate"` + Status string `json:"status"` + Detail string `json:"detail"` + Fix string `json:"fix,omitempty"` + Filament int `json:"filament,omitempty"` // 1-based job filament, on multi-filament jobs only + OperatorEstimate *StatedAmount `json:"operator_estimate,omitempty"` +} + +// StatedAmount records the operator's estimate alongside the unmodified AMS telemetry. +type StatedAmount struct { + StatedG float64 `json:"stated_g"` + AMSRemainG *int `json:"ams_remain_g"` + AMSRemainPct *int `json:"ams_remain_pct"` + RequiredG float64 `json:"required_g"` } // Result is the preflight outcome. Slot and TrayID are the first filament's. @@ -43,11 +52,12 @@ type Result struct { // 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 + Remaining map[int]float64 // operator estimates in grams, keyed by AMS tray ID; this invocation only // FTPSCheck, if set, performs a read-only FTPS login + listing and returns its error. FTPSCheck func() error } @@ -122,7 +132,11 @@ func Run(in Input) Result { if n >= len(in.TrayIDs) { break } - fg := filamentGates(s, need, in.TrayIDs[n], in.Strict) + var estimate *float64 + if grams, ok := in.Remaining[in.TrayIDs[n]]; ok { + estimate = &grams + } + fg := filamentGates(s, need, in.TrayIDs[n], in.Strict, estimate) if len(needs) > 1 { for i := range fg { fg[i].Filament = n + 1 @@ -164,7 +178,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 bool, estimate *float64) gates { var g gates label := printer.TrayLabel(trayID) var tray *printer.Tray @@ -200,11 +214,32 @@ func filamentGates(s printer.Status, need job.Filament, trayID int, strict bool) g.add("filament_profile", tray.FilamentID == need.FilamentID, Warn, fmt.Sprintf("slot %s filament_id=%s (%s) file=%s", label, orNone(tray.FilamentID), orNone(tray.Name), orNone(need.FilamentID)), "a different product than sliced for; re-slice with --filament to match, or accept") - if tray.RemainG == nil { + required := need.UsedG*1.15 + 5 + if estimate != nil { + st := Warn + fix := "operator estimate; confirm the spool has this amount before starting" + enough := *estimate >= required + if !enough || math.IsInf(*estimate, 0) { + st = Fail + fix = "load a fuller spool into slot " + label + } + ams := "unknown" + if tray.RemainG != nil && tray.RemainPct != nil { + ams = fmt.Sprintf("~%d g (%d%%)", *tray.RemainG, *tray.RemainPct) + } else if tray.RemainPct != nil { + ams = fmt.Sprintf("grams unknown (%d%%)", *tray.RemainPct) + } + g = append(g, Gate{ + Gate: "filament_amount", Status: st, + Detail: fmt.Sprintf("slot %s operator estimate %g g; AMS %s; job needs %.1f g (+15%%, +5 g) = %g g", label, *estimate, ams, need.UsedG, required), + Fix: fix, + OperatorEstimate: &StatedAmount{StatedG: *estimate, AMSRemainG: tray.RemainG, AMSRemainPct: tray.RemainPct, RequiredG: required}, + }) + } else if tray.RemainG == nil { g.add("filament_amount", false, Warn, fmt.Sprintf("slot %s remaining unknown; job needs %.1f g", label, need.UsedG), "confirm there is enough filament on the spool") } else { - ok := float64(*tray.RemainG) >= need.UsedG*1.15+5 + ok := float64(*tray.RemainG) >= required 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) } diff --git a/internal/preflight/remaining_test.go b/internal/preflight/remaining_test.go new file mode 100644 index 0000000..a596327 --- /dev/null +++ b/internal/preflight/remaining_test.go @@ -0,0 +1,73 @@ +package preflight + +import ( + "math" + "strings" + "testing" + + "github.com/voska/bambu/internal/job" + "github.com/voska/bambu/internal/testutil" +) + +func TestRemainingEstimateGates(t *testing.T) { + for _, tc := range []struct { + name string + grams float64 + unknown, empty bool + want, gate string + }{ + {"sufficient", 50, false, false, Warn, "filament_amount"}, + {"insufficient", 19, false, false, Fail, "filament_amount"}, + {"rounded threshold", 19.14, false, false, Fail, "filament_amount"}, + {"at reserve", 12.3*1.15 + 5, false, false, Warn, "filament_amount"}, + {"below reserve", 12.3*1.15 + 5 - 0.001, false, false, Fail, "filament_amount"}, + {"unknown sufficient", 50, true, false, Warn, "filament_amount"}, + {"unknown insufficient", 0, true, false, Fail, "filament_amount"}, + {"empty tray", 50, false, true, Fail, "tray"}, + } { + t.Run(tc.name, func(t *testing.T) { + j, err := job.Inspect(testutil.Write(t, t.TempDir(), "part", testutil.Opts{Filaments: []string{"PLA:GFA01:12.3"}}), 1) + if err != nil { + t.Fatal(err) + } + s := idleStatus(t) + if tc.unknown { + s.AMS[0].RemainG, s.AMS[0].RemainPct = nil, nil + } + s.AMS[0].Loaded = !tc.empty + for _, strict := range []bool{false, true} { + r := Run(Input{Printer: shop, Job: j, Status: s, TrayIDs: []int{0}, Strict: strict, Remaining: map[int]float64{0: tc.grams}}) + if status(r, tc.gate) != tc.want || (r.Result == Fail) != (tc.want == Fail) { + t.Fatalf("strict=%v: %+v", strict, r) + } + if tc.gate == "filament_amount" && !strings.Contains(gateFor(r, tc.gate, 0).Detail, "= 19.145 g") { + t.Fatalf("required grams must not round down: %+v", r.Gates) + } + if tc.unknown { + g := gateFor(r, tc.gate, 0) + if g.OperatorEstimate == nil || g.OperatorEstimate.AMSRemainG != nil || g.OperatorEstimate.AMSRemainPct != nil { + t.Fatalf("unknown AMS evidence: %+v", g) + } + } + } + }) + } +} + +func TestRemainingEstimateCannotBypassInvalidJobUsage(t *testing.T) { + j, err := job.Inspect(testutil.Write(t, t.TempDir(), "part", testutil.Opts{Filaments: []string{"PLA:GFA01:NaN"}}), 1) + if err != nil { + t.Fatal(err) + } + if !math.IsNaN(j.Filaments[0].UsedG) { + t.Fatal("fixture must reproduce nonfinite job usage") + } + for _, remaining := range []map[int]float64{nil, {0: 50}} { + for _, strict := range []bool{false, true} { + r := Run(Input{Printer: shop, Job: j, Status: idleStatus(t), TrayIDs: []int{0}, Remaining: remaining, Strict: strict}) + if status(r, "filament_amount") != Fail { + t.Fatalf("invalid job usage must still FAIL with a checked estimate: %+v", r) + } + } + } +} diff --git a/skills/bambu/SKILL.md b/skills/bambu/SKILL.md index 2f03bc8..59911f2 100644 --- a/skills/bambu/SKILL.md +++ b/skills/bambu/SKILL.md @@ -61,6 +61,8 @@ bambu monitor --snapshot-at-layer 2 --json # NDJSON events; **Slots** are `1`–`4` (first AMS) or `A1`–`D4`. Preflight checks that the slot's filament type matches the sliced one. +**Checked spool amount:** when a person reports a physically checked amount and AMS remaining telemetry is wrong, pass that amount (e.g. `--filament-remaining A2=50g`) to both the dry-run and send. Use the same amount in both and repeat per checked slot. It applies only to that invocation. Sufficient estimates WARN with the stated amount and original AMS reading, including with `--strict`; report that WARN. Insufficient estimates still FAIL against the existing requirement of job grams Ɨ 1.15 + 5 g (15% + 5 g reserve). Other gates remain enforced. + **Two colours, changing at a layer height** (the AMS swaps by itself, from the G-code): ```bash diff --git a/skills/bambu/references/COMMANDS.md b/skills/bambu/references/COMMANDS.md index 85d6b1b..5fc869e 100644 --- a/skills/bambu/references/COMMANDS.md +++ b/skills/bambu/references/COMMANDS.md @@ -81,6 +81,7 @@ Run the safety gates for a sliced file (read-only). | flag | type | default | help | |---|---|---|---| | `--slot, -s` | []string | | AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1). (required) | +| `--filament-remaining` | []string | | Grams left on a spool you checked, e.g. A2=50g, for this invocation only. Repeat per slot; sufficient estimates WARN (also with --strict), insufficient estimates FAIL. | | `--no-ftp` | bool | | Skip the FTPS login check. | ### `bambu print pause` @@ -107,6 +108,7 @@ Upload a sliced file and start it (requires --confirm and a passing preflight). | flag | type | default | help | |---|---|---|---| | `--slot, -s` | []string | | AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1). (required) | +| `--filament-remaining` | []string | | Grams left on a spool you checked, e.g. A2=50g, for this invocation only. Repeat per slot; sufficient estimates WARN (also with --strict), insufficient estimates FAIL. | | `--confirm` | bool | | Required to actually print. Only pass it after a human approved this exact file and confirmed the plate is clear. | | `--dry-run, -n` | bool | | Run preflight and show the exact upload path and MQTT payload; send nothing. | | `--timelapse` | bool | | Record a timelapse. |