diff --git a/CHANGELOG.md b/CHANGELOG.md index e66cb8e..460d15d 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 --slot S --type T --filament-id ID --color RRGGBB --nozzle-temp-min T --nozzle-temp-max T`: record a non-RFID spool's metadata with `--confirm`, or preview with `--dry-run`. Requires an idle printer, Developer Mode, and physical filament detection. Verifies every field from fresh tray reports; exits 14 when submitted but unverified. Repeating identical values sends nothing. Flow calibration is reported but unchanged. - 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..6259266 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. ### Exit codes diff --git a/SPEC.md b/SPEC.md index 7eb776f..33de9a0 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,27 @@ 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. + **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. @@ -279,7 +303,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/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<