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
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
- 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.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 11 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
166 changes: 166 additions & 0 deletions internal/cmd/filament_remaining_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
84 changes: 65 additions & 19 deletions internal/cmd/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import (
"errors"
"fmt"
"io"
"math"
"os"
"regexp"
"slices"
"strconv"
"strings"
"time"

Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading