diff --git a/CHANGELOG.md b/CHANGELOG.md index 038d4cf..c0d7fb9 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 +- `ams set` records non-RFID spool metadata while idle, with physical filament detection, confirmation, dry-run support, and verified read-back. Status trays include nullable `present` physical detection without changing `loaded`. - 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. @@ -27,6 +28,7 @@ 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 +- Preflight distinguishes present but untyped AMS trays from empty slots and points to `bambu ams set` to configure the physical spool. Unknown detection is reported as unknown; the tray gate and exit codes are unchanged. - `slice --strict` rejects slicer warnings, including floating regions, with exit 11 before publishing new output files. JSON errors include the warnings and slicer log path; slicing without `--strict` still reports warnings in the summary. - `slice --set` now preserves JSON list values, including commas inside quoted entries; comma-separated batches and repeated overrides remain supported. - `print pause` and `print stop` now verify PAUSE or an idle state before exiting 0; `--wait` defaults to 30s and must be positive. diff --git a/README.md b/README.md index fd4eeb3..f32df8b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Slice, check, send and monitor **Bambu Lab** prints from the terminal over your - **Safety first:** - Preflight gates check that the printer is idle, the right filament is in the slot, the plate and nozzle match, Developer Mode is on, and there's enough filament. - `print send` refuses to run without `--confirm`. - - No command ever changes printer settings. + - AMS spool metadata edits require `--confirm` and verified read-back. AI protections and network settings stay read-only. - **LAN only:** MQTT and FTPS go directly to the printer. No cloud account, no Bambu Connect. - **Agent-friendly:** `--json` everywhere (NDJSON for streams), stable exit codes, `schema` introspection, and no prompts with `--no-input`. - **Single static binary** for macOS, Linux and Windows. @@ -127,6 +127,35 @@ Recipes name presets generically (`"0.20mm Standard"`, `"Bambu PLA Basic"`), and - **Custom recipes:** drop JSON files in `~/.config/bambu/recipes/`. The same name overrides a built-in. See `bambu recipe show solid-pla`. - **`--set key=value`** takes any Bambu Studio setting key. Unknown keys are rejected before slicing. At 100% infill, patterns the slicer rejects (grid, gyroid, …) switch to zig-zag automatically. +### Configure an AMS spool + +After loading a non-RFID spool, record what is physically in the slot: + +```bash +bambu ams set --slot A1 --type PLA --filament-id GFL99 --color C8A26B \ + --nozzle-temp-min 200 --nozzle-temp-max 230 --dry-run --json +# Use --confirm instead of --dry-run to apply the same values. +``` + +`GFL99` is Generic PLA; PLA+ uses material type `PLA`. Supply the matching profile ID, +preview color, and temperature range from the spool manufacturer. Common Generic +profile IDs are type-checked; matching other IDs to the material is the caller's responsibility. A wood-colored PLA+ +spool uses a PLA profile; a wood-filled material needs its own suitable profile. + +All five material fields are required so a changed spool does not inherit old values. +The printer must be idle, in Developer Mode, and detect filament in the slot. Paused +jobs, busy AMS operations, empty or unknown slots, and RFID spools are refused. +Dry-run runs the same gates and shows the protocol payload without changing anything. +Exit 0 on a write means a fresh tray report matched every field, including color alpha; +an acknowledgement alone is insufficient. Repeating the same values sends nothing. + +This records metadata without loading filament or heating the printer. Temperature +bounds are used by later filament loads and preflight checks. Bed temperature, flow +ratio, and volumetric speed belong to the slicing profile; this command also leaves +the tray's flow calibration unchanged and reports any calibration telemetry before +and after. Only use it after a human identifies the physical spool, never to bypass a +preflight mismatch. + ### Safety model 1. **`preflight` checks before anything is sent:** @@ -141,7 +170,10 @@ Recipes name presets generically (`"0.20mm Standard"`, `"Bambu PLA Basic"`), and 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. -`bambu` has no command that changes printer settings. +5. **`ams set`** requires `--confirm` to record non-RFID spool metadata while idle, + with physical filament detection and verified read-back. `--dry-run` sends nothing. + +AI protections, network settings, and direct temperature controls remain read-only. 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 diff --git a/SECURITY.md b/SECURITY.md index ddca927..125c615 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,5 @@ as is practical, crediting reporters who wish to be named. process listings; errors are redacted. - Printers use self-signed TLS certificates, so `bambu` does not verify them. Only use it on networks you trust. - `print send`, `print pause|resume|stop` require `--confirm`; `print send` also requires a passing preflight. - `bambu` never changes printer settings. Never let an agent pass `--confirm` without a human's approval. + `ams set` can record human-identified non-RFID spool metadata while idle, with physical detection and verified read-back. + AI protections and network settings remain read-only. Never let an agent pass `--confirm` without a human's approval. diff --git a/SPEC.md b/SPEC.md index 996d091..0b2a0a9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -16,6 +16,7 @@ In v0.1: - Pause / resume / stop. `resume` succeeds only once the printer reports RUNNING. - Two-filament jobs: a layer-height colour change that the AMS performs from the G-code, one `--slot` per filament. - `filament load`: a remote AMS colour change while paused (the manual route when the second colour isn't in the job). +- `ams set`: record a human-identified, non-RFID spool's metadata while idle, with verified read-back. - Status, and a streaming `status --watch`. - `monitor` with a layer-N snapshot and an exec hook. - Camera snapshot for RTSPS models (X1/X1E/H2/P2S/X2D) via ffmpeg. @@ -25,7 +26,7 @@ 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. - The P1/A1 camera, which uses the port-6000 JPEG protocol: a clear error for now. -- Changing persistent printer settings: no xcam/AI toggles, network configuration, or temperature controls. The chamber light is an explicit, verified control. +- Changing persistent printer settings other than AMS spool metadata: no xcam/AI toggles, network configuration, or direct temperature controls. The chamber light is an explicit, verified control. - OrcaSlicer (see §8). ## 2. Settled decisions @@ -44,7 +45,7 @@ Out of scope for v0.1: ## 3. Command tree -The tree is noun-verb where there is a real noun: `printer`, `auth`, `print`, `camera`, `recipe`, `slicer`. +The tree is noun-verb where there is a real noun: `printer`, `auth`, `print`, `camera`, `recipe`, `slicer`, `ams`. Top-level verbs are kept for the high-frequency, single-purpose actions an operator types all the time: `status`, `slice`, `preflight`, `monitor`. ``` @@ -55,6 +56,7 @@ bambu print send --slot S [--slot S2]... (--dry-run | --confirm 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 +bambu ams set --slot S --type PLA --filament-id GFL99 --color RRGGBB --nozzle-temp-min T --nozzle-temp-max T (--dry-run | --confirm) [--wait 10s] bambu monitor [--snapshot-at-layer N] [--snapshot-dir D] [--exec CMD] [--timeout 2h] bambu camera snapshot [-o file.jpg] bambu printer add --host H --serial S [--model X1C] [--nozzle 0.4] [--plate textured_plate] [--default] @@ -86,6 +88,7 @@ Slots: `1`–`4` (first AMS) or `A1`…`D4`, plus `ext` for the external spool ( - status: state - slice: the path of the .gcode.3mf - snapshot: the path of the image + - ams set: the normalized slot label - send: state - preflight: PASS/FAIL - **Errors in `--json` mode** print `{"error":{"code":N,"name":"...","message":"...","hint":"..."}}` to stdout **and** a human line to stderr. @@ -164,6 +167,32 @@ pause, A4 → A1): `{"print":{"sequence_id":"","command":"ams_change_filament Done when `ams.tray_now` is the target and `ams_status >> 8` is no longer `0x01` (filament_change) nor the stage a loading/unloading one. +**AMS metadata:** Studio's `command_ams_filament_settings` sends +`{"print":{"sequence_id":"","command":"ams_filament_setting","ams_id":A,"slot_id":S,"tray_id":S, +"tray_info_idx":"","setting_id":"","tray_type":"PLA","tray_color":"RRGGBBFF", +"nozzle_temp_min":200,"nozzle_temp_max":230}}`. `tray_id` is local to the AMS (0–3). +All metadata fields are required; colors accept an optional `#`, and temperatures +must satisfy 150 ≤ min ≤ max ≤ 350 °C. Common Generic profile IDs (PLA, PETG, ABS, +ASA, PA, PC, TPU) are checked against the material type. For other IDs, the caller +must supply a matching pair; the CLI does not resolve slicer presets. +`tray_exist_bits` is a hexadecimal physical detection mask, independent of configured +`tray_type`. Refuse missing detection or an empty slot, RFID identity, active jobs +(including PAUSE), busy AMS operations, blocking errors, and missing Developer Mode. +After publishing, request fresh `pushall` reports and compare type, filament ID, full +RGBA color, and both temperature bounds. Exit 14 means submitted, unverified, with +before/after evidence in the error data. An identical initial report is a verified +no-op. Dry-run performs the same gates and returns `dry_run:true, sent:false, +verified:false` with the payload. Successful writes include `before`, `after`, +`sent`, `verified`, `payload`, and optional `ack` and calibration telemetry. +Plain columns: slot, type, filament_id, color, nozzle_temp_min, nozzle_temp_max, +dry_run, sent, verified. Bed/flow/speed remain slicing settings; tray calibration +is reported but never selected or changed by this command. + +Status tray `present` is nullable physical detection from this mask; `loaded` retains +its existing meaning of nonempty material metadata. Preflight's `tray` gate fails +for an untyped tray even when present, naming `bambu ams set` as the recovery command. +Missing or malformed detection remains unknown, rather than evidence of an empty slot. + **Two-filament `ams_mapping`:** one entry per project filament in filament order, e.g. `[3, 0]` for A4 then A1 (Studio's "v0" format, `SelectMachine.cpp`). Not yet verified on a real print. @@ -258,7 +287,7 @@ shows both sources, and confirmed sends also warn on stderr before uploading. | nozzle_diameter | ≠ printer | | | | | 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) | +| tray | slot empty, material unset, or type ≠ file type | filament_id differs (e.g. GFA01 vs GFA00) | | filament_amount | remain_g < used_g × 1.15 + 5 | remaining unknown | | 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 | | @@ -290,7 +319,10 @@ internal/errfmt/ exit codes, typed errors with hints - `print pause|resume|stop` and `filament load` require `--confirm`. `filament load` runs only while PAUSE or IDLE/FINISH/FAILED. It refuses during a change, with a blocking HMS code, or on an empty slot. When paused, it also refuses without an AMS tray in the toolhead, or with a different material than the job's. -- No command changes printer settings. +- `ams set` is the exception for human-identified, non-RFID spool metadata. It needs + `--confirm`, an idle printer, physical filament detection, and fresh read-back; + never use it to bypass a preflight mismatch. It does not load, heat, or select flow calibration. +- AI protections, network settings, and direct temperature controls stay read-only. - The access code is never printed, never logged, and never written to config. `ffmpeg` errors are redacted. - `--no-input` or a non-TTY stdin never prompts. diff --git a/docs/protocol.md b/docs/protocol.md index baf4676..9dabe5f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -14,6 +14,31 @@ September 2026). - **AMS state:** `print.ams_status >> 8` is Bambu Studio's `AmsStatusMain`: `0x00` idle, `0x01` filament_change, `0x02` rfid_identifying, `0x03` assist (resting, filament loaded), `0x04` calibration, `0x07` cold_pull, `0x10` self_check, `0x20` debug. `ams.tray_now` is the tray in the toolhead (`255` none, `254` external spool). A G-code pause (`M400 U1`) reports `PAUSE` with `print_error` `0300_8013`. (verified) - **Filament load:** `{"print":{"sequence_id":"","command":"ams_change_filament","ams_id":A,"slot_id":S,"target":A*4+S,"curr_temp":T1,"tar_temp":T2}}`, as Studio's `command_ams_change_filament` sends it. Each temperature is the middle of that tray's `nozzle_temp_min`/`max` (Studio's default is 210). The printer cuts and unloads, loads the target and purges: `ams_status` goes `filament_change`, then `tray_now` becomes the target and `ams_status` returns to `assist`. (verified on an X1 Carbon during a pause, 2026-10-08: A4 → A1, then `resume` → RUNNING, and the print finished cleanly.) +## AMS spool metadata: `print.ams_filament_setting` + +Matches Bambu Studio's `DeviceManager.cpp::command_ams_filament_settings`: + +```json +{"print":{"sequence_id":"","command":"ams_filament_setting", + "ams_id":0,"slot_id":0,"tray_id":0,"tray_info_idx":"GFL99","setting_id":"", + "tray_type":"PLA","tray_color":"C8A26BFF","nozzle_temp_min":200,"nozzle_temp_max":230}} +``` + +`ams_id` names the unit; `slot_id` and `tray_id` are local indices (0–3). +Colors are RGBA. The command sends an empty `setting_id`; its effect is unverified +and it is not included in the read-back guarantee. Metadata is +verified through fresh `pushall` tray reports, comparing all five fields; the +command acknowledgement does not establish success. `tray_exist_bits` is the +hexadecimal physical filament detection mask, independent of configured type. +RFID spools are refused because their metadata can be restored from the tag. +This command does not load filament, heat the nozzle, or change flow calibration. +Temperature bounds affect subsequent filament loads and preflight checks. + +Verified on an X1 Carbon on 2026-10-12: an unconfigured non-RFID spool became +PLA / GFL99 / C8A26BFF / 200–230 °C, confirmed by a separate status connection. +The other three slots, toolhead selection, and heater targets stayed unchanged. +Repeating the same settings published nothing. Other printer models remain unverified. + ## Start a job: `print.project_file` (verified) ```json diff --git a/internal/cmd/ams.go b/internal/cmd/ams.go new file mode 100644 index 0000000..cb979c6 --- /dev/null +++ b/internal/cmd/ams.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "context" + "encoding/hex" + "regexp" + "strconv" + "strings" + "time" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/output" + "github.com/voska/bambu/internal/printer" +) + +// AMSCmd groups AMS tray metadata commands. +type AMSCmd struct { + Set AMSSetCmd `cmd:"" help:"Set non-RFID spool metadata while idle; verify by fresh read-back. Does not load filament, heat, slice, or select flow calibration."` +} + +// AMSSetCmd records the material a human has physically loaded into a tray. +type AMSSetCmd struct { + Slot string `short:"s" required:"" help:"AMS slot: 1-4 (first AMS) or A1-D4."` + Type string `required:"" help:"Material type, e.g. PLA or PETG. PLA+ uses PLA."` + FilamentID string `required:"" help:"Matching filament profile ID, e.g. GFL99 for Generic PLA. Common Generic IDs are type-checked; the caller must match other IDs to --type."` + Color string `required:"" aliases:"colour" help:"Opaque preview color: RRGGBB or #RRGGBB."` + NozzleTempMin int `required:"" help:"Spool's minimum nozzle temperature in Celsius (150-350)."` + NozzleTempMax int `required:"" help:"Spool's maximum nozzle temperature in Celsius (150-350)."` + DryRun bool `help:"Read state and show the exact payload; send nothing."` + Confirm bool `help:"Required for writes: a human identified the physical spool. Never relabel a spool to bypass preflight. Temperature bounds affect later filament loading."` + Wait time.Duration `default:"10s" help:"How long to wait for fresh tray read-back (exit 14 if unverified)."` +} + +type amsSetResult struct { + Printer string `json:"printer"` + Slot string `json:"slot"` + DryRun bool `json:"dry_run"` + Sent bool `json:"sent"` + Verified bool `json:"verified"` + Before printer.Tray `json:"before"` + After printer.Tray `json:"after"` + CalibrationBefore map[string]any `json:"calibration_before,omitempty"` + CalibrationAfter map[string]any `json:"calibration_after,omitempty"` + Payload map[string]map[string]any `json:"payload"` + Ack map[string]any `json:"ack,omitempty"` +} + +var ( + materialPattern = regexp.MustCompile(`^[A-Z][A-Z0-9-]{0,31}$`) + filamentPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`) + // Bambu Studio profiles/BBL/filament/Generic @base.json. + genericMaterials = map[string]string{ + "GFL99": "PLA", "GFG99": "PETG", "GFB99": "ABS", "GFB98": "ASA", + "GFN99": "PA", "GFC99": "PC", "GFU99": "TPU", + } +) + +func (c *AMSSetCmd) validate() (int, string, error) { + id, err := printer.ParseSlot(c.Slot) + if err != nil { + return 0, "", errfmt.Wrap(errfmt.ExitUsage, err, "bad --slot") + } + color := strings.ToUpper(strings.TrimPrefix(c.Color, "#")) + _, colorErr := hex.DecodeString(color) + switch { + case !materialPattern.MatchString(c.Type): + return 0, "", errfmt.New(errfmt.ExitUsage, "--type must be a material code such as PLA or PETG") + case !filamentPattern.MatchString(c.FilamentID): + return 0, "", errfmt.New(errfmt.ExitUsage, "--filament-id must be a profile ID such as GFL99") + case genericMaterials[c.FilamentID] != "" && genericMaterials[c.FilamentID] != c.Type: + return 0, "", errfmt.New(errfmt.ExitUsage, "--filament-id %s is %s, not --type %s", c.FilamentID, genericMaterials[c.FilamentID], c.Type) + case len(color) != 6 || colorErr != nil: + return 0, "", errfmt.New(errfmt.ExitUsage, "--color must be RRGGBB or #RRGGBB") + case c.NozzleTempMin < 150 || c.NozzleTempMax > 350 || c.NozzleTempMin > c.NozzleTempMax: + return 0, "", errfmt.New(errfmt.ExitUsage, "nozzle temperature range must satisfy 150 <= min <= max <= 350") + case c.Wait <= 0: + return 0, "", errfmt.New(errfmt.ExitUsage, "--wait must be positive") + case c.DryRun && c.Confirm: + return 0, "", errfmt.New(errfmt.ExitUsage, "use either --dry-run or --confirm") + case !c.DryRun && !c.Confirm: + return 0, "", errfmt.New(errfmt.ExitGate, "ams set needs --confirm or --dry-run").WithHint("only record the material physically loaded into the slot, after a human identifies it") + } + return id, color, nil +} + +// Run executes the command. +func (c *AMSSetCmd) Run(g *Globals) error { + id, color, err := c.validate() + if err != nil { + return err + } + p, err := g.Target() + if err != nil { + return err + } + conn, err := g.Connect(p) + if err != nil { + return err + } + defer conn.Close() + raw, err := conn.Pushall(g.Ctx) + if err != nil { + return err + } + before := printer.Summarize(raw) + if err := amsSetGates(raw, before, id); err != nil { + return err + } + body := printer.AMSFilamentSetting(id, c.Type, c.FilamentID, color, c.NozzleTempMin, c.NozzleTempMax) + r := amsSetResult{ + Printer: p.Name, Slot: printer.TrayLabel(id), DryRun: c.DryRun, + Before: *findTray(before, id), After: *findTray(before, id), + Payload: map[string]map[string]any{"print": withSeq(body)}, + CalibrationBefore: trayCalibration(printer.RawAMSTray(raw, id)), + } + r.CalibrationAfter = r.CalibrationBefore + if r.Before.Type != c.Type && len(r.CalibrationBefore) > 0 { + g.Out.Hint("AMS metadata does not change the slot's flow calibration; check its calibration before printing %s", c.Type) + } + if !c.DryRun { + r.Verified = printer.AMSSettingsMatch(raw, id, body) + if !r.Verified { + ackCtx, cancel := context.WithTimeout(g.Ctx, min(c.Wait, 3*time.Second)) + r.Ack, err = conn.Command(ackCtx, "print", body) + cancel() + if err != nil { + return err + } + r.Sent = true + if err := printer.CheckAck(r.Ack, "set "+r.Slot); err != nil { + return err + } + if err := verifyAMSSet(g.Ctx, conn, id, body, c.Wait, &r); err != nil { + return err + } + } + } + return g.Out.Print(output.View{ + Data: r, + Human: func(h *output.Human) { + if r.DryRun { + h.Line("dry-run: %s -> %s (%s), #%s, %d-%d C; nothing sent", r.Slot, c.Type, c.FilamentID, color, c.NozzleTempMin, c.NozzleTempMax) + return + } + h.Line("%s: %s (%s), %s, %d-%d C; verified (sent=%t)", r.Slot, r.After.Type, r.After.FilamentID, r.After.Color, r.After.TempMin, r.After.TempMax, r.Sent) + }, + Plain: [][]string{ + {"slot", "type", "filament_id", "color", "nozzle_temp_min", "nozzle_temp_max", "dry_run", "sent", "verified"}, + {r.Slot, c.Type, c.FilamentID, "#" + color, strconv.Itoa(c.NozzleTempMin), strconv.Itoa(c.NozzleTempMax), strconv.FormatBool(r.DryRun), strconv.FormatBool(r.Sent), strconv.FormatBool(r.Verified)}, + }, + Quiet: r.Slot, + }) +} + +func amsSetGates(raw map[string]any, s printer.Status, id int) error { + label := printer.TrayLabel(id) + switch { + case !printer.IdleStates[s.State]: + return errfmt.New(errfmt.ExitGate, "can't set AMS metadata: printer is %q", s.State).WithHint("wait until IDLE, FINISH, or FAILED; paused jobs also retain their material mapping") + case s.DevMode == nil || !*s.DevMode: + return errfmt.New(errfmt.ExitForbidden, "ams set needs Developer Mode").WithHint("enable Developer Mode on the printer") + case printer.FilamentChanging(s) || (s.AMSStatus != "" && s.AMSStatus != "idle" && s.AMSStatus != "assist"): + return errfmt.New(errfmt.ExitGate, "AMS is busy (ams_status=%s stage=%s)", s.AMSStatus, s.Stage) + case s.Errors.Blocking: + return errfmt.New(errfmt.ExitGate, "printer has blocking errors").WithHint("check `bambu status` and clear the cause first") + case findTray(s, id) == nil: + return errfmt.New(errfmt.ExitGate, "AMS slot %s was not reported", label) + } + present := printer.TrayPresent(raw, id) + if present == nil || !*present { + return errfmt.New(errfmt.ExitGate, "filament presence in %s is empty or unknown", label).WithHint("insert the spool; the AMS must report tray_exist_bits before metadata can be set") + } + t := printer.RawAMSTray(raw, id) + for _, key := range []string{"tag_uid", "tray_uuid"} { + if v, ok := t[key].(string); ok && strings.Trim(v, "0") != "" { + return errfmt.New(errfmt.ExitGate, "%s has RFID metadata; ams set only edits non-RFID spools", label) + } + } + return nil +} + +func trayCalibration(t map[string]any) map[string]any { + out := map[string]any{} + for _, k := range []string{"k", "n", "cali_idx"} { + if v, ok := t[k]; ok { + out[k] = v + } + } + return out +} + +func verifyAMSSet(ctx context.Context, conn printer.Conn, id int, body map[string]any, wait time.Duration, r *amsSetResult) error { + ctx, cancel := context.WithTimeout(ctx, wait) + defer cancel() + tick := time.NewTicker(500 * time.Millisecond) + defer tick.Stop() + for { + raw, err := conn.Pushall(ctx) + if err != nil { + return errfmt.Wrap(errfmt.ExitTimeout, err, "%s metadata submitted, unverified: read-back failed", r.Slot). + WithData("ams", r).WithHint("read `bambu status --json` before retrying") + } + if after := findTray(printer.Summarize(raw), id); after != nil { + r.After = *after + } + r.CalibrationAfter = trayCalibration(printer.RawAMSTray(raw, id)) + if printer.AMSSettingsMatch(raw, id, body) { + r.Verified = true + return nil + } + select { + case <-ctx.Done(): + return errfmt.New(errfmt.ExitTimeout, "%s metadata submitted, unverified after %s", r.Slot, wait). + WithData("ams", r).WithHint("read `bambu status --json` before retrying") + case <-tick.C: + } + } +} diff --git a/internal/cmd/ams_test.go b/internal/cmd/ams_test.go new file mode 100644 index 0000000..37932a0 --- /dev/null +++ b/internal/cmd/ams_test.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "context" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/printer" + "github.com/voska/bambu/internal/testutil" +) + +var amsSetArgs = []string{"ams", "set", "--slot", "1", "--type", "PLA", "--filament-id", "GFL99", "--color", "#c8a26b", "--nozzle-temp-min", "200", "--nozzle-temp-max", "230"} + +func amsHarness(t *testing.T, mut func(map[string]any)) *harness { + t.Helper() + return newHarness(t, func(m map[string]any) { + m["ams"].(map[string]any)["tray_exist_bits"] = "f" + m["ams_status"] = 0 + if mut != nil { + mut(m) + } + }) +} + +func rawTray(m map[string]any, unit, slot int) map[string]any { + for _, a := range m["ams"].(map[string]any)["ams"].([]any) { + am := a.(map[string]any) + if am["id"] != strconv.Itoa(unit) { + continue + } + for _, t := range am["tray"].([]any) { + tr := t.(map[string]any) + if tr["id"] == strconv.Itoa(slot) { + return tr + } + } + } + return nil +} + +func applyTraySetting(f *testutil.FakeConn, body map[string]any) { + m := f.State() + t := rawTray(m, body["ams_id"].(int), body["slot_id"].(int)) + for _, k := range []string{"tray_type", "tray_info_idx", "tray_color", "nozzle_temp_min", "nozzle_temp_max"} { + t[k] = body[k] + } + f.Set("ams", m["ams"]) +} + +func TestAMSSetUnconfiguredSpool(t *testing.T) { + h := amsHarness(t, func(m map[string]any) { + t := rawTray(m, 0, 0) + for k := range t { + if k != "id" { + delete(t, k) + } + } + m["ams"].(map[string]any)["tray_exist_bits"] = "f" + }) + before := h.conn.State() + h.conn.OnCommand = func(f *testutil.FakeConn, section string, body map[string]any) map[string]any { + if section != "print" { + t.Fatalf("wrong section: %s", section) + } + applyTraySetting(f, body) + return map[string]any{"result": "success"} + } + if code := h.run(append(amsSetArgs, "--confirm", "--json")...); code != 0 { + t.Fatalf("exit %d: %s %s", code, h.stdout.String(), h.stderr.String()) + } + want := map[string]any{ + "command": "ams_filament_setting", "ams_id": 0, "slot_id": 0, "tray_id": 0, + "tray_info_idx": "GFL99", "setting_id": "", "tray_type": "PLA", "tray_color": "C8A26BFF", "nozzle_temp_min": 200, "nozzle_temp_max": 230, + } + if len(h.conn.Commands) != 1 || !reflect.DeepEqual(h.conn.Commands[0]["print"], want) { + t.Fatalf("commands: %v", h.conn.Commands) + } + var out struct { + Slot string `json:"slot"` + Sent bool `json:"sent"` + Verified bool `json:"verified"` + After printer.Tray `json:"after"` + } + h.json(&out) + if !out.Sent || !out.Verified || out.Slot != "A1" || out.After.FilamentID != "GFL99" || out.After.Color != "#C8A26B" || out.After.TempMin != 200 || out.After.TempMax != 230 { + t.Fatalf("read-back: %+v", out) + } + after := h.conn.State() + for slot := 1; slot < 4; slot++ { + if !reflect.DeepEqual(rawTray(before, 0, slot), rawTray(after, 0, slot)) { + t.Errorf("changed another slot: %d", slot) + } + } + if after["ams"].(map[string]any)["tray_now"] != before["ams"].(map[string]any)["tray_now"] { + t.Fatal("metadata edit changed the toolhead filament") + } +} + +func TestAMSSetDryRunAndIdempotence(t *testing.T) { + h := amsHarness(t, nil) + if code := h.run(append(amsSetArgs, "--dry-run", "--json")...); code != 0 || len(h.conn.Commands) != 0 { + t.Fatalf("dry-run exit %d: %s %v", code, h.stdout.String(), h.conn.Commands) + } + var plan struct { + DryRun bool `json:"dry_run"` + Verified bool `json:"verified"` + Payload map[string]map[string]any `json:"payload"` + } + h.json(&plan) + if !plan.DryRun || plan.Verified || plan.Payload["print"]["tray_color"] != "C8A26BFF" { + t.Fatalf("plan: %+v", plan) + } + h = amsHarness(t, func(m map[string]any) { + t := rawTray(m, 0, 0) + t["tray_info_idx"], t["tray_color"] = "GFL99", "c8a26bff" + t["nozzle_temp_min"], t["nozzle_temp_max"] = "200", "230" + }) + if code := h.run(append(amsSetArgs, "--confirm", "--json")...); code != 0 || len(h.conn.Commands) != 0 { + t.Fatalf("idempotence exit %d: %s %v", code, h.stdout.String(), h.conn.Commands) + } + var result struct{ Sent, Verified bool } + h.json(&result) + if result.Sent || !result.Verified { + t.Fatalf("already configured: %+v", result) + } +} + +func TestAMSSetNeverTrustsAck(t *testing.T) { + for _, field := range []string{"tray_type", "tray_info_idx", "tray_color", "nozzle_temp_min", "nozzle_temp_max"} { + t.Run(field, func(t *testing.T) { + h := amsHarness(t, nil) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + applyTraySetting(f, body) + m := f.State() + rawTray(m, 0, 0)[field] = "wrong" + f.Set("ams", m["ams"]) + return map[string]any{"result": "success"} + } + if code := h.run(append(amsSetArgs, "--confirm", "--wait", "10ms", "--json")...); code != errfmt.ExitTimeout { + t.Fatalf("ack cannot confirm %s: exit %d %s", field, code, h.stdout.String()) + } + if !strings.Contains(h.stdout.String(), "unverified") { + t.Fatalf("must report submitted, unverified: %s", h.stdout.String()) + } + }) + } +} + +func TestAMSSetMissingAckStillVerifies(t *testing.T) { + h := amsHarness(t, nil) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + applyTraySetting(f, body) + return nil + } + if code := h.run(append(amsSetArgs, "--confirm", "--plain")...); code != 0 || !strings.Contains(h.stdout.String(), "A1") { + t.Fatalf("exit %d: %s", code, h.stdout.String()) + } +} + +func TestAMSSetRefusals(t *testing.T) { + cases := []struct { + name string + args []string + mut func(map[string]any) + want int + }{ + {"no confirmation", nil, nil, errfmt.ExitGate}, + {"conflicting modes", []string{"--dry-run", "--confirm"}, nil, errfmt.ExitUsage}, + {"printing", []string{"--confirm"}, func(m map[string]any) { m["gcode_state"] = "RUNNING" }, errfmt.ExitGate}, + {"paused", []string{"--confirm"}, func(m map[string]any) { m["gcode_state"] = "PAUSE" }, errfmt.ExitGate}, + {"no state", []string{"--confirm"}, func(m map[string]any) { delete(m, "gcode_state") }, errfmt.ExitGate}, + {"AMS busy", []string{"--confirm"}, func(m map[string]any) { m["ams_status"] = 0x0100 }, errfmt.ExitGate}, + {"AMS RFID scan", []string{"--confirm"}, func(m map[string]any) { m["ams_status"] = 0x0200 }, errfmt.ExitGate}, + {"dev mode off", []string{"--confirm"}, func(m map[string]any) { m["fun"] = "20011A30F9CFB" }, errfmt.ExitForbidden}, + {"blocking error", []string{"--confirm"}, func(m map[string]any) { m["print_error"] = 1 }, errfmt.ExitGate}, + {"missing AMS", []string{"--confirm"}, func(m map[string]any) { m["ams"] = map[string]any{} }, errfmt.ExitGate}, + {"physically empty", []string{"--confirm"}, func(m map[string]any) { m["ams"].(map[string]any)["tray_exist_bits"] = "e" }, errfmt.ExitGate}, + {"unknown presence", []string{"--confirm"}, func(m map[string]any) { delete(m["ams"].(map[string]any), "tray_exist_bits") }, errfmt.ExitGate}, + {"RFID", []string{"--confirm"}, func(m map[string]any) { rawTray(m, 0, 0)["tag_uid"] = "FAKE123" }, errfmt.ExitGate}, + {"spool UUID", []string{"--confirm"}, func(m map[string]any) { rawTray(m, 0, 0)["tray_uuid"] = "FAKE123" }, errfmt.ExitGate}, + {"dry-run busy", []string{"--dry-run"}, func(m map[string]any) { m["gcode_state"] = "RUNNING" }, errfmt.ExitGate}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := amsHarness(t, tc.mut) + if code := h.run(append(amsSetArgs, tc.args...)...); code != tc.want || len(h.conn.Commands) != 0 { + t.Fatalf("exit %d, want %d: %s %v", code, tc.want, h.stderr.String(), h.conn.Commands) + } + }) + } +} + +func TestAMSSetSecondUnitAndFullColor(t *testing.T) { + h := amsHarness(t, func(m map[string]any) { + ams := m["ams"].(map[string]any) + ams["tray_exist_bits"] = "ff" + ams["ams"] = append(ams["ams"].([]any), map[string]any{"id": "1", "tray": []any{ + map[string]any{"id": "1", "tray_type": "PLA", "tray_info_idx": "GFL99", "tray_color": "C8A26B00", "nozzle_temp_min": "200", "nozzle_temp_max": "230", "tag_uid": "0000000000000000", "tray_uuid": "00000000000000000000000000000000"}, + }}) + }) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + applyTraySetting(f, body) + return nil + } + args := append([]string(nil), amsSetArgs...) + args[3] = "B2" + if code := h.run(append(args, "--confirm", "--quiet")...); code != 0 || strings.TrimSpace(h.stdout.String()) != "B2" { + t.Fatalf("exit %d: %s %s", code, h.stdout.String(), h.stderr.String()) + } + if len(h.conn.Commands) != 1 { + t.Fatalf("same RGB but wrong alpha needs a write: %v", h.conn.Commands) + } + body := h.conn.Commands[0]["print"].(map[string]any) + if body["ams_id"] != 1 || body["slot_id"] != 1 || body["tray_id"] != 1 { + t.Fatalf("second AMS uses local tray_id: %v", body) + } +} + +func TestAMSSetRejected(t *testing.T) { + h := amsHarness(t, nil) + h.conn.OnCommand = func(*testutil.FakeConn, string, map[string]any) map[string]any { + return map[string]any{"result": "fail", "reason": "not allowed"} + } + if code := h.run(append(amsSetArgs, "--confirm", "--json")...); code != errfmt.ExitError { + t.Fatalf("exit %d: %s", code, h.stdout.String()) + } +} + +type refreshTrayConn struct { + *testutil.FakeConn + reads int + body map[string]any +} + +func (c *refreshTrayConn) Pushall(ctx context.Context) (map[string]any, error) { + c.reads++ + if c.reads == 3 { + applyTraySetting(c.FakeConn, c.body) + } + return c.FakeConn.Pushall(ctx) +} + +func TestAMSSetFreshReadbackWithoutAck(t *testing.T) { + h := amsHarness(t, nil) + conn := &refreshTrayConn{FakeConn: h.conn} + h.conn.OnCommand = func(_ *testutil.FakeConn, _ string, body map[string]any) map[string]any { + conn.body = body + return nil + } + args := append(amsSetArgs, "--confirm", "--json") + code := Execute(args, BuildInfo{Version: "test"}, func(g *Globals) { + g.Keyring = h.kr + g.Dial = func(context.Context, string, string, string) (printer.Conn, error) { return conn, nil } + g.Out.Stdout, g.Out.Stderr = &h.stdout, &h.stderr + }) + if code != 0 || conn.reads != 3 { + t.Fatalf("fresh status must be requested even without an ack: exit %d, reads %d: %s", code, conn.reads, h.stdout.String()) + } +} + +func TestAMSSetRejectsBadValues(t *testing.T) { + for flag, values := range map[string][]string{ + "--slot": {"Z1", "external"}, "--color": {"wood", "#12345", "12345678"}, + "--type": {"", "PLA\n", "PETG"}, "--filament-id": {"", "bad/id", "GFG99"}, + "--nozzle-temp-min": {"0", "250"}, "--nozzle-temp-max": {"190", "400"}, "--wait": {"0s", "-1s"}, + } { + for _, value := range values { + h := amsHarness(t, nil) + args := append([]string(nil), amsSetArgs...) + found := false + for i, arg := range args { + if arg == flag { + args[i+1], found = value, true + } + } + if !found { + args = append(args, flag, value) + } + if code := h.run(append(args, "--confirm")...); code != errfmt.ExitUsage || len(h.conn.Commands) != 0 { + t.Errorf("%s %q: exit %d: %s %v", flag, value, code, h.stderr.String(), h.conn.Commands) + } + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6087a50..51c694f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -29,6 +29,7 @@ type CLI struct { Preflight PreflightCmd `cmd:"" help:"Run the safety gates for a sliced file (read-only)."` Print PrintCmd `cmd:"" help:"Send and control print jobs."` Filament FilamentCmd `cmd:"" help:"AMS filament in the toolhead (remote colour change at a pause)."` + AMS AMSCmd `cmd:"" help:"Configure the material physically loaded in an AMS slot."` Monitor MonitorCmd `cmd:"" help:"Follow the current job until it ends; exit code reflects the outcome."` Camera CameraCmd `cmd:"" help:"Printer camera."` Light LightCmd `cmd:"" help:"Read or switch the chamber light (verified by read-back)."` diff --git a/internal/cmd/tray_state_test.go b/internal/cmd/tray_state_test.go new file mode 100644 index 0000000..f3c0dc5 --- /dev/null +++ b/internal/cmd/tray_state_test.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/preflight" + "github.com/voska/bambu/internal/testutil" +) + +func TestAMSSetAvailable(t *testing.T) { + h := newHarness(t, nil) + if code := h.run("ams", "set", "--help"); code != 0 { + t.Fatalf("ams set unavailable: exit %d: %s", code, h.stderr.String()) + } +} + +func TestUntypedTrayPreflight(t *testing.T) { + for _, strict := range []bool{false, true} { + for _, tc := range []struct { + name, bits, detail string + }{ + {"present", "f", "slot A3 is untyped (spool detected; no material set)"}, + {"empty", "b", "slot A3 is empty"}, + {"unknown", "", "slot A3 has no material metadata; filament presence is unknown"}, + } { + t.Run(tc.name+map[bool]string{false: "", true: " strict"}[strict], func(t *testing.T) { + h := newHarness(t, func(m map[string]any) { + ams := m["ams"].(map[string]any) + ams["tray_exist_bits"] = tc.bits + unit := ams["ams"].([]any)[0].(map[string]any) + unit["tray"].([]any)[2] = map[string]any{"id": "2"} + }) + args := []string{"print", "send", h.job(testutil.Opts{}), "--slot", "A3", "--dry-run", "--json"} + if strict { + args = append(args, "--strict") + } + if code := h.run(args...); code != errfmt.ExitGate { + t.Fatalf("exit %d: %s", code, h.stdout.String()) + } + var out struct { + Preflight preflight.Result `json:"preflight"` + } + h.json(&out) + found := false + for _, gate := range out.Preflight.Gates { + if gate.Gate == "tray" { + found = true + if gate.Status != preflight.Fail || gate.Detail != tc.detail { + t.Fatalf("wrong tray failure: %+v", gate) + } + if tc.name != "empty" && !strings.Contains(gate.Fix, "bambu ams set --slot A3") { + t.Fatalf("missing recovery command: %+v", gate) + } + } + } + if !found || len(h.conn.Commands) != 0 { + t.Fatalf("missing gate or dry-run wrote to printer: %+v, %v", out, h.conn.Commands) + } + }) + } + } +} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index d89f0fc..1e07316 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -148,7 +148,7 @@ func Run(in Input) Result { pr := s.Protections g.add("ai_protections", isTrue(pr.FirstLayerInspector) && isTrue(pr.SpaghettiDetector) && isTrue(pr.PrintHalt), Warn, fmt.Sprintf("first_layer_inspector=%s spaghetti_detector=%s print_halt=%s", boolStr(pr.FirstLayerInspector), boolStr(pr.SpaghettiDetector), boolStr(pr.PrintHalt)), - "enable them on the printer (bambu never changes printer settings)") + "enable them on the printer (bambu never changes AI protections)") if in.FTPSCheck != nil { err := in.FTPSCheck() detail := "implicit FTPS login + listing OK" @@ -192,7 +192,18 @@ func filamentGates(s printer.Status, need job.Filament, trayID int, strict bool, g.add("tray", false, Fail, "slot "+label+" not reported by the AMS", "check the AMS is connected, or pick another slot") return g case !tray.Loaded: - g.add("tray", false, Fail, "slot "+label+" is empty", fmt.Sprintf("load %s into AMS slot %s", need.Type, label)) + detail := "slot " + label + " has no material metadata; filament presence is unknown" + fix := fmt.Sprintf("check whether slot %s holds a spool; if so, identify it and configure its material (bambu ams set --slot %s --help); if empty, load %s", label, label, need.Type) + if tray.Present != nil { + if *tray.Present { + detail = "slot " + label + " is untyped (spool detected; no material set)" + fix = "identify the physical spool, then configure its material (bambu ams set --slot " + label + " --help)" + } else { + detail = "slot " + label + " is empty" + fix = fmt.Sprintf("load %s into AMS slot %s", need.Type, label) + } + } + g.add("tray", false, Fail, detail, fix) return g } exact := strings.EqualFold(tray.Type, need.Type) diff --git a/internal/printer/ams.go b/internal/printer/ams.go new file mode 100644 index 0000000..4fb9080 --- /dev/null +++ b/internal/printer/ams.go @@ -0,0 +1,52 @@ +package printer + +import ( + "strconv" + "strings" +) + +// RawAMSTray returns a tray's telemetry by global tray id, or nil if absent. +func RawAMSTray(raw map[string]any, id int) map[string]any { + for _, a := range list(obj(raw["ams"])["ams"]) { + am := obj(a) + if num(am["id"]) != id/4 { + continue + } + for _, t := range list(am["tray"]) { + tm := obj(t) + if num(tm["id"]) == id%4 { + return tm + } + } + } + return nil +} + +// TrayPresent reads the AMS's physical filament detection; missing or malformed telemetry is unknown. +func TrayPresent(raw map[string]any, id int) *bool { + bits, err := strconv.ParseUint(str(obj(raw["ams"])["tray_exist_bits"]), 16, 64) + if err != nil || id < 0 || id >= 16 { + return nil + } + present := bits&(1<For AI agents

FAQ

Does it work without Developer Mode?

Status does. Sending, pausing and stopping need Developer Mode: since the January 2025 "Authorization Control" firmware, printers reject unsigned third-party commands. Developer Mode disconnects Bambu Cloud and the Handy app.

Which printers are supported?

Tested on an X1 Carbon. The X1, X1E, P1, A1, H2 and P2S series share the LAN protocol and should work; camera snapshots currently need an RTSPS camera (X1/H2/P2S series).

-
Will it change my printer settings?

No. There is no command that changes settings. Filament, plate and toggles are up to you.

+
Will it change my printer settings?

ams set records the material in a non-RFID spool while idle, with --confirm and verified read-back. Identify the physical spool first. AI protections and network settings stay read-only.

Is this affiliated with Bambu Lab?

No. It's an independent MIT-licensed project that builds on public protocol documentation (OpenBambuAPI, ha-bambulab).