From 861bf110072fe9d8b7f6dfd71a57fd379638a5a5 Mon Sep 17 00:00:00 2001 From: Matt Voska Date: Fri, 9 Oct 2026 07:09:45 -0300 Subject: [PATCH] feat: two-colour AMS jobs, remote filament load; resume confirms RUNNING - slice: repeatable --filament/--color and --filament-change-z write a layer-height ToolChange (--load-custom-gcodes, MultiAsSingle), so the AMS swaps from the G-code with no pause. The change is read back from the G-code; a slice without it exits 11 and never reaches the output dir. - preflight/print send: one --slot per filament; per-filament gates run against each filament's own slot; ams_mapping maps each filament to its tray. Single-filament payloads are unchanged. - filament load --slot S --confirm: print.ams_change_filament while paused or idle; exit 0 only once the printer reports S in the toolhead with the change over. - fix: print resume exited 0 while the printer was still paused; it now waits for RUNNING (--wait) and refuses mid-change or with an empty toolhead. - job: per-filament settings via filament_self_index / filament_extruder_variant; filament changes parsed from the G-code. --- CHANGELOG.md | 18 ++ README.md | 26 +- SPEC.md | 35 +- docs/protocol.md | 8 +- internal/cmd/filament.go | 199 ++++++++++++ internal/cmd/filament_test.go | 260 +++++++++++++++ internal/cmd/print.go | 110 +++++-- internal/cmd/root.go | 1 + internal/cmd/slice.go | 32 +- internal/job/job.go | 143 ++++++-- internal/job/job_test.go | 6 +- internal/job/multi_test.go | 116 +++++++ internal/job/payload.go | 30 +- .../testdata/single_filament/plate_1.gcode | 108 +++++++ .../job/testdata/single_filament/plate_1.json | 1 + .../single_filament/project_settings.config | 62 ++++ .../job/testdata/single_filament/result.json | 46 +++ .../single_filament/slice_info.config | 36 +++ .../job/testdata/two_filament/plate_1.gcode | 111 +++++++ .../job/testdata/two_filament/plate_1.json | 1 + .../two_filament/project_settings.config | 77 +++++ .../job/testdata/two_filament/result.json | 53 +++ .../testdata/two_filament/slice_info.config | 35 ++ internal/preflight/preflight.go | 147 +++++---- internal/preflight/preflight_test.go | 82 ++++- internal/printer/filament.go | 74 +++++ internal/printer/filament_test.go | 85 +++++ internal/printer/status.go | 4 + internal/printer/testdata/pause.json | 77 +++++ internal/slicer/slice.go | 305 ++++++++++++++---- internal/slicer/slicer_test.go | 173 +++++++++- .../BBL/process/fdm_process_common.json | 2 +- internal/testutil/threemf.go | 38 +++ skills/bambu/SKILL.md | 18 +- skills/bambu/references/COMMANDS.md | 19 +- 35 files changed, 2313 insertions(+), 225 deletions(-) create mode 100644 internal/cmd/filament.go create mode 100644 internal/cmd/filament_test.go create mode 100644 internal/job/multi_test.go create mode 100644 internal/job/testdata/single_filament/plate_1.gcode create mode 100644 internal/job/testdata/single_filament/plate_1.json create mode 100644 internal/job/testdata/single_filament/project_settings.config create mode 100644 internal/job/testdata/single_filament/result.json create mode 100644 internal/job/testdata/single_filament/slice_info.config create mode 100644 internal/job/testdata/two_filament/plate_1.gcode create mode 100644 internal/job/testdata/two_filament/plate_1.json create mode 100644 internal/job/testdata/two_filament/project_settings.config create mode 100644 internal/job/testdata/two_filament/result.json create mode 100644 internal/job/testdata/two_filament/slice_info.config create mode 100644 internal/printer/filament.go create mode 100644 internal/printer/filament_test.go create mode 100644 internal/printer/testdata/pause.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b660b4..5da0cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ JSON field names and exit codes are part of the public API. ## [Unreleased] +### Added +- Two-filament jobs with an automatic AMS colour change at a layer height: + - `slice --filament P1 --color C1 --filament P2 --color C2 --filament-change-z Z` (`--filament` and `--color` repeat once per filament; `--colour` is an alias). Filament 2 starts on the first layer above Z, which must be a layer top. `slice` reads the change back from the G-code and exits 11 without writing to the output dir if Bambu Studio didn't place it. + - `preflight`/`print send` take one `--slot` per filament, in filament order. Each filament's type, profile, amount and temperature gates run against its own slot. `ams_mapping` maps each filament to its slot (e.g. `[3, 0]`). The same slot given twice exits 2. +- `filament load --slot S --confirm [--timeout 5m]`: unload the toolhead and load AMS slot S, e.g. a remote colour change at a pause. It runs only while paused or IDLE/FINISH/FAILED. 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 S in the toolhead with the change finished. It exits 14 if that's unconfirmed and 1 if a new HMS code or print error appears during the change. +- JSON fields (additive): + - `status.ams_status` + - slice summary: `filament_changes`, `prime_tower`, `filament_presets`, and per-filament `model_g`/`waste_g`/`nozzle_temp`/`nozzle_temp_range` + - preflight: `slots`/`tray_ids`, and `filament` on per-filament gate rows of multi-filament jobs + - job: `filament_changes` + +### Changed +- `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 +- `print resume --confirm` exited 0 even when the printer was still paused 3 s later. It now exits 0 only once the printer reports RUNNING (`--wait`, default 30s, must be positive; otherwise exit 14). It also refuses (exit 9) during a filament change, and on X1-series printers when the AMS reports an empty toolhead. +- Per-filament temperatures in multi-filament 3MFs are read through `filament_self_index`/`filament_extruder_variant` instead of plain indexing, which reads the wrong row whenever an earlier filament has more than one nozzle-variant row. + ## [0.2.0] - 2026-10-06 ### Changed diff --git a/README.md b/README.md index 224a1eb..3070ee8 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,21 @@ bambu print send part-solid-pla.gcode.3mf --slot 4 --confirm bambu monitor --snapshot-at-layer 2 --exec 'notify-send "$BAMBU_EVENT $BAMBU_STATE"' ``` +### Two colours + +A two-filament job changes colour at a layer height, and the AMS swaps by itself: no pause, nobody at the printer. + +```bash +bambu slice sign.3mf --recipe prototype-pla \ + --filament "Generic PLA" --color FFFFFF --filament "Bambu PLA Matte" --color 000000 \ + --filament-change-z 4.0 # white through Z 4.0, black from the next layer up +bambu print send sign-prototype-pla.gcode.3mf --slot A4 --slot A1 --dry-run # one slot per filament, in order +``` + +- `--filament-change-z` must be a layer top. `slice` reads the change back from the G-code and fails (exit 11) if Bambu Studio didn't place it. +- The summary has grams per filament, including purge and prime-tower waste, and `filament_changes`. +- To change colour by hand at a pause (e.g. a spool that isn't in the job), `bambu filament load --slot A1 --confirm` swaps the toolhead to another AMS slot remotely, then `bambu print resume --confirm`. + ### Recipes Recipes name presets generically (`"0.20mm Standard"`, `"Bambu PLA Basic"`), and `bambu` resolves the right system preset for your printer model and nozzle. @@ -115,13 +130,16 @@ Recipes name presets generically (`"0.20mm Standard"`, `"Bambu PLA Basic"`), and 1. **`preflight` checks before anything is sent:** - the printer is idle, with no blocking errors (informational HMS codes like "Inspecting first layer" are ignored) - Developer Mode is on and an SD card is present - - the 3MF is intact and sliced for this printer model, with a single filament + - the 3MF is intact and sliced for this printer model, with one `--slot` per filament - the nozzle diameter and plate match - - the slot is loaded with the same filament type (a different product of that type is a WARN) - - there's enough filament (unknown remaining is a WARN), and the temperatures are sane + - each slot is loaded with its filament's type (a different product of that type is a WARN) + - each slot has enough filament (unknown remaining is a WARN), and the temperatures are sane - the printer's AI protections are on (WARN only) and FTPS login works 2. **`print send`** re-runs preflight and refuses on any FAIL. It requires `--confirm`, uploads over FTPS, reads the file back to verify its MD5, then starts the job with bed leveling, flow calibration, vibration compensation and first-layer inspection **on**. -3. **`print pause|resume|stop`** require `--confirm`. `bambu` has no command that changes printer settings. +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. ### Exit codes diff --git a/SPEC.md b/SPEC.md index 2076c35..6862926 100644 --- a/SPEC.md +++ b/SPEC.md @@ -13,7 +13,9 @@ In v0.1: - Headless slicing (STL/3MF/OBJ) with Bambu Studio system presets, flattened by us, plus built-in and user recipes. - Preflight safety gates. - LAN send: implicit FTPS upload, read-back MD5, then the MQTT `project_file` command. -- Pause / resume / stop. +- 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). - 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. @@ -21,7 +23,7 @@ In v0.1: Out of scope for v0.1: - Cloud API and Bambu Connect. -- Multi-material/multi-color send: preflight refuses jobs with more than one filament. +- Per-object or painted multi-colour slicing: `slice` makes multi-filament jobs only from layer-height changes. - STEP input (convert to STL/3MF first). - The P1/A1 camera, which uses the port-6000 JPEG protocol: a clear error for now. - Changing any printer setting. `bambu` never changes printer settings: no xcam/AI toggles, no lights, no temperatures. @@ -49,9 +51,11 @@ Top-level verbs are kept for the high-frequency, single-purpose actions an opera ``` bambu status [--watch] # read-only; --watch streams NDJSON until Ctrl-C bambu slice --recipe R [--filament PRESET] [--set k=v]... [--name N] [--out DIR] [--auto-orient] -bambu preflight --slot S [--no-ftp] -bambu print send --slot S (--dry-run | --confirm) [--timelapse] [--wait 180s] -bambu print pause|resume|stop --confirm +bambu preflight --slot S [--slot S2]... [--no-ftp] +bambu print send --slot S [--slot S2]... (--dry-run | --confirm) [--timelapse] [--wait 180s] +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 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] @@ -155,6 +159,15 @@ Precedence: **Control:** `{"print":{"sequence_id":"","command":"pause"|"resume"|"stop","param":""}}` at QoS 1. +**Filament load** (Bambu Studio `command_ams_change_filament`; verified against X1C firmware 01.12 on 2026-10-08 during a +pause, A4 → A1): `{"print":{"sequence_id":"","command":"ams_change_filament","ams_id":A,"slot_id":S,"target":A*4+S, +"curr_temp":,"tar_temp":}}` (210 when a tray has no range). +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. + +**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. + **HMS:** - Code = `%04X_%04X_%04X_%04X` of attr>>16, attr&0xFFFF, code>>16, code&0xFFFF. - Module = attr>>24; severity = code>>16 (1 fatal, 2 serious, 3 common, 4 info). @@ -188,6 +201,11 @@ Resources are the first of `../Resources`, `../resources`, `../share/BambuStudio **Invocation:** - Absolute `--outputdir` (a work dir under the user cache). Bambu Studio chdirs into its bundle, so relative paths break. - `--slice 0 --arrange 1 --orient 0|1 --load-settings "m.json;p.json" --load-filaments f.json --export-3mf .gcode.3mf ` +- Two or more filaments: `--load-filaments "f.json;f_2.json"`, `--filament-colour "#RRGGBB;#RRGGBB"` (Studio sizes the + purge from the colours) and `--load-custom-gcodes c.json` holding + `{"mode":"MultiAsSingle","gcodes":[{"type":"ToolChange","print_z":,"extruder":2,"color":"#RRGGBB","extra":""}]}`, + the change the GUI's layer slider stores. Studio drops a ToolChange silently when the mode doesn't fit, so `slice` + reads the `T` changes back from the G-code and exits 11 (nothing in ``) unless they match. - Parse `result.json` (`return_code`, `error_string`, `sliced_plates`) and capture untimestamped stderr lines as `details`. **Outputs:** @@ -218,13 +236,14 @@ All recipes use `brim_type = no_brim`. **Brims are opt-in** (`--set brim_type=ou | sdcard | no SD card | | | file_integrity | plate gcode md5 ≠ embedded .md5 | | | sliced_for_printer | 3MF `printer_model` ≠ configured model | | -| single_filament | > 1 filament used | | +| single_filament | number of `--slot`s ≠ filaments in the plate | | | 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) | | 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 | | | | | ai_protections: first_layer_inspector / spaghetti_detector / print_halt off | | ftps_login | login + NLST failed | | @@ -250,7 +269,9 @@ internal/errfmt/ exit codes, typed errors with hints ## 12. Safety rules (non-negotiable) - `print send` requires `--confirm` and a PASS from preflight, re-run at send time. `--dry-run` never connects FTPS for writing and never publishes. -- `print pause|resume|stop` require `--confirm`. +- `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. - 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 223589f..baf4676 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -10,7 +10,9 @@ September 2026). - **Status:** `{"pushing":{"sequence_id":"0","command":"pushall"}}` requests the full status. X1 printers also push about one partial `print.push_status` per second while printing; merge them. (verified) - **Developer Mode:** `print.fun` is a hex bitfield. Bit `0x20000000` means signed commands are required, i.e. Developer Mode is **off**. (verified: `…1A30…` off, `…1830…` on) -- **Control:** `{"print":{"sequence_id":"","command":"pause"|"resume"|"stop","param":""}}` at QoS 1. (not yet verified) +- **Control:** `{"print":{"sequence_id":"","command":"pause"|"resume"|"stop","param":""}}` at QoS 1. (`resume` verified: PAUSE → RUNNING within seconds; `pause`/`stop` not yet verified) +- **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.) ## Start a job: `print.project_file` (verified) @@ -23,7 +25,7 @@ September 2026). ``` - **Reply:** `{"print":{"command":"project_file","result":"SUCCESS",…}}`, and `gcode_state` goes PREPARE/RUNNING within seconds. -- **`ams_mapping`:** Bambu Studio's "v0" format (`SelectMachine.cpp::get_ams_mapping_result`). One entry per project filament; the value is the global tray id (`ams_index*4 + slot`, 0-based); `-1` = unused. `[3]` = first AMS, slot 4 (verified). +- **`ams_mapping`:** Bambu Studio's "v0" format (`SelectMachine.cpp::get_ams_mapping_result`). One entry per project filament; the value is the global tray id (`ams_index*4 + slot`, 0-based); `-1` = unused. `[3]` = first AMS, slot 4 (verified). A two-filament job sends `[3, 0]` for A4 then A1 (from Studio's source; not yet verified on a real print). Studio also sends `ams_mapping2` (`[{ams_id, slot_id}]`); the verified single-filament payload omits it. - **Without Developer Mode**, the reply is a failure mentioning verification and HMS `0500_0500_0001_0007`. `bambu` maps that to exit 6. ## FTPS (upload) @@ -63,3 +65,5 @@ Bambu Studio binds UDP 2021 exclusively while it runs. Broadcasts don't cross VL - **Exit codes:** the shell sees `256 + return_code`. `result.json` holds `return_code`/`error_string`; untimestamped stderr lines hold the reason. - **100% sparse infill** only accepts patterns that are also valid top-surface patterns (e.g. `zig-zag`, `monotonic`, `concentric`). Otherwise it fails with `-18`. - **STEP input** fails with `-6`. +- **Layer-height colour change:** `--load-custom-gcodes c.json` with `{"mode":"MultiAsSingle","gcodes":[{"type":"ToolChange","print_z":Z,"extruder":2,"color":"#RRGGBB","extra":""}]}` (`CustomGCode::Info::from_json`). `print_z` is the first layer printed with the new filament (`IMSlider::add_code_as_tick`). The G-code then carries `M620 S1A` / `T1` / `M621 S1A` at that layer, and the AMS swaps without a pause. Studio drops a ToolChange silently when the mode doesn't fit (`ToolOrdering.cpp`), so read the `T` lines back. `--filament-colour "#A;#B"` sizes the purge between filaments. (verified: 27-layer part, a single `T1` at layer 21 / Z 4.2 for a change after Z 4.0) +- **Per-filament settings:** temperatures that vary by nozzle variant hold one entry per (filament, variant) pair in `project_settings.config`. Look them up through `filament_self_index` and `filament_extruder_variant`; plain indexing reads the wrong filament in a two-filament project. diff --git a/internal/cmd/filament.go b/internal/cmd/filament.go new file mode 100644 index 0000000..c2746cf --- /dev/null +++ b/internal/cmd/filament.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "context" + "slices" + "strconv" + "strings" + "time" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/output" + "github.com/voska/bambu/internal/printer" +) + +// FilamentCmd groups AMS filament commands. +type FilamentCmd struct { + Load FilamentLoadCmd `cmd:"" help:"Unload the toolhead and load an AMS slot: a remote colour change at a pause (requires --confirm)."` +} + +// FilamentLoadCmd swaps the filament in the toolhead. +type FilamentLoadCmd struct { + Slot string `short:"s" required:"" help:"AMS slot to load: 1-4 (first AMS) or A1-D4."` + Confirm bool `help:"Required: the printer cuts the filament in the toolhead, loads the slot and purges. Only as part of an approved job."` + Timeout time.Duration `default:"5m" help:"How long to wait for the printer to report the slot loaded (exit 14 if it doesn't)."` +} + +type loadResult struct { + Printer string `json:"printer"` + Slot string `json:"slot"` + TrayID int `json:"tray_id"` + Before string `json:"toolhead_before"` + After string `json:"toolhead_after"` + Changed bool `json:"changed"` + AMSStatus string `json:"ams_status"` + State string `json:"state"` + Ack map[string]any `json:"ack,omitempty"` +} + +// Run executes the command. +func (c *FilamentLoadCmd) Run(g *Globals) error { + if !c.Confirm { + return errfmt.New(errfmt.ExitGate, "filament load needs --confirm"). + WithHint("it cuts the filament in the toolhead and loads another slot; only as part of an approved job (e.g. the colour change at a pause)") + } + tray, err := printer.ParseSlot(c.Slot) + if err != nil { + return errfmt.Wrap(errfmt.ExitUsage, err, "bad --slot") + } + label := printer.TrayLabel(tray) + 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) + target, active := findTray(before, tray), findTray(before, -1) + if err := loadGates(before, label, target, active); err != nil { + return err + } + res := loadResult{ + Printer: p.Name, Slot: label, TrayID: tray, Before: printer.ToolheadSlot(before.ActiveTray), + After: printer.ToolheadSlot(before.ActiveTray), AMSStatus: before.AMSStatus, State: before.State, + } + if before.ActiveTray != strconv.Itoa(tray) { + ctx, cancel := context.WithTimeout(g.Ctx, 10*time.Second) + ack, err := conn.Command(ctx, "print", printer.AMSChangeFilament(tray, printer.LoadTemp(active), printer.LoadTemp(target))) + cancel() + if err != nil { + return err + } + if err := printer.CheckAck(ack, "load "+label); err != nil { + return err + } + after, err := waitLoaded(g.Ctx, conn, before, tray, c.Timeout) + if err != nil { + return err + } + res.Changed, res.Ack, res.After, res.AMSStatus, res.State = true, ack, label, after.AMSStatus, after.State + } + return g.Out.Print(output.View{ + Data: res, + Human: func(h *output.Human) { + if !res.Changed { + h.Line("%s is already in the toolhead; nothing sent", label) + } else { + h.Line("%s: toolhead %s -> %s ams_status=%s state=%s", h.Good("loaded"), orDash(res.Before), res.After, res.AMSStatus, h.Status(res.State)) + } + if res.State == "PAUSE" { + h.Line("%s", h.Dim("next: check the camera (bambu camera snapshot), then bambu print resume --confirm")) + } + }, + Plain: [][]string{{"slot", "toolhead_before", "toolhead_after", "changed", "state"}, {label, res.Before, res.After, strconv.FormatBool(res.Changed), res.State}}, + Quiet: res.After, + }) +} + +// findTray returns the AMS tray with id, or the one in the toolhead for id -1 (nil if none). +func findTray(s printer.Status, id int) *printer.Tray { + if id < 0 { + n, err := strconv.Atoi(s.ActiveTray) + if err != nil { + return nil + } + id = n + } + for i := range s.AMS { + if s.AMS[i].TrayID == id { + return &s.AMS[i] + } + } + return nil +} + +// loadGates refuses a filament load before anything is sent. +func loadGates(s printer.Status, label string, target, active *printer.Tray) error { + switch { + case !printer.IdleStates[s.State] && s.State != "PAUSE": + return errfmt.New(errfmt.ExitGate, "can't load filament: printer is %q", s.State). + WithHint("only while paused or idle: bambu print pause --confirm first, or wait for the job to end") + case s.DevMode == nil || !*s.DevMode: + return errfmt.New(errfmt.ExitForbidden, "filament load needs Developer Mode (the printer requires signed commands)"). + WithHint("enable Developer Mode on the printer, or use the printer screen") + case printer.FilamentChanging(s): + return errfmt.New(errfmt.ExitGate, "a filament change is already in progress (ams_status=%s stage=%s)", s.AMSStatus, s.Stage). + WithHint("wait for it to finish (`bambu status`)") + } + if codes := blockingHMS(s, nil); len(codes) > 0 { + return errfmt.New(errfmt.ExitGate, "printer has HMS errors %v", codes). + WithHint("fix the cause and clear them on the printer first (see the wiki links in `bambu status`)") + } + if target == nil || !target.Loaded { + return errfmt.New(errfmt.ExitGate, "slot %s is empty", label).WithHint("load filament into AMS slot %s first", label) + } + switch { + case s.State != "PAUSE": + case active == nil: + return errfmt.New(errfmt.ExitGate, "can't check the paused job's material: the toolhead holds no AMS filament (tray_now=%s)", orDash(s.ActiveTray)). + WithHint("look at the camera and use the printer screen") + case !strings.EqualFold(target.Type, active.Type): + return errfmt.New(errfmt.ExitGate, "slot %s has %s but the paused job is printing %s from %s", label, target.Type, active.Type, active.Slot). + WithHint("pick a slot with the same material (`bambu status`)") + } + return nil +} + +// blockingHMS lists non-informational HMS codes that are not in base. +func blockingHMS(s printer.Status, base []string) []string { + var out []string + for _, h := range s.Errors.HMS { + if !h.Informational && !slices.Contains(base, h.Code) { + out = append(out, h.Code) + } + } + return out +} + +// waitLoaded succeeds only once the printer reports tray in the toolhead with the change over. A new HMS code or +// print_error during the change fails it, as does the timeout. +func waitLoaded(ctx context.Context, conn printer.Conn, before printer.Status, tray int, timeout time.Duration) (printer.Status, error) { + label, want := printer.TrayLabel(tray), strconv.Itoa(tray) + var base []string + for _, h := range before.Errors.HMS { + base = append(base, h.Code) + } + var fault []string + s, err := waitState(ctx, conn, timeout, func(s printer.Status) bool { + fault = blockingHMS(s, base) + if pe := s.Errors.PrintError; pe != "" && pe != before.Errors.PrintError { + fault = append(fault, "print_error "+pe) + } + return len(fault) > 0 || (s.ActiveTray == want && !printer.FilamentChanging(s)) + }) + switch { + case len(fault) > 0: + return s, errfmt.New(errfmt.ExitError, "printer reported %v while loading %s", fault, label). + WithHint("check `bambu status` (HMS wiki links) and the camera; don't resume until the toolhead holds the right filament"). + WithData("status", s) + case err != nil: + return s, errfmt.New(errfmt.ExitTimeout, "%s not confirmed loaded %s after the command: toolhead=%s ams_status=%s stage=%s", + label, timeout, orDash(printer.ToolheadSlot(s.ActiveTray)), s.AMSStatus, s.Stage). + WithHint("look at the camera / printer screen before anything else; `bambu status`").WithData("status", s) + } + return s, nil +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/internal/cmd/filament_test.go b/internal/cmd/filament_test.go new file mode 100644 index 0000000..ad04197 --- /dev/null +++ b/internal/cmd/filament_test.go @@ -0,0 +1,260 @@ +package cmd + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/testutil" +) + +// pauseFixture is a recorded report from a G-code pause before a colour change: PAUSE at layer 21, A4 in the toolhead, +// print_error 0300_8013 (paused), ams_status "assist". Trays: A1 PLA Matte, A2 PETG HF, A3 PLA Basic, A4 Generic PLA. +const pauseFixture = "../printer/testdata/pause.json" + +func pausedHarness(t *testing.T, mut func(m map[string]any)) *harness { + t.Helper() + h := newHarness(t, nil) + h.conn = testutil.NewFakeConn(t, pauseFixture, mut) + return h +} + +// setToolhead changes ams.tray_now (and optionally ams_status) the way the printer reports a finished load. +func setToolhead(f *testutil.FakeConn, trayNow string, amsStatus int) { + ams := f.State()["ams"].(map[string]any) + ams["tray_now"] = trayNow + f.Set("ams", ams) + f.Set("ams_status", amsStatus) +} + +type loadOut struct { + Printer string `json:"printer"` + Slot string `json:"slot"` + Before string `json:"toolhead_before"` + After string `json:"toolhead_after"` + Changed bool `json:"changed"` + AMSStatus string `json:"ams_status"` + State string `json:"state"` + Ack map[string]any `json:"ack"` +} + +func TestFilamentLoadAtPause(t *testing.T) { + h := pausedHarness(t, nil) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + f.Set("ams_status", 0x0100) // filament_change: unloading A4, then loading A1 + go func() { + time.Sleep(100 * time.Millisecond) + setToolhead(f, "0", 0x0100) // A1 in the toolhead, still purging: not done yet + time.Sleep(300 * time.Millisecond) + setToolhead(f, "0", 768) + }() + return map[string]any{"command": body["command"], "result": "success"} + } + if code := h.run("filament", "load", "--slot", "A1", "--confirm", "--json"); code != 0 { + t.Fatalf("exit %d: %s %s", code, h.stdout.String(), h.stderr.String()) + } + want := map[string]any{"command": "ams_change_filament", "ams_id": 0, "slot_id": 0, "target": 0, "curr_temp": 215, "tar_temp": 210} + if len(h.conn.Commands) != 1 || !reflect.DeepEqual(h.conn.Commands[0]["print"], want) { + t.Fatalf("commands: %v", h.conn.Commands) + } + var r loadOut + h.json(&r) + if !r.Changed || r.Before != "A4" || r.After != "A1" || r.AMSStatus != "assist" || r.State != "PAUSE" || r.Slot != "A1" { + t.Fatalf("success only once A1 is in and the change is over: %+v", r) + } +} + +func TestFilamentLoadAlreadyLoaded(t *testing.T) { + h := pausedHarness(t, nil) + if code := h.run("filament", "load", "--slot", "A4", "--confirm", "--json"); code != 0 { + t.Fatalf("exit %d: %s", code, h.stderr.String()) + } + var r loadOut + h.json(&r) + if r.Changed || r.After != "A4" || len(h.conn.Commands) != 0 { + t.Fatalf("nothing to send: %+v %v", r, h.conn.Commands) + } +} + +func TestFilamentLoadRefusals(t *testing.T) { + cases := []struct { + name string + args []string + mut func(m map[string]any) + want int + }{ + {"no --confirm", []string{"--slot", "A1"}, nil, errfmt.ExitGate}, + {"bad slot", []string{"--slot", "Z9", "--confirm"}, nil, errfmt.ExitUsage}, + {"printing", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { m["gcode_state"] = "RUNNING" }, errfmt.ExitGate}, + {"preparing", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { m["gcode_state"] = "PREPARE" }, errfmt.ExitGate}, + {"dev mode off", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { m["fun"] = "20011A30F9CFB" }, errfmt.ExitForbidden}, + {"change in progress", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { m["ams_status"] = 0x0100 }, errfmt.ExitGate}, + {"loading stage", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { m["stg_cur"] = 24 }, errfmt.ExitGate}, + {"blocking hms", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { + m["hms"] = []any{map[string]any{"attr": 0x07000200, "code": 0x00020001}} + }, errfmt.ExitGate}, + {"other material than the paused job", []string{"--slot", "A2", "--confirm"}, nil, errfmt.ExitGate}, // A2 PETG, job PLA + {"empty slot", []string{"--slot", "B1", "--confirm"}, nil, errfmt.ExitGate}, + {"paused, toolhead empty: job material unknown", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { + m["ams"].(map[string]any)["tray_now"] = "255" + }, errfmt.ExitGate}, + {"paused on the external spool: job material unknown", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { + m["ams"].(map[string]any)["tray_now"] = "254" + }, errfmt.ExitGate}, + {"unknown state", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { m["gcode_state"] = "INIT" }, errfmt.ExitGate}, + {"no state reported", []string{"--slot", "A1", "--confirm"}, func(m map[string]any) { delete(m, "gcode_state") }, errfmt.ExitGate}, + } + for _, c := range cases { + h := pausedHarness(t, c.mut) + if code := h.run(append([]string{"filament", "load", "--json", "--timeout", "1s"}, c.args...)...); code != c.want { + t.Errorf("%s: exit %d want %d: %s", c.name, code, c.want, h.stdout.String()) + } + if len(h.conn.Commands) != 0 { + t.Errorf("%s: published %v", c.name, h.conn.Commands) + } + } +} + +func TestFilamentLoadIdleAllowsOtherMaterial(t *testing.T) { + h := pausedHarness(t, func(m map[string]any) { m["gcode_state"] = "FINISH" }) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + setToolhead(f, "1", 768) + return map[string]any{"command": body["command"], "result": "success"} + } + if code := h.run("filament", "load", "--slot", "A2", "--confirm", "--json"); code != 0 { + t.Fatalf("idle: no job to match, exit %d: %s", code, h.stderr.String()) + } +} + +func TestFilamentLoadNotConfirmed(t *testing.T) { + cases := []struct { + name string + react func(f *testutil.FakeConn) + want int + }{ + {"never loads", func(*testutil.FakeConn) {}, errfmt.ExitTimeout}, + {"stuck in the change", func(f *testutil.FakeConn) { setToolhead(f, "0", 0x0100) }, errfmt.ExitTimeout}, + {"new hms during the change", func(f *testutil.FakeConn) { + f.Set("hms", []any{map[string]any{"attr": 0x07000200, "code": 0x00020001}}) + }, errfmt.ExitError}, + {"new print_error during the change", func(f *testutil.FakeConn) { f.Set("print_error", 0x07008011) }, errfmt.ExitError}, + } + for _, c := range cases { + h := pausedHarness(t, nil) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + c.react(f) + return map[string]any{"command": body["command"], "result": "success"} + } + if code := h.run("filament", "load", "--slot", "A1", "--confirm", "--timeout", "1s", "--json"); code != c.want { + t.Errorf("%s: exit %d want %d: %s", c.name, code, c.want, h.stdout.String()) + } + } + h := pausedHarness(t, nil) + h.conn.OnCommand = func(_ *testutil.FakeConn, _ string, body map[string]any) map[string]any { + return map[string]any{"command": body["command"], "result": "fail", "reason": "not allowed"} + } + if code := h.run("filament", "load", "--slot", "A1", "--confirm", "--timeout", "1s"); code != errfmt.ExitError { + t.Errorf("rejected: exit %d", code) + } +} + +func TestResumeConfirmsRunning(t *testing.T) { + h := pausedHarness(t, nil) + h.conn.OnCommand = func(_ *testutil.FakeConn, _ string, body map[string]any) map[string]any { + return map[string]any{"command": body["command"], "result": "success"} // acked, but the printer stays paused + } + if code := h.run("print", "resume", "--confirm", "--wait", "1s", "--json"); code != errfmt.ExitTimeout { + t.Fatalf("resume must not report success while still PAUSE: exit %d %s", code, h.stdout.String()) + } + h = pausedHarness(t, nil) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + f.Set("gcode_state", "RUNNING") + return map[string]any{"command": body["command"], "result": "success"} + } + if code := h.run("print", "resume", "--confirm", "--json"); code != 0 { + t.Fatalf("exit %d: %s", code, h.stderr.String()) + } + var r map[string]any + h.json(&r) + if r["state_before"] != "PAUSE" || r["state_after"] != "RUNNING" { + t.Fatalf("%v", r) + } +} + +func TestResumeAlwaysVerifies(t *testing.T) { + for _, wait := range []string{"0s", "-1s"} { + h := pausedHarness(t, nil) + h.conn.OnCommand = func(_ *testutil.FakeConn, _ string, body map[string]any) map[string]any { + return map[string]any{"command": body["command"], "result": "success"} + } + if code := h.run("print", "resume", "--confirm", "--wait", wait); code != errfmt.ExitUsage || len(h.conn.Commands) != 0 { + t.Errorf("--wait %s: exit %d, commands %v", wait, code, h.conn.Commands) + } + } +} + +func TestResumePollsUntilRunning(t *testing.T) { + h := pausedHarness(t, nil) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + go func() { + time.Sleep(700 * time.Millisecond) + f.Set("gcode_state", "RUNNING") + }() + return map[string]any{"command": body["command"], "result": "success"} + } + if code := h.run("print", "resume", "--confirm", "--wait", "5s", "-q"); code != 0 || strings.TrimSpace(h.stdout.String()) != "RUNNING" { + t.Fatalf("exit %d %q", code, h.stdout.String()) + } +} + +func TestResumeEmptyToolheadOnlyGatedWhereVerified(t *testing.T) { + h := pausedHarness(t, func(m map[string]any) { m["ams"].(map[string]any)["tray_now"] = "255" }) + cfg := filepath.Join(h.dir, "config.toml") + _ = os.WriteFile(cfg, []byte("default_printer = \"shop\"\n[printers.shop]\nhost = \"127.0.0.1\"\nserial = \"00M00A000000001\"\nmodel = \"H2D\"\nnozzle = 0.4\nplate = \"textured_plate\"\n"), 0o600) + h.conn.OnCommand = func(f *testutil.FakeConn, _ string, body map[string]any) map[string]any { + f.Set("gcode_state", "RUNNING") + return map[string]any{"command": body["command"], "result": "success"} + } + if code := h.run("print", "resume", "--confirm"); code != 0 { + t.Fatalf("H2D: tray_now 255 isn't known to mean an empty toolhead there; exit %d: %s", code, h.stderr.String()) + } +} + +func TestResumeRefusedMidChange(t *testing.T) { + for name, mut := range map[string]func(m map[string]any){ + "filament change in progress": func(m map[string]any) { m["ams_status"] = 0x0100 }, + "empty toolhead": func(m map[string]any) { m["ams"].(map[string]any)["tray_now"] = "255" }, + } { + h := pausedHarness(t, mut) + if code := h.run("print", "resume", "--confirm"); code != errfmt.ExitGate || len(h.conn.Commands) != 0 { + t.Errorf("%s: exit %d, commands %v", name, code, h.conn.Commands) + } + } +} + +func TestTwoFilamentSend(t *testing.T) { + h := newHarness(t, nil) + f := testutil.FromDir(t, "../job/testdata/two_filament", h.dir, "two") + if code := h.run("print", "send", f, "--slot", "A4", "--slot", "A1", "--dry-run", "--json"); code != 0 { + t.Fatalf("exit %d: %s %s", code, h.stdout.String(), h.stderr.String()) + } + var r sendResult + h.json(&r) + p := r.Plan.MQTT.Payload["print"].(map[string]any) + if !reflect.DeepEqual(p["ams_mapping"], []any{float64(3), float64(0)}) || r.Preflight.Result != "PASS" { + t.Fatalf("payload %v preflight %+v", p["ams_mapping"], r.Preflight) + } + if !reflect.DeepEqual(r.Preflight.Slots, []string{"A4", "A1"}) || r.Preflight.Slot != "A4" { + t.Fatalf("%+v", r.Preflight) + } + if code := h.run("preflight", f, "--slot", "A4", "-q"); code != errfmt.ExitGate { + t.Fatalf("two filaments, one slot: exit %d", code) + } + if code := h.run("preflight", f, "--slot", "A4", "--slot", "4"); code != errfmt.ExitUsage { + t.Fatalf("same slot twice: exit %d", code) + } +} diff --git a/internal/cmd/print.go b/internal/cmd/print.go index 605cbf6..99633bd 100644 --- a/internal/cmd/print.go +++ b/internal/cmd/print.go @@ -4,8 +4,12 @@ import ( "context" "crypto/md5" //nolint:gosec // integrity check of the uploaded file "encoding/hex" + "errors" + "fmt" "io" "os" + "slices" + "strings" "time" "github.com/voska/bambu/internal/config" @@ -19,9 +23,9 @@ import ( // PreflightCmd runs the safety gates. type PreflightCmd struct { - File string `arg:"" help:"Sliced .gcode.3mf." type:"path"` - Slot string `short:"s" required:"" help:"AMS slot: 1-4 (first AMS) or A1-D4."` - NoFTP bool `name:"no-ftp" help:"Skip the FTPS login check."` + 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."` } // Run executes the command. @@ -49,14 +53,22 @@ type preflightCtx struct { code string } -func runPreflight(g *Globals, file, slot string, checkFTP bool) (*preflightCtx, error) { +func runPreflight(g *Globals, file string, slots []string, checkFTP bool) (*preflightCtx, error) { p, err := g.Target() if err != nil { return nil, err } - tray, err := printer.ParseSlot(slot) - if err != nil { - return nil, errfmt.Wrap(errfmt.ExitUsage, err, "bad --slot") + trays := make([]int, 0, len(slots)) + for _, slot := range slots { + tray, err := printer.ParseSlot(slot) + if err != nil { + return nil, errfmt.Wrap(errfmt.ExitUsage, err, "bad --slot") + } + if slices.Contains(trays, tray) { + return nil, errfmt.New(errfmt.ExitUsage, "slot %s given more than once", printer.TrayLabel(tray)). + WithHint("each filament needs its own AMS slot: one --slot per filament, in filament order") + } + trays = append(trays, tray) } j, err := job.Inspect(file, 1) if err != nil { @@ -75,7 +87,7 @@ func runPreflight(g *Globals, file, slot string, checkFTP bool) (*preflightCtx, conn.Close() return nil, err } - in := preflight.Input{Printer: p, Job: j, Status: printer.Summarize(raw), TrayID: tray} + in := preflight.Input{Printer: p, Job: j, Status: printer.Summarize(raw), TrayIDs: trays} if checkFTP { in.FTPSCheck = func() error { ctx, cancel := context.WithTimeout(g.Ctx, 30*time.Second) @@ -112,9 +124,13 @@ func gatesView(pc *preflightCtx) output.View { } func humanGates(h *output.Human, name string, r preflight.Result) { - h.Line("preflight %s (%s, slot %s)", h.Status(r.Result), name, r.Slot) + h.Line("preflight %s (%s, slot %s)", h.Status(r.Result), name, strings.Join(r.Slots, ", ")) for _, gt := range r.Gates { - h.Line(" [%s] %-18s %s", h.Status(gt.Status), gt.Gate, gt.Detail) + detail := gt.Detail + if gt.Filament > 0 { + detail = fmt.Sprintf("filament %d: %s", gt.Filament, detail) + } + h.Line(" [%s] %-18s %s", h.Status(gt.Status), gt.Gate, detail) if gt.Fix != "" { h.Line(" %s %s", h.Dim("→"), h.Dim(gt.Fix)) } @@ -132,7 +148,7 @@ 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:"" help:"AMS slot: 1-4 (first AMS) or A1-D4."` + 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."` @@ -184,7 +200,7 @@ func (c *SendCmd) Run(g *Globals) error { if err != nil { return errfmt.Wrap(errfmt.ExitError, err, "read %s", pc.job.File) } - body := job.ProjectFile(pc.job, remote, pc.result.TrayID, c.Timelapse) + body := job.ProjectFile(pc.job, remote, pc.result.TrayIDs, c.Timelapse) plan.MQTT.Topic, plan.MQTT.QoS = "device/"+pc.p.Serial+"/request", 1 plan.MQTT.Payload = map[string]any{"print": withSeq(body)} res := sendResult{Printer: pc.p.Name, DryRun: c.DryRun, Preflight: pc.result, Plan: plan} @@ -323,15 +339,21 @@ type PauseCmd struct { } // Run executes the command. -func (c *PauseCmd) Run(g *Globals) error { return control(g, "pause", c.Confirm) } +func (c *PauseCmd) Run(g *Globals) error { return control(g, "pause", c.Confirm, 0) } // ResumeCmd resumes a paused job. type ResumeCmd struct { - Confirm bool `help:"Required: only resume once the cause of the pause is fixed."` + Confirm bool `help:"Required: only resume once the cause of the pause is fixed."` + Wait time.Duration `default:"30s" help:"How long to wait for the printer to report RUNNING (exit 14 if it doesn't)."` } // Run executes the command. -func (c *ResumeCmd) Run(g *Globals) error { return control(g, "resume", c.Confirm) } +func (c *ResumeCmd) Run(g *Globals) error { + if c.Wait <= 0 { + return errfmt.New(errfmt.ExitUsage, "--wait must be positive").WithHint("resume always waits for the printer to report RUNNING") + } + return control(g, "resume", c.Confirm, c.Wait) +} // StopCmd aborts the job. type StopCmd struct { @@ -339,9 +361,10 @@ type StopCmd struct { } // Run executes the command. -func (c *StopCmd) Run(g *Globals) error { return control(g, "stop", c.Confirm) } +func (c *StopCmd) Run(g *Globals) error { return control(g, "stop", c.Confirm, 0) } -func control(g *Globals, verb string, confirm bool) error { +// control sends pause/resume/stop. A non-zero wait means: succeed only once the printer reports RUNNING. +func control(g *Globals, verb string, confirm bool, wait time.Duration) error { if !confirm { return errfmt.New(errfmt.ExitGate, "print %s needs --confirm", verb).WithHint("this acts on a real print") } @@ -369,6 +392,14 @@ func control(g *Globals, verb string, confirm bool) error { return errfmt.New(errfmt.ExitForbidden, "%s needs Developer Mode (the printer requires signed commands)", verb). WithHint("enable Developer Mode on the printer, or use the printer screen") } + if verb == "resume" && printer.FilamentChanging(before) { + return errfmt.New(errfmt.ExitGate, "can't resume: a filament change is still in progress (ams_status=%s stage=%s)", before.AMSStatus, before.Stage). + WithHint("wait for it to finish (`bambu status`), then check the camera") + } + if m, _ := printer.LookupModel(p.Model); verb == "resume" && before.ActiveTray == printer.EmptyToolhead && printer.EmptyToolheadKnown(m) { + return errfmt.New(errfmt.ExitGate, "can't resume: the printer reports no filament in the toolhead"). + WithHint("load the job's filament first (bambu filament load --slot N --confirm) and check the camera") + } ctx, cancel := context.WithTimeout(g.Ctx, 10*time.Second) ack, err := conn.Command(ctx, "print", map[string]any{"command": verb, "param": ""}) cancel() @@ -378,11 +409,20 @@ func control(g *Globals, verb string, confirm bool) error { if err := printer.CheckAck(ack, verb); err != nil { return err } - select { - case <-time.After(3 * time.Second): - case <-g.Ctx.Done(): + var after printer.Status + if wait > 0 { + after, err = waitState(g.Ctx, conn, wait, func(s printer.Status) bool { return s.State == "RUNNING" }) + if err != nil { + return errfmt.New(errfmt.ExitTimeout, "%s not confirmed: printer still %s %s after the command", verb, after.State, wait). + WithHint("check the camera / printer screen and `bambu status`").WithData("ack", ack).WithData("state", after.State) + } + } else { + select { + case <-time.After(3 * time.Second): + case <-g.Ctx.Done(): + } + after = printer.Summarize(conn.State()) } - after := printer.Summarize(conn.State()) data := map[string]any{"printer": p.Name, "command": verb, "acked": ack != nil, "state_before": before.State, "state_after": after.State} return g.Out.Print(output.View{ Data: data, @@ -391,3 +431,31 @@ func control(g *Globals, verb string, confirm bool) error { Quiet: after.State, }) } + +// waitState polls the live report (with a full pushall every 15 s, in case a partial report was missed) until done +// returns true. It returns the last status, and a non-nil error when wait expires (or ctx ends) first. +func waitState(ctx context.Context, conn printer.Conn, wait time.Duration, done func(printer.Status) bool) (printer.Status, error) { + ctx, cancel := context.WithTimeout(ctx, wait) + defer cancel() + tick := time.NewTicker(500 * time.Millisecond) + defer tick.Stop() + refresh := time.NewTicker(15 * time.Second) + defer refresh.Stop() + for { + s := printer.Summarize(conn.State()) + if done(s) { + return s, nil + } + select { + case <-ctx.Done(): + if s = printer.Summarize(conn.State()); done(s) { + return s, nil + } + return s, errors.New("not confirmed") + case <-refresh.C: + _, _ = conn.Pushall(ctx) + case <-conn.Updates(): + case <-tick.C: + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 474c8d3..a357301 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -28,6 +28,7 @@ type CLI struct { Slice SliceCmd `cmd:"" help:"Slice a model headlessly with Bambu Studio and a recipe."` 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)."` Monitor MonitorCmd `cmd:"" help:"Follow the current job until it ends; exit code reflects the outcome."` Camera CameraCmd `cmd:"" help:"Printer camera."` Printers PrinterCmd `cmd:"" name:"printer" help:"Manage configured printers."` diff --git a/internal/cmd/slice.go b/internal/cmd/slice.go index 5845ae0..cc50c8f 100644 --- a/internal/cmd/slice.go +++ b/internal/cmd/slice.go @@ -16,13 +16,15 @@ import ( // SliceCmd slices a model. type SliceCmd struct { - Model string `arg:"" help:"Model file (.stl, .3mf, .obj)." type:"path"` - Recipe string `short:"r" required:"" help:"Recipe name (see: bambu recipe list)."` - Filament string `short:"f" help:"Filament preset to use instead of the recipe's (e.g. \"Generic PLA\", \"Bambu PLA Matte\")."` - Set []string `short:"s" help:"Override a Bambu Studio setting: key=value (repeatable)." placeholder:"KEY=VALUE"` - Name string `short:"n" help:"Output base name (default -)."` - Out string `short:"o" help:"Output directory (default: config output_dir, else current dir)." type:"path"` - AutoOrient bool `name:"auto-orient" help:"Let the slicer re-orient the model (default: keep the model's orientation)."` + Model string `arg:"" help:"Model file (.stl, .3mf, .obj)." type:"path"` + Recipe string `short:"r" required:"" help:"Recipe name (see: bambu recipe list)."` + Filament []string `short:"f" sep:"none" help:"Filament preset to use instead of the recipe's (e.g. \"Generic PLA\", \"Bambu PLA Matte\"). Repeat once per filament, in print order, for a multi-colour job."` + Color []string `name:"color" aliases:"colour" sep:"none" placeholder:"RRGGBB" help:"Spool colour per --filament, same order (e.g. FFFFFF). Required with two or more filaments: Bambu Studio sizes the purge between them from the colours."` + ChangeZ []float64 `name:"filament-change-z" sep:"none" placeholder:"Z" help:"The next --filament starts on the first layer above Z mm (the top of the previous filament's last layer); the AMS swaps automatically. Once per filament after the first."` + Set []string `short:"s" help:"Override a Bambu Studio setting: key=value (repeatable)." placeholder:"KEY=VALUE"` + Name string `short:"n" help:"Output base name (default -)."` + Out string `short:"o" help:"Output directory (default: config output_dir, else current dir)." type:"path"` + AutoOrient bool `name:"auto-orient" help:"Let the slicer re-orient the model (default: keep the model's orientation)."` } // Run executes the command. @@ -68,7 +70,7 @@ func (c *SliceCmd) Run(g *Globals) error { st.DetectVersion(g.Ctx) sum, err := slicer.Run(g.Ctx, st, ix, slicer.Request{ Model: c.Model, Recipe: r, MachinePreset: m.MachinePreset(p.NozzleString()), Plate: p.Plate, - Filament: c.Filament, Sets: c.Set, Name: name, OutDir: out, + Filaments: c.Filament, Colors: c.Color, ChangeZs: c.ChangeZ, Sets: c.Set, Name: name, OutDir: out, WorkDir: filepath.Join(cache, "bambu", "slice", name), AutoOrient: c.AutoOrient, }) if err != nil { @@ -101,7 +103,19 @@ func humanSlice(h *output.Human, s *slicer.Summary) { h.Line(" time %s (%d s)", s.Time, s.TimeS) h.Line(" filament %.2f g %s (%s) layers %d", s.WeightG, f.Type, f.ID, s.Layers) h.Line(" temps bed %s C (%s) nozzle %s C, first layer %s C", s.BedTemp, s.BedType, s.NozzleTemp, s.NozzleFirst) - h.Line(" presets %s | %s | %s", s.Presets["machine"], s.Presets["process"], s.Presets["filament"]) + h.Line(" presets %s | %s | %s", s.Presets["machine"], s.Presets["process"], strings.Join(s.FilamentPresets, " + ")) + if len(s.Filaments) > 1 { + for _, f := range s.Filaments { + line := fmt.Sprintf(" filament %d %s %s %s %.2f g", f.Index+1, f.Type, f.FilamentID, f.Color, f.UsedG) + if f.ModelG != nil && f.WasteG != nil { + line += fmt.Sprintf(" (model %.2f + purge/prime tower %.2f)", *f.ModelG, *f.WasteG) + } + h.Line("%s", line) + } + for _, c := range s.FilamentChanges { + h.Line(" change filament %d from layer %d (Z %g), automatic AMS swap", c.Filament, c.Layer, c.Z) + } + } keys := make([]string, 0, len(s.Settings)) for k := range s.Settings { if k != "nozzle_temperature" && k != "nozzle_temperature_initial_layer" { diff --git a/internal/job/job.go b/internal/job/job.go index 45c5fa6..74a6d44 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -41,39 +41,51 @@ var KeySettings = []string{ // Filament is one filament used by the plate. type Filament struct { - Index int `json:"index"` // 0-based project filament index - Type string `json:"type"` - FilamentID string `json:"filament_id"` - Color string `json:"color"` - UsedG float64 `json:"used_g"` - UsedM float64 `json:"used_m"` + Index int `json:"index"` // 0-based project filament index + Type string `json:"type"` + FilamentID string `json:"filament_id"` + Color string `json:"color"` + UsedG float64 `json:"used_g"` // includes its share of purge and prime tower + UsedM float64 `json:"used_m"` + ModelG *float64 `json:"model_g,omitempty"` // set by slice: model + support only + WasteG *float64 `json:"waste_g,omitempty"` // set by slice: purge + prime tower + NozzleTemp string `json:"nozzle_temp,omitempty"` + NozzleTempRange []string `json:"nozzle_temp_range,omitempty"` +} + +// FilamentChange is an AMS filament change inside the printed layers: Filament (1-based) starts on Layer, at Z. +type FilamentChange struct { + Layer int `json:"layer"` + Z float64 `json:"z"` + Filament int `json:"filament"` } // Job is what bambu needs to know about a sliced plate. type Job struct { - File string `json:"file"` - Plate int `json:"plate"` - GcodeParam string `json:"gcode_param"` - GcodeMD5 string `json:"gcode_md5"` - GcodeMD5OK *bool `json:"gcode_md5_ok"` - PrinterModel string `json:"printer_model"` - PrinterPreset string `json:"printer_preset"` - ProcessPreset string `json:"process_preset"` - FilamentPresets []string `json:"filament_presets"` - NozzleDiameter string `json:"nozzle_diameter"` - NozzleType string `json:"nozzle_type"` - BedType string `json:"bed_type"` - BedTemp string `json:"bed_temp"` - NozzleTemp string `json:"nozzle_temp"` - NozzleTempFirst string `json:"nozzle_temp_initial"` - NozzleTempRange [2]string `json:"nozzle_temp_range"` - Filaments []Filament `json:"filaments"` - PredictionS int `json:"time_s"` - TotalTime string `json:"time"` - WeightG float64 `json:"weight_g"` - Layers int `json:"layers"` - Settings map[string]any `json:"settings"` - Objects []string `json:"objects"` + File string `json:"file"` + Plate int `json:"plate"` + GcodeParam string `json:"gcode_param"` + GcodeMD5 string `json:"gcode_md5"` + GcodeMD5OK *bool `json:"gcode_md5_ok"` + PrinterModel string `json:"printer_model"` + PrinterPreset string `json:"printer_preset"` + ProcessPreset string `json:"process_preset"` + FilamentPresets []string `json:"filament_presets"` + NozzleDiameter string `json:"nozzle_diameter"` + NozzleType string `json:"nozzle_type"` + BedType string `json:"bed_type"` + BedTemp string `json:"bed_temp"` + NozzleTemp string `json:"nozzle_temp"` + NozzleTempFirst string `json:"nozzle_temp_initial"` + NozzleTempRange [2]string `json:"nozzle_temp_range"` + Filaments []Filament `json:"filaments"` + FilamentChanges []FilamentChange `json:"filament_changes"` + PredictionS int `json:"time_s"` + TotalTime string `json:"time"` + WeightG float64 `json:"weight_g"` + Layers int `json:"layers"` + Settings map[string]any `json:"settings"` + Objects []string `json:"objects"` } type sliceInfo struct { @@ -99,6 +111,7 @@ type sliceInfo struct { var ( reLayers = regexp.MustCompile(`total layer number: (\d+)`) reTime = regexp.MustCompile(`total estimated time: ([^\n;]+)`) + reTool = regexp.MustCompile(`^T(\d+)$`) ) // Inspect reads a sliced .gcode.3mf. @@ -148,6 +161,7 @@ func Inspect(path string, plate int) (*Job, error) { if m := reTime.FindStringSubmatch(head); m != nil { j.TotalTime = strings.TrimSpace(m[1]) } + j.FilamentChanges = FilamentChanges(gcode) ps := map[string]any{} if b, ok := read("Metadata/project_settings.config"); ok { @@ -175,7 +189,14 @@ func Inspect(path string, plate int) (*Job, error) { id, _ := strconv.Atoi(fl.ID) g, _ := strconv.ParseFloat(fl.UsedG, 64) m, _ := strconv.ParseFloat(fl.UsedM, 64) - j.Filaments = append(j.Filaments, Filament{Index: id - 1, Type: fl.Type, FilamentID: fl.TrayID, Color: fl.Color, UsedG: g, UsedM: m}) + i := id - 1 + j.Filaments = append(j.Filaments, Filament{ + Index: i, Type: fl.Type, FilamentID: fl.TrayID, Color: fl.Color, UsedG: g, UsedM: m, + NozzleTemp: filamentSetting(ps, "nozzle_temperature", i), + NozzleTempRange: []string{ + filamentSetting(ps, "nozzle_temperature_range_low", i), filamentSetting(ps, "nozzle_temperature_range_high", i), + }, + }) } } idx := 0 @@ -198,10 +219,10 @@ func Inspect(path string, plate int) (*Job, error) { if j.BedType == "" { j.BedType = BedTypes[pick(ps, "curr_bed_type", 0)] } - j.BedTemp = pick(ps, PlateTempKey[j.BedType], idx) - j.NozzleTemp = pick(ps, "nozzle_temperature", idx) - j.NozzleTempFirst = pick(ps, "nozzle_temperature_initial_layer", idx) - j.NozzleTempRange = [2]string{pick(ps, "nozzle_temperature_range_low", idx), pick(ps, "nozzle_temperature_range_high", idx)} + j.BedTemp = filamentSetting(ps, PlateTempKey[j.BedType], idx) + j.NozzleTemp = filamentSetting(ps, "nozzle_temperature", idx) + j.NozzleTempFirst = filamentSetting(ps, "nozzle_temperature_initial_layer", idx) + j.NozzleTempRange = [2]string{filamentSetting(ps, "nozzle_temperature_range_low", idx), filamentSetting(ps, "nozzle_temperature_range_high", idx)} for _, k := range KeySettings { if v, ok := ps[k]; ok { j.Settings[k] = v @@ -229,6 +250,60 @@ func pick(ps map[string]any, key string, i int) string { return "" } +// filamentSetting is filament i's (0-based) value of a project setting. Settings that vary by extruder variant hold one +// entry per (filament, variant) pair, found through filament_self_index and filament_extruder_variant; the installed +// nozzle's variant is " " (PrintConfig get_index_for_extruder). Plain indexing reads +// the wrong row whenever an earlier filament has more than one variant (rows: f1 Standard, f1 High Flow, f2 Standard). +func filamentSetting(ps map[string]any, key string, i int) string { + v, ok := ps[key].([]any) + self, variants := strs(ps["filament_self_index"]), strs(ps["filament_extruder_variant"]) + if !ok || len(v) != len(self) || len(v) != len(variants) { + return pick(ps, key, i) + } + want := pick(ps, "extruder_type", 0) + " " + pick(ps, "nozzle_volume_type", 0) + row := -1 + for k, f := range self { + if f != strconv.Itoa(i+1) { + continue + } + if row < 0 || variants[k] == want { + row = k + } + if variants[k] == want { + break + } + } + if row < 0 { + return "" + } + s, _ := v[row].(string) + return s +} + +// FilamentChanges lists the AMS filament changes inside the printed layers. Before layer 1 the start G-code loads the +// first filament (T0) and runs T1000/T1100 macros; T255 at the end unloads. Only T0..T254 after a layer marker count. +func FilamentChanges(gcode []byte) []FilamentChange { + out := []FilamentChange{} + layer, z := 0, 0.0 + for _, line := range strings.Split(string(gcode), "\n") { + switch { + case strings.HasPrefix(line, "; Z_HEIGHT:"): + z, _ = strconv.ParseFloat(strings.TrimSpace(strings.TrimPrefix(line, "; Z_HEIGHT:")), 64) + case strings.HasPrefix(line, "; layer num/total_layer_count:"): + n, _, _ := strings.Cut(strings.TrimSpace(strings.TrimPrefix(line, "; layer num/total_layer_count:")), "/") + layer, _ = strconv.Atoi(n) + case layer > 0: + code, _, _ := strings.Cut(line, ";") + if m := reTool.FindStringSubmatch(strings.TrimSpace(code)); m != nil { + if t, _ := strconv.Atoi(m[1]); t < 255 { + out = append(out, FilamentChange{Layer: layer, Z: z, Filament: t + 1}) + } + } + } + } + return out +} + func strs(v any) []string { switch t := v.(type) { case string: diff --git a/internal/job/job_test.go b/internal/job/job_test.go index e253d49..ffb6539 100644 --- a/internal/job/job_test.go +++ b/internal/job/job_test.go @@ -52,7 +52,7 @@ func TestInspectErrors(t *testing.T) { func TestProjectFileVerifiedPayload(t *testing.T) { p := testutil.Write(t, t.TempDir(), "part", testutil.Opts{}) j, _ := Inspect(p, 1) - got := ProjectFile(j, RemoteName(p), 3, false) + got := ProjectFile(j, RemoteName(p), []int{3}, false) want := map[string]any{ "command": "project_file", "param": "Metadata/plate_1.gcode", "url": "file:///sdcard/part.gcode.3mf", "subtask_name": "part", "md5": j.GcodeMD5, "project_id": "0", "profile_id": "0", "task_id": "0", "subtask_id": "0", @@ -66,10 +66,10 @@ func TestProjectFileVerifiedPayload(t *testing.T) { func TestAMSMapping(t *testing.T) { j := &Job{FilamentPresets: []string{"a", "b", "c"}, Filaments: []Filament{{Index: 1}}} - if got := AMSMapping(j, 2); !reflect.DeepEqual(got, []int{-1, 2, -1}) { + if got := AMSMapping(j, []int{2}); !reflect.DeepEqual(got, []int{-1, 2, -1}) { t.Fatal(got) } - if got := AMSMapping(&Job{}, 0); !reflect.DeepEqual(got, []int{0}) { + if got := AMSMapping(&Job{}, []int{0}); !reflect.DeepEqual(got, []int{0}) { t.Fatal(got) } } diff --git a/internal/job/multi_test.go b/internal/job/multi_test.go new file mode 100644 index 0000000..c0d58ec --- /dev/null +++ b/internal/job/multi_test.go @@ -0,0 +1,116 @@ +package job + +import ( + "reflect" + "testing" + + "github.com/voska/bambu/internal/testutil" +) + +// testdata/{single,two}_filament are trimmed recordings of real Bambu Studio 02.08 slices of the same 27-layer part: +// one filament, and two filaments with a layer-height change (filament 2 from layer 21, Z 4.2). +func inspectRecorded(t *testing.T, name string) *Job { + t.Helper() + j, err := Inspect(testutil.FromDir(t, "testdata/"+name, t.TempDir(), name), 1) + if err != nil { + t.Fatal(err) + } + return j +} + +func TestInspectTwoFilaments(t *testing.T) { + j := inspectRecorded(t, "two_filament") + if len(j.Filaments) != 2 || j.Filaments[0].FilamentID != "GFL99" || j.Filaments[1].FilamentID != "GFA01" || j.Filaments[1].Index != 1 { + t.Fatalf("filaments: %+v", j.Filaments) + } + if want := []FilamentChange{{Layer: 21, Z: 4.2, Filament: 2}}; !reflect.DeepEqual(j.FilamentChanges, want) { + t.Fatalf("changes: %+v", j.FilamentChanges) + } + for _, f := range j.Filaments { + if f.NozzleTemp != "220" || !reflect.DeepEqual(f.NozzleTempRange, []string{"190", "240"}) { + t.Fatalf("per-filament temps: %+v", f) + } + } +} + +func TestInspectSingleFilamentHasNoChanges(t *testing.T) { + j := inspectRecorded(t, "single_filament") + if len(j.Filaments) != 1 || j.FilamentChanges == nil || len(j.FilamentChanges) != 0 { + t.Fatalf("%+v %+v", j.Filaments, j.FilamentChanges) + } + if j.NozzleTemp != "220" || j.NozzleTempRange != [2]string{"190", "240"} || j.BedTemp != "55" { + t.Fatalf("temps: %+v", j) + } +} + +func TestFilamentSetting(t *testing.T) { + std := map[string]any{"extruder_type": []any{"Direct Drive"}, "nozzle_volume_type": []any{"Standard"}} + with := func(kv map[string]any) map[string]any { + m := map[string]any{} + for k, v := range std { + m[k] = v + } + for k, v := range kv { + m[k] = v + } + return m + } + arr := func(s ...string) []any { + out := make([]any, len(s)) + for i, x := range s { + out[i] = x + } + return out + } + cases := []struct { + name string + ps map[string]any + key string + want []string + }{ + {"per variant, recorded layout", with(map[string]any{ + "nozzle_temperature": arr("220", "230", "240"), "filament_self_index": arr("1", "2", "2"), + "filament_extruder_variant": arr("Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"), + }), "nozzle_temperature", []string{"220", "230"}}, + {"first filament has two variants, high flow first", with(map[string]any{ + "nozzle_temperature": arr("250", "220", "230"), "filament_self_index": arr("1", "1", "2"), + "filament_extruder_variant": arr("Direct Drive High Flow", "Direct Drive Standard", "Direct Drive Standard"), + }), "nozzle_temperature", []string{"220", "230"}}, + {"high flow nozzle installed", map[string]any{ + "extruder_type": arr("Direct Drive"), "nozzle_volume_type": arr("High Flow"), + "nozzle_temperature": arr("220", "230", "240"), "filament_self_index": arr("1", "2", "2"), + "filament_extruder_variant": arr("Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"), + }, "nozzle_temperature", []string{"220", "240"}}, + {"per filament, not per variant", with(map[string]any{ + "nozzle_temperature_range_low": arr("190", "200"), "filament_self_index": arr("1", "2", "2"), + "filament_extruder_variant": arr("Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"), + }), "nozzle_temperature_range_low", []string{"190", "200"}}, + {"no variant keys", map[string]any{"nozzle_temperature": arr("220")}, "nozzle_temperature", []string{"220", "220"}}, + } + for _, c := range cases { + got := []string{filamentSetting(c.ps, c.key, 0), filamentSetting(c.ps, c.key, 1)} + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: %v want %v", c.name, got, c.want) + } + } +} + +func TestFilamentChanges(t *testing.T) { + g := []byte("M620 S0A\n T0\nM621 S0A\nT1000\n; CHANGE_LAYER\n; Z_HEIGHT: 0.2\n; layer num/total_layer_count: 1/3\n" + + "; CHANGE_LAYER\n; Z_HEIGHT: 0.4\n; layer num/total_layer_count: 2/3\nM620 S1A\nT1 ; change\nM621 S1A\n" + + "; CHANGE_LAYER\n; Z_HEIGHT: 0.6\n; layer num/total_layer_count: 3/3\n T2\nM620 S255\nT255\nM621 S255\n") + want := []FilamentChange{{Layer: 2, Z: 0.4, Filament: 2}, {Layer: 3, Z: 0.6, Filament: 3}} + if got := FilamentChanges(g); !reflect.DeepEqual(got, want) { + t.Fatalf("%+v", got) + } +} + +func TestAMSMappingPerFilament(t *testing.T) { + j := inspectRecorded(t, "two_filament") + if got := AMSMapping(j, []int{3, 0}); !reflect.DeepEqual(got, []int{3, 0}) { + t.Fatal(got) + } + if got := ProjectFile(j, "p.gcode.3mf", []int{3, 0}, false)["ams_mapping"]; !reflect.DeepEqual(got, []int{3, 0}) { + t.Fatal(got) + } +} diff --git a/internal/job/payload.go b/internal/job/payload.go index 0dadea7..c9915ff 100644 --- a/internal/job/payload.go +++ b/internal/job/payload.go @@ -16,24 +16,26 @@ func RemoteName(local string) string { } // AMSMapping returns Bambu Studio's "v0" ams_mapping: one entry per project filament, value = global tray id -// (ams_index*4 + slot_index), -1 for unused (SelectMachine.cpp get_ams_mapping_result). -func AMSMapping(j *Job, trayID int) []int { - n := len(j.FilamentPresets) - if n < 1 { - n = 1 +// (ams_index*4 + slot_index), -1 for unused (SelectMachine.cpp get_ams_mapping_result). trayIDs[n] feeds the job's +// nth used filament, in filament order. +func AMSMapping(j *Job, trayIDs []int) []int { + used := j.Filaments + if len(used) == 0 { + used = []Filament{{Index: 0}} } - idx := 0 - if len(j.Filaments) > 0 { - idx = j.Filaments[0].Index - } - if idx >= n { - n = idx + 1 + n := max(len(j.FilamentPresets), 1) + for _, f := range used { + n = max(n, f.Index+1) } m := make([]int, n) for i := range m { m[i] = -1 } - m[idx] = trayID + for k, f := range used { + if k < len(trayIDs) { + m[f.Index] = trayIDs[k] + } + } return m } @@ -41,7 +43,7 @@ func AMSMapping(j *Job, trayID int) []int { // // Verified on an X1 Carbon (firmware 01.12, Developer Mode) on 2026-09-23: this field set returns // result "SUCCESS" and the printer goes RUNNING within seconds. sequence_id is filled in by the sender. -func ProjectFile(j *Job, remote string, trayID int, timelapse bool) map[string]any { +func ProjectFile(j *Job, remote string, trayIDs []int, timelapse bool) map[string]any { return map[string]any{ "command": "project_file", "param": j.GcodeParam, @@ -59,6 +61,6 @@ func ProjectFile(j *Job, remote string, trayID int, timelapse bool) map[string]a "vibration_cali": true, "layer_inspect": true, "use_ams": true, - "ams_mapping": AMSMapping(j, trayID), + "ams_mapping": AMSMapping(j, trayIDs), } } diff --git a/internal/job/testdata/single_filament/plate_1.gcode b/internal/job/testdata/single_filament/plate_1.gcode new file mode 100644 index 0000000..eda94c8 --- /dev/null +++ b/internal/job/testdata/single_filament/plate_1.gcode @@ -0,0 +1,108 @@ +; HEADER_BLOCK_START +; BambuStudio 02.08.02.61 +; model printing time: 33m 53s; total estimated time: 41m 15s +; total layer number: 27 +; total filament length [mm] : 6279.41 +; total filament volume [cm^3] : 15103.75 +; total filament weight [g] : 18.73 +; filament_density: 1.24 +; filament_diameter: 1.75 +; max_z_height: 5.40 +; filament: 1 +; support_material_on_wipe_tower: 0 +; HEADER_BLOCK_END +M620 S0A ; switch material if AMS exist + T0 +M621 S0A +T1000 + T1000 + T1000 +T1100 + T1100 + T1100 + T1000 +T1000 +; CHANGE_LAYER +; Z_HEIGHT: 0.2 +; layer num/total_layer_count: 1/27 +; CHANGE_LAYER +; Z_HEIGHT: 0.4 +; layer num/total_layer_count: 2/27 +; CHANGE_LAYER +; Z_HEIGHT: 0.6 +; layer num/total_layer_count: 3/27 +; CHANGE_LAYER +; Z_HEIGHT: 0.8 +; layer num/total_layer_count: 4/27 +; CHANGE_LAYER +; Z_HEIGHT: 1 +; layer num/total_layer_count: 5/27 +; CHANGE_LAYER +; Z_HEIGHT: 1.2 +; layer num/total_layer_count: 6/27 +; CHANGE_LAYER +; Z_HEIGHT: 1.4 +; layer num/total_layer_count: 7/27 +; CHANGE_LAYER +; Z_HEIGHT: 1.6 +; layer num/total_layer_count: 8/27 +; CHANGE_LAYER +; Z_HEIGHT: 1.8 +; layer num/total_layer_count: 9/27 +; CHANGE_LAYER +; Z_HEIGHT: 2 +; layer num/total_layer_count: 10/27 +; CHANGE_LAYER +; Z_HEIGHT: 2.2 +; layer num/total_layer_count: 11/27 +; CHANGE_LAYER +; Z_HEIGHT: 2.4 +; layer num/total_layer_count: 12/27 +; CHANGE_LAYER +; Z_HEIGHT: 2.6 +; layer num/total_layer_count: 13/27 +; CHANGE_LAYER +; Z_HEIGHT: 2.8 +; layer num/total_layer_count: 14/27 +; CHANGE_LAYER +; Z_HEIGHT: 3 +; layer num/total_layer_count: 15/27 +; CHANGE_LAYER +; Z_HEIGHT: 3.2 +; layer num/total_layer_count: 16/27 +; CHANGE_LAYER +; Z_HEIGHT: 3.4 +; layer num/total_layer_count: 17/27 +; CHANGE_LAYER +; Z_HEIGHT: 3.6 +; layer num/total_layer_count: 18/27 +; CHANGE_LAYER +; Z_HEIGHT: 3.8 +; layer num/total_layer_count: 19/27 +; CHANGE_LAYER +; Z_HEIGHT: 4 +; layer num/total_layer_count: 20/27 +; CHANGE_LAYER +; Z_HEIGHT: 4.2 +; layer num/total_layer_count: 21/27 +; CHANGE_LAYER +; Z_HEIGHT: 4.4 +; layer num/total_layer_count: 22/27 +; CHANGE_LAYER +; Z_HEIGHT: 4.6 +; layer num/total_layer_count: 23/27 +; CHANGE_LAYER +; Z_HEIGHT: 4.8 +; layer num/total_layer_count: 24/27 +; CHANGE_LAYER +; Z_HEIGHT: 5 +; layer num/total_layer_count: 25/27 +; CHANGE_LAYER +; Z_HEIGHT: 5.2 +; layer num/total_layer_count: 26/27 +; CHANGE_LAYER +; Z_HEIGHT: 5.4 +; layer num/total_layer_count: 27/27 +M620 S255 +T255 +M621 S255 diff --git a/internal/job/testdata/single_filament/plate_1.json b/internal/job/testdata/single_filament/plate_1.json new file mode 100644 index 0000000..7cc46aa --- /dev/null +++ b/internal/job/testdata/single_filament/plate_1.json @@ -0,0 +1 @@ +{"bbox_all":[84.00341,84.00341,171.99657000000002,171.99657000000002],"bbox_objects":[{"area":6081.49365234375,"bbox":[84.00341,84.00341,171.99657000000002,171.99657000000002],"id":28,"layer_height":0.20000000298023224,"name":"CadQuery Component"}],"bed_type":"textured_plate","filament_colors":["#00AE42"],"filament_ids":[0],"first_extruder":0,"first_layer_time":0.0,"is_seq_print":false,"nozzle_diameter":0.4000000059604645,"version":2} \ No newline at end of file diff --git a/internal/job/testdata/single_filament/project_settings.config b/internal/job/testdata/single_filament/project_settings.config new file mode 100644 index 0000000..a1bf997 --- /dev/null +++ b/internal/job/testdata/single_filament/project_settings.config @@ -0,0 +1,62 @@ +{ + "printer_model": "Bambu Lab X1 Carbon", + "printer_settings_id": "Bambu Lab X1 Carbon 0.4 nozzle", + "print_settings_id": "0.20mm Standard @BBL X1C", + "filament_settings_id": [ + "Generic PLA" + ], + "nozzle_diameter": [ + "0.4" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel" + ], + "curr_bed_type": "Textured PEI Plate", + "textured_plate_temp": [ + "55" + ], + "nozzle_temperature": [ + "220" + ], + "nozzle_temperature_initial_layer": [ + "220" + ], + "nozzle_temperature_range_low": [ + "190" + ], + "nozzle_temperature_range_high": [ + "240" + ], + "filament_self_index": [ + "1" + ], + "filament_extruder_variant": [ + "Direct Drive Standard" + ], + "extruder_type": [ + "Direct Drive" + ], + "nozzle_volume_type": [ + "Standard" + ], + "filament_type": [ + "PLA" + ], + "filament_ids": [ + "GFL99" + ], + "filament_colour": [ + "#00AE42" + ], + "layer_height": "0.2", + "initial_layer_print_height": "0.2", + "wall_loops": "2", + "top_shell_layers": "5", + "bottom_shell_layers": "3", + "sparse_infill_density": "15%", + "sparse_infill_pattern": "gyroid", + "brim_type": "no_brim", + "enable_support": "0", + "enable_prime_tower": "1" +} diff --git a/internal/job/testdata/single_filament/result.json b/internal/job/testdata/single_filament/result.json new file mode 100644 index 0000000..ff45e98 --- /dev/null +++ b/internal/job/testdata/single_filament/result.json @@ -0,0 +1,46 @@ +{ + "return_code": 0, + "error_string": "Success.", + "sliced_plates": [ + { + "filaments": [ + { + "filament_id": "GFL99", + "id": 1, + "main_used_g": 18.72865104675293, + "total_used_g": 18.72865104675293 + } + ], + "feature_type_times": { + "Bottom surface": 159.02462768554688, + "Bridge": 271.5497131347656, + "Custom": 428.9681091308594, + "Flush": 29.0, + "Gap infill": 32.95169448852539, + "Inner wall": 98.78113555908203, + "Internal solid infill": 646.0354614257812, + "Outer wall": 152.99423217773438, + "Overhang wall": 0.3451322615146637, + "Sparse infill": 292.5027770996094, + "Top surface": 210.83197021484375, + "Travel": 168.2787322998047, + "Undefined": 152.80227661132812 + }, + "filament_change_times": 0, + "warning_message": "It seems object CadQuery Component has floating regions. Please re-orient the object or enable support generation.", + "objects": [ + { + "name": "CadQuery Component", + "bbox": { + "depth": 98.40521240234375, + "height": 5.400000095367432, + "width": 98.40521240234375, + "x": 78.7973861694336, + "y": 78.79739379882812, + "z": 4.440892098500626e-16 + } + } + ] + } + ] +} diff --git a/internal/job/testdata/single_filament/slice_info.config b/internal/job/testdata/single_filament/slice_info.config new file mode 100644 index 0000000..cac9f65 --- /dev/null +++ b/internal/job/testdata/single_filament/slice_info.config @@ -0,0 +1,36 @@ + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/job/testdata/two_filament/plate_1.gcode b/internal/job/testdata/two_filament/plate_1.gcode new file mode 100644 index 0000000..7e814ab --- /dev/null +++ b/internal/job/testdata/two_filament/plate_1.gcode @@ -0,0 +1,111 @@ +; HEADER_BLOCK_START +; BambuStudio 02.08.02.61 +; model printing time: 37m 28s; total estimated time: 44m 51s +; total layer number: 27 +; total filament length [mm] : 5694.71,982.06 +; total filament volume [cm^3] : 13697.38,2362.13 +; total filament weight [g] : 16.98,3.12 +; filament_density: 1.24,1.32 +; filament_diameter: 1.75,1.75 +; max_z_height: 5.40 +; filament: 1,2 +; support_material_on_wipe_tower: 0 +; HEADER_BLOCK_END +M620 S0A ; switch material if AMS exist + T0 +M621 S0A +T1000 + T1000 + T1000 +T1100 + T1100 + T1100 + T1000 +T1000 +; CHANGE_LAYER +; Z_HEIGHT: 0.2 +; layer num/total_layer_count: 1/27 +; CHANGE_LAYER +; Z_HEIGHT: 0.4 +; layer num/total_layer_count: 2/27 +; CHANGE_LAYER +; Z_HEIGHT: 0.6 +; layer num/total_layer_count: 3/27 +; CHANGE_LAYER +; Z_HEIGHT: 0.8 +; layer num/total_layer_count: 4/27 +; CHANGE_LAYER +; Z_HEIGHT: 1 +; layer num/total_layer_count: 5/27 +; CHANGE_LAYER +; Z_HEIGHT: 1.2 +; layer num/total_layer_count: 6/27 +; CHANGE_LAYER +; Z_HEIGHT: 1.4 +; layer num/total_layer_count: 7/27 +; CHANGE_LAYER +; Z_HEIGHT: 1.6 +; layer num/total_layer_count: 8/27 +; CHANGE_LAYER +; Z_HEIGHT: 1.8 +; layer num/total_layer_count: 9/27 +; CHANGE_LAYER +; Z_HEIGHT: 2 +; layer num/total_layer_count: 10/27 +; CHANGE_LAYER +; Z_HEIGHT: 2.2 +; layer num/total_layer_count: 11/27 +; CHANGE_LAYER +; Z_HEIGHT: 2.4 +; layer num/total_layer_count: 12/27 +; CHANGE_LAYER +; Z_HEIGHT: 2.6 +; layer num/total_layer_count: 13/27 +; CHANGE_LAYER +; Z_HEIGHT: 2.8 +; layer num/total_layer_count: 14/27 +; CHANGE_LAYER +; Z_HEIGHT: 3 +; layer num/total_layer_count: 15/27 +; CHANGE_LAYER +; Z_HEIGHT: 3.2 +; layer num/total_layer_count: 16/27 +; CHANGE_LAYER +; Z_HEIGHT: 3.4 +; layer num/total_layer_count: 17/27 +; CHANGE_LAYER +; Z_HEIGHT: 3.6 +; layer num/total_layer_count: 18/27 +; CHANGE_LAYER +; Z_HEIGHT: 3.8 +; layer num/total_layer_count: 19/27 +; CHANGE_LAYER +; Z_HEIGHT: 4 +; layer num/total_layer_count: 20/27 +; CHANGE_LAYER +; Z_HEIGHT: 4.2 +; layer num/total_layer_count: 21/27 +M620 S1A +T1 +M621 S1A +; CHANGE_LAYER +; Z_HEIGHT: 4.4 +; layer num/total_layer_count: 22/27 +; CHANGE_LAYER +; Z_HEIGHT: 4.6 +; layer num/total_layer_count: 23/27 +; CHANGE_LAYER +; Z_HEIGHT: 4.8 +; layer num/total_layer_count: 24/27 +; CHANGE_LAYER +; Z_HEIGHT: 5 +; layer num/total_layer_count: 25/27 +; CHANGE_LAYER +; Z_HEIGHT: 5.2 +; layer num/total_layer_count: 26/27 +; CHANGE_LAYER +; Z_HEIGHT: 5.4 +; layer num/total_layer_count: 27/27 +M620 S255 +T255 +M621 S255 diff --git a/internal/job/testdata/two_filament/plate_1.json b/internal/job/testdata/two_filament/plate_1.json new file mode 100644 index 0000000..8dd40bc --- /dev/null +++ b/internal/job/testdata/two_filament/plate_1.json @@ -0,0 +1 @@ +{"bbox_all":[11.79827,84.00341,171.99657000000002,247.30277],"bbox_objects":[{"area":6081.49365234375,"bbox":[84.00341,84.00341,171.99657000000002,171.99657000000002],"id":28,"layer_height":0.20000000298023224,"name":"CadQuery Component"},{"area":1.401298464324817e-45,"bbox":[11.79827,216.79036000000002,42.748940000000005,247.30277],"id":1000,"layer_height":0.0,"name":"wipe_tower"}],"bed_type":"textured_plate","filament_colors":["#F4EE2A","#000000"],"filament_ids":[0,1],"first_extruder":0,"first_layer_time":0.0,"is_seq_print":false,"nozzle_diameter":0.4000000059604645,"version":2} \ No newline at end of file diff --git a/internal/job/testdata/two_filament/project_settings.config b/internal/job/testdata/two_filament/project_settings.config new file mode 100644 index 0000000..b84e5c6 --- /dev/null +++ b/internal/job/testdata/two_filament/project_settings.config @@ -0,0 +1,77 @@ +{ + "printer_model": "Bambu Lab X1 Carbon", + "printer_settings_id": "Bambu Lab X1 Carbon 0.4 nozzle", + "print_settings_id": "0.20mm Standard @BBL X1C", + "filament_settings_id": [ + "Generic PLA", + "Bambu PLA Matte @BBL X1C" + ], + "nozzle_diameter": [ + "0.4" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel" + ], + "curr_bed_type": "Textured PEI Plate", + "textured_plate_temp": [ + "55", + "55" + ], + "nozzle_temperature": [ + "220", + "220", + "220" + ], + "nozzle_temperature_initial_layer": [ + "220", + "220", + "220" + ], + "nozzle_temperature_range_low": [ + "190", + "190" + ], + "nozzle_temperature_range_high": [ + "240", + "240" + ], + "filament_self_index": [ + "1", + "2", + "2" + ], + "filament_extruder_variant": [ + "Direct Drive Standard", + "Direct Drive Standard", + "Direct Drive High Flow" + ], + "extruder_type": [ + "Direct Drive" + ], + "nozzle_volume_type": [ + "Standard" + ], + "filament_type": [ + "PLA", + "PLA" + ], + "filament_ids": [ + "GFL99", + "GFA01" + ], + "filament_colour": [ + "#F4EE2A", + "#000000" + ], + "layer_height": "0.2", + "initial_layer_print_height": "0.2", + "wall_loops": "2", + "top_shell_layers": "5", + "bottom_shell_layers": "3", + "sparse_infill_density": "15%", + "sparse_infill_pattern": "gyroid", + "brim_type": "no_brim", + "enable_support": "0", + "enable_prime_tower": "1" +} diff --git a/internal/job/testdata/two_filament/result.json b/internal/job/testdata/two_filament/result.json new file mode 100644 index 0000000..4fc63fe --- /dev/null +++ b/internal/job/testdata/two_filament/result.json @@ -0,0 +1,53 @@ +{ + "return_code": 0, + "error_string": "Success.", + "sliced_plates": [ + { + "filaments": [ + { + "filament_id": "GFL99", + "id": 1, + "main_used_g": 16.01702308654785, + "total_used_g": 16.984750747680664 + }, + { + "filament_id": "GFA01", + "id": 2, + "main_used_g": 2.889824628829956, + "total_used_g": 3.118014097213745 + } + ], + "feature_type_times": { + "Bottom surface": 159.02442932128906, + "Bridge": 272.9046630859375, + "Custom": 429.2455139160156, + "Flush": 109.39039611816406, + "Gap infill": 28.78656578063965, + "Inner wall": 94.23754119873047, + "Internal solid infill": 638.7852172851562, + "Outer wall": 151.09385681152344, + "Overhang wall": 0.5377050042152405, + "Prime tower": 115.01567840576172, + "Sparse infill": 292.5014953613281, + "Top surface": 207.865234375, + "Travel": 207.54945373535156, + "Undefined": 192.072998046875 + }, + "filament_change_times": 1, + "warning_message": "", + "objects": [ + { + "name": "CadQuery Component", + "bbox": { + "depth": 98.40521240234375, + "height": 5.400000095367432, + "width": 98.40521240234375, + "x": 78.7973861694336, + "y": 78.79739379882812, + "z": 4.440892098500626e-16 + } + } + ] + } + ] +} diff --git a/internal/job/testdata/two_filament/slice_info.config b/internal/job/testdata/two_filament/slice_info.config new file mode 100644 index 0000000..6199389 --- /dev/null +++ b/internal/job/testdata/two_filament/slice_info.config @@ -0,0 +1,35 @@ + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 7dd7f38..535c54e 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -21,19 +21,22 @@ const ( // Gate is one check. type Gate struct { - Gate string `json:"gate"` - Status string `json:"status"` - Detail string `json:"detail"` - Fix string `json:"fix,omitempty"` + Gate string `json:"gate"` + Status string `json:"status"` + Detail string `json:"detail"` + Fix string `json:"fix,omitempty"` + Filament int `json:"filament,omitempty"` // 1-based job filament, on multi-filament jobs only } -// Result is the preflight outcome. +// Result is the preflight outcome. Slot and TrayID are the first filament's. type Result struct { Result string `json:"result"` Failed []string `json:"failed"` Warnings []string `json:"warnings"` Slot string `json:"slot"` TrayID int `json:"tray_id"` + Slots []string `json:"slots"` + TrayIDs []int `json:"tray_ids"` Gates []Gate `json:"gates"` } @@ -42,7 +45,7 @@ type Input struct { Printer *config.Printer Job *job.Job Status printer.Status - TrayID int + TrayIDs []int // the AMS tray feeding each job filament, in filament order // FTPSCheck, if set, performs a read-only FTPS login + listing and returns its error. FTPSCheck func() error } @@ -65,7 +68,6 @@ func (g *gates) add(name string, ok bool, failStatus, detail, fix string) { func Run(in Input) Result { var g gates s, j, p := in.Status, in.Job, in.Printer - label := printer.TrayLabel(in.TrayID) g.add("printer_idle", printer.IdleStates[s.State], Fail, fmt.Sprintf("state=%s stage=%s", s.State, s.Stage), "wait for the current job to finish (bambu monitor)") @@ -93,8 +95,8 @@ func Run(in Input) Result { g.add("sliced_for_printer", j.PrinterModel == want, Fail, fmt.Sprintf("file=%q printer=%q", j.PrinterModel, want), "re-slice for this printer (bambu slice --printer "+p.Name+")") used := len(j.Filaments) - g.add("single_filament", used == 1, Fail, fmt.Sprintf("%d filaments used", used), - "multi-material jobs are not supported yet; use Bambu Studio") + g.add("single_filament", used == len(in.TrayIDs), Fail, fmt.Sprintf("%d filament%s in plate, %d slot%s given", used, plural(used), len(in.TrayIDs), plural(len(in.TrayIDs))), + "pass one --slot per filament, in filament order (e.g. --slot A4 --slot A1)") pd, _ := strconv.ParseFloat(s.Nozzle.Diameter, 64) jd, _ := strconv.ParseFloat(j.NozzleDiameter, 64) @@ -106,54 +108,21 @@ func Run(in Input) Result { g.add("bed_type", j.BedType == p.Plate, Fail, fmt.Sprintf("file=%s installed=%s", j.BedType, p.Plate), "re-slice (the plate comes from the printer config) or swap the plate and update it: bambu printer add "+p.Name+" … --plate") - var tray *printer.Tray - for i := range s.AMS { - if s.AMS[i].TrayID == in.TrayID { - tray = &s.AMS[i] - } - } - var need job.Filament - if len(j.Filaments) > 0 { - need = j.Filaments[0] + needs := j.Filaments + if len(needs) == 0 { + needs = []job.Filament{{NozzleTemp: j.NozzleTemp, NozzleTempRange: j.NozzleTempRange[:]}} } - switch { - case tray == nil: - g.add("tray", false, Fail, "slot "+label+" not reported by the AMS", "check the AMS is connected, or pick another slot") - case !tray.Loaded: - g.add("tray", false, Fail, "slot "+label+" is empty", fmt.Sprintf("load %s into AMS slot %s", need.Type, label)) - default: - exact := strings.EqualFold(tray.Type, need.Type) - sameFamily := family(tray.Type) == family(need.Type) - st := Pass - switch { - case !exact && sameFamily: - st = Warn - case !exact: - st = Fail + for n, need := range needs { + if n >= len(in.TrayIDs) { + break } - g = append(g, Gate{ - Gate: "filament_type", Status: st, Detail: fmt.Sprintf("slot %s=%s file=%s", label, tray.Type, need.Type), - Fix: map[string]string{ - Pass: "", Warn: "same family, different variant; OK only if intended", - Fail: fmt.Sprintf("load %s into slot %s, or choose the slot that has it (--slot)", need.Type, label), - }[st], - }) - g.add("filament_profile", tray.FilamentID == need.FilamentID, Warn, - fmt.Sprintf("slot %s filament_id=%s (%s) file=%s", label, orNone(tray.FilamentID), orNone(tray.Name), orNone(need.FilamentID)), - "a different product than sliced for; re-slice with --filament to match, or accept") - if tray.RemainG == nil { - g.add("filament_amount", false, Warn, fmt.Sprintf("slot %s remaining unknown; job needs %.1f g", label, need.UsedG), - "confirm there is enough filament on the spool") - } else { - ok := float64(*tray.RemainG) >= need.UsedG*1.15+5 - g.add("filament_amount", ok, Fail, fmt.Sprintf("slot %s ~%d g (%d%%); job needs %.1f g (+15%%, +5 g)", label, *tray.RemainG, *tray.RemainPct, need.UsedG), - "load a fuller spool into slot "+label) + fg := filamentGates(s, need, in.TrayIDs[n]) + if len(needs) > 1 { + for i := range fg { + fg[i].Filament = n + 1 + } } - lo, _ := strconv.ParseFloat(j.NozzleTempRange[0], 64) - hi, _ := strconv.ParseFloat(j.NozzleTempRange[1], 64) - t, _ := strconv.ParseFloat(j.NozzleTemp, 64) - g.add("nozzle_temp", t > 0 && (lo == 0 || t >= lo) && (hi == 0 || t <= hi), Fail, - fmt.Sprintf("nozzle=%s range=%s-%s", j.NozzleTemp, j.NozzleTempRange[0], j.NozzleTempRange[1]), "fix the recipe temperatures") + g = append(g, fg...) } pr := s.Protections @@ -169,7 +138,13 @@ func Run(in Input) Result { g.add("ftps_login", err == nil, Fail, detail, "check the access code (bambu auth status --check) and Developer Mode") } - r := Result{Result: Pass, Slot: label, TrayID: in.TrayID, Gates: g, Failed: []string{}, Warnings: []string{}} + r := Result{Result: Pass, Gates: g, Failed: []string{}, Warnings: []string{}, TrayIDs: in.TrayIDs, Slots: []string{}} + for _, t := range in.TrayIDs { + r.Slots = append(r.Slots, printer.TrayLabel(t)) + } + if len(in.TrayIDs) > 0 { + r.Slot, r.TrayID = r.Slots[0], in.TrayIDs[0] + } for _, x := range g { switch x.Status { case Fail: @@ -182,6 +157,68 @@ func Run(in Input) Result { return r } +// filamentGates checks one job filament against the AMS tray mapped to it. +func filamentGates(s printer.Status, need job.Filament, trayID int) gates { + var g gates + label := printer.TrayLabel(trayID) + var tray *printer.Tray + for i := range s.AMS { + if s.AMS[i].TrayID == trayID { + tray = &s.AMS[i] + } + } + switch { + case tray == nil: + 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)) + return g + } + exact := strings.EqualFold(tray.Type, need.Type) + sameFamily := family(tray.Type) == family(need.Type) + st := Pass + switch { + case !exact && sameFamily: + st = Warn + case !exact: + st = Fail + } + g = append(g, Gate{ + Gate: "filament_type", Status: st, Detail: fmt.Sprintf("slot %s=%s file=%s", label, tray.Type, need.Type), + Fix: map[string]string{ + Pass: "", Warn: "same family, different variant; OK only if intended", + Fail: fmt.Sprintf("load %s into slot %s, or choose the slot that has it (--slot)", need.Type, label), + }[st], + }) + g.add("filament_profile", tray.FilamentID == need.FilamentID, Warn, + fmt.Sprintf("slot %s filament_id=%s (%s) file=%s", label, orNone(tray.FilamentID), orNone(tray.Name), orNone(need.FilamentID)), + "a different product than sliced for; re-slice with --filament to match, or accept") + if tray.RemainG == nil { + g.add("filament_amount", false, Warn, fmt.Sprintf("slot %s remaining unknown; job needs %.1f g", label, need.UsedG), + "confirm there is enough filament on the spool") + } else { + ok := float64(*tray.RemainG) >= need.UsedG*1.15+5 + g.add("filament_amount", ok, Fail, fmt.Sprintf("slot %s ~%d g (%d%%); job needs %.1f g (+15%%, +5 g)", label, *tray.RemainG, *tray.RemainPct, need.UsedG), + "load a fuller spool into slot "+label) + } + var rng [2]string + copy(rng[:], need.NozzleTempRange) + lo, _ := strconv.ParseFloat(rng[0], 64) + hi, _ := strconv.ParseFloat(rng[1], 64) + t, _ := strconv.ParseFloat(need.NozzleTemp, 64) + g.add("nozzle_temp", t > 0 && (lo == 0 || t >= lo) && (hi == 0 || t <= hi), Fail, + fmt.Sprintf("nozzle=%s range=%s-%s", need.NozzleTemp, rng[0], rng[1]), "fix the recipe temperatures") + return g +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + func family(t string) string { t = strings.ToUpper(strings.TrimSpace(t)) if i := strings.IndexAny(t, "- "); i > 0 { diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go index 13cce7d..2516f05 100644 --- a/internal/preflight/preflight_test.go +++ b/internal/preflight/preflight_test.go @@ -38,7 +38,7 @@ func run(t *testing.T, o testutil.Opts, slot int, mut func(*printer.Status)) Res if mut != nil { mut(&s) } - return Run(Input{Printer: shop, Job: j, Status: s, TrayID: slot, FTPSCheck: func() error { return nil }}) + return Run(Input{Printer: shop, Job: j, Status: s, TrayIDs: []int{slot}, FTPSCheck: func() error { return nil }}) } func status(r Result, gate string) string { @@ -122,8 +122,86 @@ func TestFamilyWarn(t *testing.T) { func TestFTPSFailure(t *testing.T) { j, _ := job.Inspect(testutil.Write(t, t.TempDir(), "p", testutil.Opts{}), 1) - r := Run(Input{Printer: shop, Job: j, Status: idleStatus(t), TrayID: 0, FTPSCheck: func() error { return errors.New("login refused") }}) + r := Run(Input{Printer: shop, Job: j, Status: idleStatus(t), TrayIDs: []int{0}, FTPSCheck: func() error { return errors.New("login refused") }}) if !slices.Contains(r.Failed, "ftps_login") { t.Fatal("ftps failure must FAIL") } } + +func runTwo(t *testing.T, trays []int, mut func(*printer.Status)) Result { + t.Helper() + // recorded two-filament slice: Generic PLA (GFL99) 16.98 g, then PLA Matte (GFA01) 3.12 g from layer 21 + j, err := job.Inspect(testutil.FromDir(t, "../job/testdata/two_filament", t.TempDir(), "two"), 1) + if err != nil { + t.Fatal(err) + } + s := idleStatus(t) + if mut != nil { + mut(&s) + } + return Run(Input{Printer: shop, Job: j, Status: s, TrayIDs: trays, FTPSCheck: func() error { return nil }}) +} + +func gateFor(r Result, gate string, filament int) *Gate { + for i := range r.Gates { + if r.Gates[i].Gate == gate && r.Gates[i].Filament == filament { + return &r.Gates[i] + } + } + return nil +} + +func TestTwoFilaments(t *testing.T) { + // A4 = Generic PLA GFL99 (remaining unknown), A1 = PLA Matte GFA01 ~330 g + r := runTwo(t, []int{3, 0}, nil) + if r.Result != Pass || !slices.Equal(r.Slots, []string{"A4", "A1"}) || !slices.Equal(r.TrayIDs, []int{3, 0}) || r.Slot != "A4" || r.TrayID != 3 { + t.Fatalf("want PASS on A4+A1: %+v", r) + } + if !slices.Equal(r.Warnings, []string{"filament_amount"}) || gateFor(r, "filament_amount", 1).Status != Warn { + t.Fatalf("only A4's unknown amount should WARN: %+v", r.Gates) + } + for _, g := range []string{"filament_type", "filament_profile", "filament_amount", "nozzle_temp"} { + if gateFor(r, g, 1) == nil || gateFor(r, g, 2) == nil { + t.Errorf("%s must run once per filament: %+v", g, r.Gates) + } + } + if d := gateFor(r, "filament_amount", 2).Detail; d != "slot A1 ~330 g (33%); job needs 3.1 g (+15%, +5 g)" { + t.Errorf("filament 2 is checked against A1: %q", d) + } +} + +func TestTwoFilamentFailures(t *testing.T) { + cases := []struct { + name string + trays []int + mut func(*printer.Status) + gate string + filament int + }{ + {"one slot for two filaments", []int{3}, nil, "single_filament", 0}, + {"second slot empty", []int{3, 1}, nil, "tray", 2}, + {"second slot too low", []int{3, 2}, func(s *printer.Status) { g := 2; s.AMS[2].RemainG = &g }, "filament_amount", 2}, + {"second slot wrong material", []int{3, 0}, func(s *printer.Status) { s.AMS[0].Type = "PETG" }, "filament_type", 2}, + } + for _, c := range cases { + r := runTwo(t, c.trays, c.mut) + if g := gateFor(r, c.gate, c.filament); r.Result != Fail || g == nil || g.Status != Fail { + t.Errorf("%s: want FAIL on %s (filament %d), got %+v", c.name, c.gate, c.filament, r.Gates) + } + } + if d := gateFor(runTwo(t, []int{3}, nil), "single_filament", 0).Detail; d != "2 filaments in plate, 1 slot given" { + t.Errorf("detail: %q", d) + } +} + +func TestSingleFilamentGatesUnchanged(t *testing.T) { + r := run(t, testutil.Opts{}, 0, nil) + for _, g := range r.Gates { + if g.Filament != 0 { + t.Fatalf("single-filament gates carry no filament number: %+v", g) + } + } + if !slices.Equal(r.Slots, []string{"A1"}) || !slices.Equal(r.TrayIDs, []int{0}) || r.Slot != "A1" || r.TrayID != 0 { + t.Fatalf("%+v", r) + } +} diff --git a/internal/printer/filament.go b/internal/printer/filament.go new file mode 100644 index 0000000..cd77911 --- /dev/null +++ b/internal/printer/filament.go @@ -0,0 +1,74 @@ +package printer + +import ( + "fmt" + "strconv" +) + +// amsStatus names print.ams_status >> 8 (Bambu Studio DevDefs.h AmsStatusMain). 0x03 is the resting value with filament loaded. +var amsStatus = map[int]string{ + 0x00: "idle", 0x01: "filament_change", 0x02: "rfid_identifying", 0x03: "assist", 0x04: "calibration", + 0x07: "cold_pull", 0x10: "self_check", 0x20: "debug", +} + +// AMSStatusName decodes print.ams_status. +func AMSStatusName(v int) string { + main := (v & 0xFF00) >> 8 + if n, ok := amsStatus[main]; ok { + return n + } + return fmt.Sprintf("ams_status_0x%02x", main) +} + +// EmptyToolhead is ams.tray_now when no filament is in the toolhead; ExternalTray is the external spool. +const ( + EmptyToolhead = "255" + ExternalTray = "254" +) + +// EmptyToolheadKnown reports whether tray_now 255 is known to mean "no filament in the toolhead" on model m: verified +// on the X1 series (X1 Carbon, firmware 01.12). H2-series reports differ, and other models are unverified. +func EmptyToolheadKnown(m Model) bool { + return m.Alias == "X1C" || m.Alias == "X1" || m.Alias == "X1E" +} + +// ToolheadSlot formats ams.tray_now: A1..D4, "external", or "" when nothing is loaded (or nothing was reported). +func ToolheadSlot(trayNow string) string { + n, err := strconv.Atoi(trayNow) + switch { + case err != nil || trayNow == EmptyToolhead: + return "" + case trayNow == ExternalTray: + return "external" + } + return TrayLabel(n) +} + +var filamentChangeStages = map[string]bool{"changing_filament": true, "filament_unloading": true, "filament_loading": true} + +// FilamentChanging reports an AMS filament change in progress. +func FilamentChanging(s Status) bool { + return s.AMSStatus == "filament_change" || filamentChangeStages[s.Stage] +} + +// defaultLoadTemp is Bambu Studio's command_ams_change_filament default when a tray reports no temperature range. +const defaultLoadTemp = 210 + +// LoadTemp is the temperature Bambu Studio sends for a tray in ams_change_filament: the middle of its nozzle range +// (StatusPanel::on_ams_load_curr). +func LoadTemp(t *Tray) int { + if t == nil || t.TempMin == 0 || t.TempMax == 0 { + return defaultLoadTemp + } + return (t.TempMin + t.TempMax) / 2 +} + +// AMSChangeFilament builds the print.ams_change_filament command for an AMS tray, as Bambu Studio sends it +// (DeviceManager.cpp command_ams_change_filament): the printer unloads the toolhead, loads trayID and purges. +// Verified on an X1 Carbon (firmware 01.12, Developer Mode) on 2026-10-08, during a pause: A4 -> A1. +func AMSChangeFilament(trayID, currTemp, tarTemp int) map[string]any { + return map[string]any{ + "command": "ams_change_filament", "ams_id": trayID / 4, "slot_id": trayID % 4, "target": trayID, + "curr_temp": currTemp, "tar_temp": tarTemp, + } +} diff --git a/internal/printer/filament_test.go b/internal/printer/filament_test.go new file mode 100644 index 0000000..04d9290 --- /dev/null +++ b/internal/printer/filament_test.go @@ -0,0 +1,85 @@ +package printer + +import ( + "encoding/json" + "os" + "reflect" + "testing" +) + +// pause.json is a recorded report from a G-code pause before a colour change: A4 in the toolhead, layer 21. +func loadPause(t *testing.T) map[string]any { + t.Helper() + b, err := os.ReadFile("testdata/pause.json") + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + return m +} + +func TestSummarizePause(t *testing.T) { + s := Summarize(loadPause(t)) + if s.State != "PAUSE" || s.AMSStatus != "assist" || s.ActiveTray != "3" || ToolheadSlot(s.ActiveTray) != "A4" { + t.Fatalf("%+v", s) + } + if s.Errors.PrintError != "0300_8013" || FilamentChanging(s) { + t.Fatalf("pause: %+v", s.Errors) + } + if Summarize(load(t)).AMSStatus != "" { + t.Fatal("ams_status must stay empty when not reported") + } +} + +func TestAMSStatusName(t *testing.T) { + for v, want := range map[int]string{0: "idle", 0x0100: "filament_change", 0x0103: "filament_change", 768: "assist", 0x4000: "ams_status_0x40"} { + if got := AMSStatusName(v); got != want { + t.Errorf("%#x: %s want %s", v, got, want) + } + } +} + +func TestFilamentChanging(t *testing.T) { + cases := []struct { + s Status + want bool + }{ + {Status{AMSStatus: "assist", Stage: "printing"}, false}, + {Status{AMSStatus: "filament_change"}, true}, + {Status{AMSStatus: "assist", Stage: "filament_loading"}, true}, + {Status{Stage: "filament_unloading"}, true}, + {Status{Stage: "changing_filament"}, true}, + } + for _, c := range cases { + if got := FilamentChanging(c.s); got != c.want { + t.Errorf("%+v: %v", c.s, got) + } + } +} + +func TestToolheadSlot(t *testing.T) { + for in, want := range map[string]string{"0": "A1", "3": "A4", "5": "B2", "254": "external", "255": "", "": ""} { + if got := ToolheadSlot(in); got != want { + t.Errorf("%q: %q want %q", in, got, want) + } + } +} + +func TestAMSChangeFilamentVerifiedPayload(t *testing.T) { + s := Summarize(loadPause(t)) + a1, a4 := &s.AMS[0], &s.AMS[3] + got := AMSChangeFilament(a1.TrayID, LoadTemp(a4), LoadTemp(a1)) + want := map[string]any{"command": "ams_change_filament", "ams_id": 0, "slot_id": 0, "target": 0, "curr_temp": 215, "tar_temp": 210} + if !reflect.DeepEqual(got, want) { + t.Fatalf("payload drift from the one verified on the printer:\n got %v\nwant %v", got, want) + } + if b := AMSChangeFilament(6, 210, 240); b["ams_id"] != 1 || b["slot_id"] != 2 || b["target"] != 6 { + t.Fatalf("B3: %v", b) + } + if LoadTemp(nil) != 210 || LoadTemp(&Tray{}) != 210 { + t.Fatal("no range: Studio's default 210") + } +} diff --git a/internal/printer/status.go b/internal/printer/status.go index 674bc17..4975b24 100644 --- a/internal/printer/status.go +++ b/internal/printer/status.go @@ -26,6 +26,7 @@ type Status struct { Nozzle Nozzle `json:"nozzle"` AMS []Tray `json:"ams"` ActiveTray string `json:"active_tray,omitempty"` + AMSStatus string `json:"ams_status,omitempty"` // "filament_change" while the AMS swaps filament External string `json:"external_spool,omitempty"` // filament type on the external spool holder, if any Protections Protections `json:"protections"` DevMode *bool `json:"dev_mode"` @@ -177,6 +178,9 @@ func Summarize(p map[string]any) Status { } ams := obj(p["ams"]) s.ActiveTray = str(ams["tray_now"]) + if v, ok := p["ams_status"]; ok { + s.AMSStatus = AMSStatusName(num(v)) + } s.AMS = []Tray{} for _, a := range list(ams["ams"]) { am := obj(a) diff --git a/internal/printer/testdata/pause.json b/internal/printer/testdata/pause.json new file mode 100644 index 0000000..a72c055 --- /dev/null +++ b/internal/printer/testdata/pause.json @@ -0,0 +1,77 @@ +{ + "command": "push_status", + "gcode_state": "PAUSE", + "stg_cur": 0, + "layer_num": 21, + "total_layer_num": 27, + "mc_percent": 81, + "mc_remaining_time": 7, + "print_error": 50364435, + "hms": [], + "fun": "20011830F9CFB", + "sdcard": true, + "ams_status": 768, + "subtask_name": "part", + "nozzle_temper": 90.0, + "nozzle_target_temper": 90.0, + "bed_temper": 55.0, + "bed_target_temper": 55.0, + "nozzle_diameter": "0.4", + "nozzle_type": "HX01", + "ams": { + "ams": [ + { + "id": "0", + "tray": [ + { + "id": "0", + "tray_type": "PLA", + "tray_info_idx": "GFA01", + "tray_sub_brands": "PLA Matte", + "tray_color": "000000FF", + "remain": 23, + "tray_weight": "1000", + "nozzle_temp_min": "190", + "nozzle_temp_max": "230" + }, + { + "id": "1", + "tray_type": "PETG", + "tray_info_idx": "GFG02", + "tray_sub_brands": "PETG HF", + "tray_color": "FFFFFFFF", + "remain": 8, + "tray_weight": "1000", + "nozzle_temp_min": "230", + "nozzle_temp_max": "260" + }, + { + "id": "2", + "tray_type": "PLA", + "tray_info_idx": "GFA00", + "tray_sub_brands": "PLA Basic", + "tray_color": "00AE42FF", + "remain": 5, + "tray_weight": "250", + "nozzle_temp_min": "190", + "nozzle_temp_max": "230" + }, + { + "id": "3", + "tray_type": "PLA", + "tray_info_idx": "GFL99", + "tray_sub_brands": "", + "tray_color": "FFFFFFFF", + "remain": -1, + "tray_weight": "0", + "nozzle_temp_min": "190", + "nozzle_temp_max": "240" + } + ] + } + ], + "tray_now": "3", + "tray_pre": "3", + "tray_tar": "3" + } +} \ No newline at end of file diff --git a/internal/slicer/slice.go b/internal/slicer/slice.go index 9eeaafa..be54791 100644 --- a/internal/slicer/slice.go +++ b/internal/slicer/slice.go @@ -7,11 +7,14 @@ import ( "errors" "fmt" "io" + "math" "os" "os/exec" "path/filepath" "regexp" + "slices" "sort" + "strconv" "strings" "time" @@ -41,50 +44,57 @@ var CLIErrors = map[int]string{ type Request struct { Model string Recipe recipe.Recipe - MachinePreset string // e.g. "Bambu Lab X1 Carbon 0.4 nozzle" - Plate string // installed plate id, e.g. "textured_plate" - Filament string // optional filament preset override - Sets []string // key=value overrides - Name string // output base name - OutDir string // where the .gcode.3mf/.png/.summary.json go - WorkDir string // scratch dir for configs and slicer output + MachinePreset string // e.g. "Bambu Lab X1 Carbon 0.4 nozzle" + Plate string // installed plate id, e.g. "textured_plate" + Filaments []string // filament presets in print order (default: the recipe's) + Colors []string // spool colour per filament (RRGGBB); Studio sizes the purge between filaments from them + ChangeZs []float64 // filament n+1 starts on the first layer above ChangeZs[n-1] + Sets []string // key=value overrides + Name string // output base name + OutDir string // where the .gcode.3mf/.png/.summary.json go + WorkDir string // scratch dir for configs and slicer output AutoOrient bool } // Summary is the slice result (the --json contract). type Summary struct { - Name string `json:"name"` - Recipe string `json:"recipe"` - Description string `json:"recipe_description"` - Model string `json:"model"` - Gcode3MF string `json:"gcode_3mf"` - PreviewPNG string `json:"preview_png"` - SummaryJSON string `json:"summary_json"` - TimeS int `json:"time_s"` - Time string `json:"time"` - WeightG float64 `json:"weight_g"` - Layers int `json:"layers"` - Filaments []job.Filament `json:"filaments"` - BedType string `json:"bed_type"` - BedTemp string `json:"bed_temp"` - NozzleTemp string `json:"nozzle_temp"` - NozzleFirst string `json:"nozzle_temp_initial"` - Presets map[string]string `json:"presets"` - Overrides map[string]any `json:"overrides"` - Notes []string `json:"notes"` - Warnings []string `json:"warnings"` - Settings map[string]any `json:"settings"` - AutoOrient bool `json:"auto_orient"` - SlicerLog string `json:"slicer_log"` - StudioVersion string `json:"studio_version,omitempty"` + Name string `json:"name"` + Recipe string `json:"recipe"` + Description string `json:"recipe_description"` + Model string `json:"model"` + Gcode3MF string `json:"gcode_3mf"` + PreviewPNG string `json:"preview_png"` + SummaryJSON string `json:"summary_json"` + TimeS int `json:"time_s"` + Time string `json:"time"` + WeightG float64 `json:"weight_g"` + Layers int `json:"layers"` + Filaments []job.Filament `json:"filaments"` + FilamentChanges []job.FilamentChange `json:"filament_changes"` + PrimeTower bool `json:"prime_tower"` + FilamentPresets []string `json:"filament_presets"` + BedType string `json:"bed_type"` + BedTemp string `json:"bed_temp"` + NozzleTemp string `json:"nozzle_temp"` + NozzleFirst string `json:"nozzle_temp_initial"` + Presets map[string]string `json:"presets"` + Overrides map[string]any `json:"overrides"` + Notes []string `json:"notes"` + Warnings []string `json:"warnings"` + Settings map[string]any `json:"settings"` + AutoOrient bool `json:"auto_orient"` + SlicerLog string `json:"slicer_log"` + StudioVersion string `json:"studio_version,omitempty"` } // Configs are the flattened configs handed to the CLI. type Configs struct { - Machine, Process, Filament map[string]any - Presets map[string]string - Applied map[string]any - Notes []string + Machine, Process map[string]any + Filaments []map[string]any // one per filament, in print order + Presets map[string]string + FilamentPresets []string + Applied map[string]any + Notes []string } // Build resolves presets for the target machine and applies recipe + --set overrides. @@ -97,56 +107,59 @@ func Build(ix *Index, req Request) (*Configs, error) { if err != nil { return nil, err } - filBase := req.Recipe.Filament - if req.Filament != "" { - filBase = req.Filament - } - filName, err := ix.Find(KindFilament, filBase, req.MachinePreset) - if err != nil { - return nil, err - } process, err := ix.Flatten(KindProcess, procName) if err != nil { return nil, err } - filament, err := ix.Flatten(KindFilament, filName) - if err != nil { - return nil, err - } - c := &Configs{ - Machine: machine, Process: process, Filament: filament, Applied: map[string]any{}, Notes: []string{}, - Presets: map[string]string{"machine": req.MachinePreset, "process": procName, "filament": filName}, + bases := req.presets() + c := &Configs{Machine: machine, Process: process, Applied: map[string]any{}, Notes: []string{}} + for _, base := range bases { + name, err := ix.Find(KindFilament, base, req.MachinePreset) + if err != nil { + return nil, err + } + f, err := ix.Flatten(KindFilament, name) + if err != nil { + return nil, err + } + c.Filaments = append(c.Filaments, f) + c.FilamentPresets = append(c.FilamentPresets, name) } + filament := c.Filaments[0] + c.Presets = map[string]string{"machine": req.MachinePreset, "process": procName, "filament": c.FilamentPresets[0]} if bed, ok := bedName(req.Plate); ok { process["curr_bed_type"] = bed } - apply := func(target map[string]any, overrides map[string]any) { + // filament overrides and filament --set keys apply to every filament + apply := func(targets []map[string]any, overrides map[string]any) { keys := make([]string, 0, len(overrides)) for k := range overrides { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { - target[k] = coerce(target[k], overrides[k]) - c.Applied[k] = target[k] + for _, target := range targets { + target[k] = coerce(target[k], overrides[k]) + } + c.Applied[k] = targets[0][k] } } - apply(process, req.Recipe.ProcessOverrides) - apply(filament, req.Recipe.FilamentOverrides) + apply([]map[string]any{process}, req.Recipe.ProcessOverrides) + apply(c.Filaments, req.Recipe.FilamentOverrides) for _, s := range req.Sets { k, v, ok := strings.Cut(s, "=") k, v = strings.TrimSpace(k), strings.TrimSpace(v) if !ok || k == "" { return nil, errfmt.New(errfmt.ExitUsage, "bad --set %q", s).WithHint("use --set key=value, e.g. --set wall_loops=4") } - var target map[string]any + var targets []map[string]any switch { case has(filament, k): - target = filament + targets = c.Filaments case has(machine, k): - target = machine + targets = []map[string]any{machine} case has(process, k) || k == "curr_bed_type": - target = process + targets = []map[string]any{process} default: return nil, errfmt.New(errfmt.ExitUsage, "unknown setting %q", k). WithHint("use a Bambu Studio config key (e.g. wall_loops, sparse_infill_density, brim_type); nothing was sliced") @@ -158,8 +171,10 @@ func Build(ix *Index, req Request) (*Configs, error) { val = arr } } - target[k] = coerce(target[k], val) - c.Applied[k] = target[k] + for _, target := range targets { + target[k] = coerce(target[k], val) + } + c.Applied[k] = targets[0][k] } if _, ok := plateID(str(process["curr_bed_type"])); !ok { return nil, errfmt.New(errfmt.ExitUsage, "unsupported curr_bed_type %q", str(process["curr_bed_type"])). @@ -227,6 +242,75 @@ func plateID(bed string) (string, bool) { return id, ok } +// NextLayerTop is the top of the first layer above z, which must itself be a layer top (first layer, then a fixed layer +// height). That is the print_z Bambu Studio stores for a filament change on that layer (IMSlider::add_code_as_tick): the +// first layer printed with the new filament. +func NextLayerTop(process map[string]any, z float64) (float64, error) { + first, err1 := strconv.ParseFloat(str(process["initial_layer_print_height"]), 64) + h, err2 := strconv.ParseFloat(str(process["layer_height"]), 64) + if err1 != nil || err2 != nil || first <= 0 || h <= 0 { + return 0, errfmt.New(errfmt.ExitConfig, "process preset has no usable initial_layer_print_height/layer_height (%v/%v)", + process["initial_layer_print_height"], process["layer_height"]).WithHint("check the recipe's process preset") + } + if z < first-1e-6 { + return 0, errfmt.New(errfmt.ExitUsage, "--filament-change-z %g is below the first layer top (%g mm)", z, first). + WithHint("give the Z where the previous filament ends: the top of its last layer") + } + n := (z - first) / h + if math.Abs(n-math.Round(n))*h > 1e-3 { + return 0, errfmt.New(errfmt.ExitUsage, "--filament-change-z %g is not a layer top (%g mm first layer, then %g mm): use %g or %g", + z, first, h, round3(first+math.Floor(n)*h), round3(first+math.Ceil(n)*h)). + WithHint("the change happens between layers; give the top of the previous filament's last layer") + } + return round3(first + (math.Round(n)+1)*h), nil +} + +func round3(f float64) float64 { return math.Round(f*1000) / 1000 } + +var hexColor = regexp.MustCompile(`^[0-9A-Fa-f]{6}$`) + +// presets are the filament presets in print order: the non-empty --filament values, else the recipe's. +func (req Request) presets() []string { + var out []string + for _, f := range req.Filaments { + if strings.TrimSpace(f) != "" { + out = append(out, f) + } + } + if len(out) == 0 { + return []string{req.Recipe.Filament} + } + return out +} + +// checkFilaments validates the multi-filament part of a request before anything is sliced. +func checkFilaments(req Request) ([]string, error) { + n := len(req.presets()) + if len(req.ChangeZs) != n-1 { + return nil, errfmt.New(errfmt.ExitUsage, "%d filament(s) need %d --filament-change-z, got %d", n, n-1, len(req.ChangeZs)). + WithHint("pass one --filament per filament in print order, and one --filament-change-z per filament after the first") + } + for i := 1; i < len(req.ChangeZs); i++ { + if req.ChangeZs[i] <= req.ChangeZs[i-1] { + return nil, errfmt.New(errfmt.ExitUsage, "--filament-change-z heights must be ascending, got %v", req.ChangeZs). + WithHint("filament n+1 starts above the nth height") + } + } + if (n > 1 || len(req.Colors) > 0) && len(req.Colors) != n { + return nil, errfmt.New(errfmt.ExitUsage, "%d filament(s) need %d --color, got %d", n, n, len(req.Colors)). + WithHint("pass each spool's colour in filament order (bambu status shows them); Bambu Studio sizes the purge between filaments from the colours") + } + colors := make([]string, 0, len(req.Colors)) + for _, c := range req.Colors { + h := strings.TrimPrefix(strings.TrimSpace(c), "#") + if !hexColor.MatchString(h) { + return nil, errfmt.New(errfmt.ExitUsage, "bad --color %q", c).WithHint("use the spool's colour as RRGGBB hex, e.g. --color FFFF00") + } + colors = append(colors, "#"+strings.ToUpper(h)) + } + return colors, nil +} + var safeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`) // SafeName turns a name into a file-safe base name. @@ -237,6 +321,12 @@ type result struct { ErrorString string `json:"error_string"` SlicedPlates []struct { WarningMessage string `json:"warning_message"` + Filaments []struct { + ID int `json:"id"` + MainUsedG *float64 `json:"main_used_g"` // model + support + TotalUsedG *float64 `json:"total_used_g"` // adds purge and prime tower + } `json:"filaments"` + FeatureTypeTimes map[string]float64 `json:"feature_type_times"` } `json:"sliced_plates"` } @@ -258,10 +348,22 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err if _, err := os.Stat(model); err != nil { return nil, errfmt.New(errfmt.ExitNotFound, "model not found: %s", req.Model) } + colors, err := checkFilaments(req) + if err != nil { + return nil, err + } cfgs, err := Build(ix, req) if err != nil { return nil, err } + tops := make([]float64, 0, len(req.ChangeZs)) + for _, z := range req.ChangeZs { + top, err := NextLayerTop(cfgs.Process, z) + if err != nil { + return nil, err + } + tops = append(tops, top) + } // Absolute paths everywhere: the Bambu Studio CLI chdirs into its app bundle (macOS: Contents/Resources), // so relative --outputdir/--export-3mf paths land inside the bundle or fail with -13. work, err := filepath.Abs(req.WorkDir) @@ -277,14 +379,25 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err return nil, errfmt.Wrap(errfmt.ExitConfig, err, "create %s", d) } } + configs := map[string]map[string]any{"machine": cfgs.Machine, "process": cfgs.Process, "filament": cfgs.Filaments[0]} + filKinds := []string{"filament"} + for i, f := range cfgs.Filaments[1:] { + kind := fmt.Sprintf("filament_%d", i+2) + configs[kind] = f + filKinds = append(filKinds, kind) + } paths := map[string]string{} - for kind, data := range map[string]map[string]any{"machine": cfgs.Machine, "process": cfgs.Process, "filament": cfgs.Filament} { + for kind, data := range configs { b, _ := json.MarshalIndent(data, "", " ") paths[kind] = filepath.Join(work, kind+".json") if err := os.WriteFile(paths[kind], b, 0o600); err != nil { return nil, errfmt.Wrap(errfmt.ExitConfig, err, "write %s config", kind) } } + filPaths := make([]string, len(filKinds)) + for i, k := range filKinds { + filPaths[i] = paths[k] + } name := SafeName(req.Name) threemf := name + ".gcode.3mf" orient := "0" @@ -293,9 +406,35 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err } args := []string{ "--slice", "0", "--arrange", "1", "--orient", orient, - "--load-settings", paths["machine"] + ";" + paths["process"], "--load-filaments", paths["filament"], - "--outputdir", work, "--export-3mf", threemf, model, + "--load-settings", paths["machine"] + ";" + paths["process"], "--load-filaments", strings.Join(filPaths, ";"), + } + if len(colors) > 0 { + args = append(args, "--filament-colour", strings.Join(colors, ";")) + } + if len(tops) > 0 { + // the change the GUI's layer slider stores (CustomGCode::Info::from_json); MultiAsSingle: one extruder, AMS swaps + type item struct { + Type string `json:"type"` + PrintZ float64 `json:"print_z"` + Extruder int `json:"extruder"` + Color string `json:"color"` + Extra string `json:"extra"` + } + cg := struct { + Mode string `json:"mode"` + Gcodes []item `json:"gcodes"` + }{Mode: "MultiAsSingle"} + for i, z := range tops { + cg.Gcodes = append(cg.Gcodes, item{Type: "ToolChange", PrintZ: z, Extruder: i + 2, Color: colors[i+1]}) + } + b, _ := json.MarshalIndent(cg, "", " ") + paths["custom_gcode"] = filepath.Join(work, "custom_gcode.json") + if err := os.WriteFile(paths["custom_gcode"], b, 0o600); err != nil { + return nil, errfmt.Wrap(errfmt.ExitConfig, err, "write custom G-code") + } + args = append(args, "--load-custom-gcodes", paths["custom_gcode"]) } + args = append(args, "--outputdir", work, "--export-3mf", threemf, model) ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, st.Binary, args...) //nolint:gosec // discovered slicer binary, validated args @@ -335,6 +474,11 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err WithHint("%s; fix the recipe/--set values; log: %s", or(CLIErrors[res.ReturnCode], "see log"), logPath). WithData("return_code", res.ReturnCode).WithData("details", details).WithData("log", logPath) } + if len(tops) > 0 { + if err := checkChanges(filepath.Join(work, threemf), tops, logPath); err != nil { + return nil, err + } + } final := filepath.Join(out, threemf) if err := move(filepath.Join(work, threemf), final); err != nil { return nil, errfmt.Wrap(errfmt.ExitError, err, "move output") @@ -351,6 +495,7 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err Name: name, Recipe: req.Recipe.Name, Description: req.Recipe.Description, Model: model, Gcode3MF: final, PreviewPNG: png, SummaryJSON: filepath.Join(out, name+".summary.json"), TimeS: j.PredictionS, Time: j.TotalTime, WeightG: j.WeightG, Layers: j.Layers, Filaments: j.Filaments, + FilamentChanges: j.FilamentChanges, FilamentPresets: cfgs.FilamentPresets, BedType: j.BedType, BedTemp: j.BedTemp, NozzleTemp: j.NozzleTemp, NozzleFirst: j.NozzleTempFirst, Presets: cfgs.Presets, Overrides: cfgs.Applied, Notes: cfgs.Notes, Warnings: []string{}, Settings: j.Settings, AutoOrient: req.AutoOrient, SlicerLog: logPath, StudioVersion: st.Version, @@ -359,6 +504,15 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err if p.WarningMessage != "" { sum.Warnings = append(sum.Warnings, p.WarningMessage) } + sum.PrimeTower = sum.PrimeTower || p.FeatureTypeTimes["Prime tower"] > 0 + for _, u := range p.Filaments { + for i := range sum.Filaments { + if f := &sum.Filaments[i]; f.Index == u.ID-1 && u.MainUsedG != nil && u.TotalUsedG != nil { + model, waste := math.Round(*u.MainUsedG*100)/100, math.Round((*u.TotalUsedG-*u.MainUsedG)*100)/100 + f.ModelG, f.WasteG = &model, &waste + } + } + } } b, _ := json.MarshalIndent(sum, "", " ") if err := os.WriteFile(sum.SummaryJSON, b, 0o600); err != nil { @@ -367,6 +521,29 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err return sum, nil } +// checkChanges reads the filament changes back from the sliced G-code. Bambu Studio drops a ToolChange without a word +// when the plate mode doesn't fit (ToolOrdering.cpp), so the G-code is the only proof the change is there. +func checkChanges(threemf string, tops []float64, logPath string) error { + j, err := job.Inspect(threemf, 1) + if err != nil { + return err + } + want := make([]job.FilamentChange, len(tops)) + for i, z := range tops { + want[i] = job.FilamentChange{Z: z, Filament: i + 2} + } + got := make([]job.FilamentChange, len(j.FilamentChanges)) + for i, c := range j.FilamentChanges { + got[i] = job.FilamentChange{Z: round3(c.Z), Filament: c.Filament} + } + if !slices.Equal(got, want) { + return errfmt.New(errfmt.ExitSliceFailed, "the slicer did not place the filament change(s) as asked: wanted %v, G-code has %v", want, j.FilamentChanges). + WithHint("nothing was written to the output dir; check the model height and the log: %s", logPath). + WithData("wanted", want).WithData("filament_changes", j.FilamentChanges).WithData("log", logPath) + } + return nil +} + func or(a, b string) string { if a != "" { return a diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index d8d6321..c2d2cdc 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -2,13 +2,16 @@ package slicer import ( "context" + "encoding/json" "os" "path/filepath" + "reflect" "runtime" "strings" "testing" "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/job" "github.com/voska/bambu/internal/recipe" "github.com/voska/bambu/internal/testutil" ) @@ -117,11 +120,11 @@ func TestBuild(t *testing.T) { if c.Process["brim_type"] != "no_brim" || c.Process["wall_loops"] != "4" { t.Errorf("overrides: %v %v", c.Process["brim_type"], c.Process["wall_loops"]) } - nt := c.Filament["nozzle_temperature"].([]any) + nt := c.Filaments[0]["nozzle_temperature"].([]any) if len(nt) != 2 || nt[0] != "215" || nt[1] != "215" { t.Errorf("scalar must broadcast to array length: %v", nt) } - if fm := c.Filament["fan_max_speed"].([]any); len(fm) != 1 || fm[0] != "30" { + if fm := c.Filaments[0]["fan_max_speed"].([]any); len(fm) != 1 || fm[0] != "30" { t.Errorf("--set routed to filament: %v", fm) } if da := c.Process["default_acceleration"].([]any); da[1] != "6000" { @@ -148,11 +151,15 @@ func TestBuildErrors(t *testing.T) { t.Errorf("bad bed: %v", err) } rq := req(r) - rq.Filament = "Generic PLA" + rq.Filaments = []string{"Generic PLA"} c, err := Build(ix, rq) if err != nil || c.Presets["filament"] != "Generic PLA" { t.Errorf("--filament override: %v %v", c, err) } + rq.Filaments = []string{""} + if c, err := Build(ix, rq); err != nil || c.Presets["filament"] != "Bambu PLA Basic @BBL TP" { + t.Errorf(`--filament "" keeps the recipe's filament: %v %v`, c, err) + } } func TestDiscoverEnv(t *testing.T) { @@ -174,9 +181,11 @@ func TestDiscoverEnv(t *testing.T) { } // fakeStudio emulates the Bambu Studio CLI: it copies a prepared 3MF to --outputdir/--export-3mf -// and writes result.json, or fails like the real CLI when FAKE_FAIL is set. +// and writes result.json (FAKE_RESULT's, if set), or fails like the real CLI when FAKE_FAIL is set. +// It records its arguments, one per line, in FAKE_ARGS. const fakeStudio = `#!/bin/sh out=""; name="" +[ -n "$FAKE_ARGS" ] && printf '%s\n' "$@" > "$FAKE_ARGS" while [ $# -gt 0 ]; do case "$1" in --outputdir) out="$2"; shift ;; @@ -190,14 +199,25 @@ if [ -n "$FAKE_FAIL" ]; then exit 238 fi cp "$FAKE_3MF" "$out/$name" +if [ -n "$FAKE_RESULT" ]; then cp "$FAKE_RESULT" "$out/result.json"; exit 0; fi echo '{"return_code": 0, "error_string": "Success.", "sliced_plates": [{"warning_message": ""}]}' > "$out/result.json" ` +func readArgs(t *testing.T) string { + t.Helper() + b, err := os.ReadFile(os.Getenv("FAKE_ARGS")) + if err != nil { + t.Fatal(err) + } + return string(b) +} + func TestRunWithFakeStudio(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell script fake") } dir := t.TempDir() + t.Setenv("FAKE_ARGS", filepath.Join(dir, "args")) bin := filepath.Join(dir, "studio.sh") _ = os.WriteFile(bin, []byte(fakeStudio), 0o755) t.Setenv("FAKE_3MF", testutil.Write(t, dir, "prepared", testutil.Opts{})) @@ -217,6 +237,13 @@ func TestRunWithFakeStudio(t *testing.T) { if !filepath.IsAbs(sum.Gcode3MF) { t.Fatal("output path must be absolute") } + if args := readArgs(t); strings.Contains(args, "--filament-colour") || strings.Contains(args, "--load-custom-gcodes") || + strings.Count(strings.Split(args, "--load-filaments\n")[1], ";") != 0 { + t.Fatalf("single filament: same Studio invocation as before:\n%s", args) + } + if len(sum.FilamentChanges) != 0 || sum.FilamentChanges == nil || !reflect.DeepEqual(sum.FilamentPresets, []string{"Bambu PLA Basic @BBL TP"}) { + t.Fatalf("%+v %+v", sum.FilamentChanges, sum.FilamentPresets) + } t.Setenv("FAKE_FAIL", "1") _, err = Run(context.Background(), st, index(t), rq) @@ -231,3 +258,141 @@ func TestRunWithFakeStudio(t *testing.T) { t.Fatalf("STEP must be rejected with usage: %v", err) } } + +func TestNextLayerTop(t *testing.T) { + proc := map[string]any{"initial_layer_print_height": "0.2", "layer_height": "0.2"} + for z, want := range map[float64]float64{4.0: 4.2, 0.2: 0.4, 5.2: 5.4} { + if got, err := NextLayerTop(proc, z); err != nil || got != want { + t.Errorf("%g: %g %v", z, got, err) + } + } + for _, z := range []float64{4.1, 0.1} { + if _, err := NextLayerTop(proc, z); errfmt.As(err).Code != errfmt.ExitUsage { + t.Errorf("%g must be refused: %v", z, err) + } + } + _, err := NextLayerTop(proc, 4.1) + if e := errfmt.As(err); !strings.Contains(e.Message, "use 4 or 4.2") { + t.Errorf("suggest the neighbouring layer tops: %q", e.Message) + } + thick := map[string]any{"initial_layer_print_height": "0.3", "layer_height": "0.12"} + if got, err := NextLayerTop(thick, 0.54); err != nil || got != 0.66 { + t.Errorf("0.3 first layer, 0.12 after: %g %v", got, err) + } +} + +func TestBuildTwoFilaments(t *testing.T) { + r := recipe.Recipe{Process: "0.20mm Standard", Filament: "Bambu PLA Basic", FilamentOverrides: map[string]any{"nozzle_temperature": "215"}} + rq := req(r, "fan_max_speed=30") + rq.Filaments = []string{"Generic PLA", "Bambu PLA Basic"} + c, err := Build(index(t), rq) + if err != nil { + t.Fatal(err) + } + if len(c.Filaments) != 2 || c.Presets["filament"] != "Generic PLA" || !reflect.DeepEqual(c.FilamentPresets, []string{"Generic PLA", "Bambu PLA Basic @BBL TP"}) { + t.Fatalf("%v %v", c.Presets, c.FilamentPresets) + } + for i, f := range c.Filaments { + if fm := f["fan_max_speed"].([]any); fm[0] != "30" { + t.Errorf("filament %d: --set applies to every filament: %v", i+1, fm) + } + if nt := f["nozzle_temperature"].([]any); nt[0] != "215" { + t.Errorf("filament %d: recipe overrides apply to every filament: %v", i+1, nt) + } + } +} + +// twoFilamentRun slices with the recorded two-filament 3MF and result.json standing in for Bambu Studio's output. +func twoFilamentRun(t *testing.T, mut func(*Request)) (*Summary, Request, error) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell script fake") + } + dir := t.TempDir() + bin := filepath.Join(dir, "studio.sh") + _ = os.WriteFile(bin, []byte(fakeStudio), 0o755) + t.Setenv("FAKE_ARGS", filepath.Join(dir, "args")) + t.Setenv("FAKE_3MF", testutil.FromDir(t, "../job/testdata/two_filament", dir, "prepared")) + result, _ := filepath.Abs("../job/testdata/two_filament/result.json") // the fake runs in the work dir + t.Setenv("FAKE_RESULT", result) + model := filepath.Join(dir, "part.3mf") + _ = os.WriteFile(model, []byte("PK"), 0o600) + rq := req(recipe.Recipe{Name: "t", Process: "0.20mm Standard", Filament: "Bambu PLA Basic"}) + rq.Model, rq.Name, rq.OutDir, rq.WorkDir = model, "two", filepath.Join(dir, "out"), filepath.Join(dir, "work") + rq.Filaments, rq.Colors, rq.ChangeZs = []string{"Generic PLA", "Bambu PLA Basic"}, []string{"ffffff", "#000000"}, []float64{4.0} + if mut != nil { + mut(&rq) + } + sum, err := Run(context.Background(), &Studio{Binary: bin, Resources: "testdata/resources"}, index(t), rq) + return sum, rq, err +} + +func TestRunTwoFilaments(t *testing.T) { + sum, rq, err := twoFilamentRun(t, nil) + if err != nil { + t.Fatal(err) + } + args := readArgs(t) + for _, want := range []string{ + "--load-filaments\n" + filepath.Join(rq.WorkDir, "filament.json") + ";" + filepath.Join(rq.WorkDir, "filament_2.json") + "\n", + "--filament-colour\n#FFFFFF;#000000\n", + "--load-custom-gcodes\n" + filepath.Join(rq.WorkDir, "custom_gcode.json") + "\n", + } { + if !strings.Contains(args, want) { + t.Errorf("Studio args missing %q:\n%s", want, args) + } + } + b, _ := os.ReadFile(filepath.Join(rq.WorkDir, "custom_gcode.json")) + var cg map[string]any + _ = json.Unmarshal(b, &cg) + want := map[string]any{"mode": "MultiAsSingle", "gcodes": []any{map[string]any{ + "type": "ToolChange", "print_z": 4.2, "extruder": float64(2), "color": "#000000", "extra": "", + }}} + if !reflect.DeepEqual(cg, want) { + t.Fatalf("custom G-code: the layer-slider change Studio stores (filament 2 from print_z 4.2):\n%s", b) + } + if !reflect.DeepEqual(sum.FilamentChanges, []job.FilamentChange{{Layer: 21, Z: 4.2, Filament: 2}}) || !sum.PrimeTower { + t.Fatalf("%+v prime_tower=%v", sum.FilamentChanges, sum.PrimeTower) + } + f1, f2 := sum.Filaments[0], sum.Filaments[1] + if *f1.ModelG != 16.02 || *f1.WasteG != 0.97 || *f2.ModelG != 2.89 || *f2.WasteG != 0.23 || f2.UsedG != 3.12 { + t.Fatalf("grams: %+v %+v", f1, f2) + } + if !exists(sum.Gcode3MF) { + t.Fatal("output missing") + } +} + +func TestRunTwoFilamentsRefusedBeforeSlicing(t *testing.T) { + cases := map[string]func(*Request){ + "no change height": func(r *Request) { r.ChangeZs = nil }, + "change height off-layer": func(r *Request) { r.ChangeZs = []float64{4.1} }, + "one colour": func(r *Request) { r.Colors = r.Colors[:1] }, + "bad colour": func(r *Request) { r.Colors[1] = "black" }, + "heights not ascending": func(r *Request) { + r.Filaments, r.Colors, r.ChangeZs = append(r.Filaments, "Generic PLA"), append(r.Colors, "FF0000"), []float64{4.0, 2.0} + }, + "change height, one filament": func(r *Request) { r.Filaments, r.Colors = r.Filaments[:1], nil }, + } + for name, mut := range cases { + _, rq, err := twoFilamentRun(t, mut) + if errfmt.As(err).Code != errfmt.ExitUsage { + t.Errorf("%s: want usage error, got %v", name, err) + } + if exists(filepath.Join(rq.WorkDir, "result.json")) { + t.Errorf("%s: Studio must not run", name) + } + } +} + +func TestRunTwoFilamentsChangeNotInGcode(t *testing.T) { + // the recorded G-code changes at Z 4.2; asking for a change after Z 3.0 must not pass as a good slice + _, rq, err := twoFilamentRun(t, func(r *Request) { r.ChangeZs = []float64{3.0} }) + e := errfmt.As(err) + if e.Code != errfmt.ExitSliceFailed || !strings.Contains(e.Message, "filament change") { + t.Fatalf("want slice failure, got %v", err) + } + if exists(filepath.Join(rq.OutDir, "two.gcode.3mf")) || exists(filepath.Join(rq.OutDir, "two.summary.json")) { + t.Fatal("a slice without the asked-for change must not reach the output dir") + } +} diff --git a/internal/slicer/testdata/resources/profiles/BBL/process/fdm_process_common.json b/internal/slicer/testdata/resources/profiles/BBL/process/fdm_process_common.json index dfc461a..782f40f 100644 --- a/internal/slicer/testdata/resources/profiles/BBL/process/fdm_process_common.json +++ b/internal/slicer/testdata/resources/profiles/BBL/process/fdm_process_common.json @@ -1,3 +1,3 @@ {"type": "process", "name": "fdm_process_common", "from": "system", "instantiation": "false", - "layer_height": "0.2", "wall_loops": "2", "sparse_infill_density": "15%", "sparse_infill_pattern": "grid", + "layer_height": "0.2", "initial_layer_print_height": "0.2", "wall_loops": "2", "sparse_infill_density": "15%", "sparse_infill_pattern": "grid", "brim_type": "auto_brim", "brim_width": "5", "default_acceleration": ["10000", "10000"]} diff --git a/internal/testutil/threemf.go b/internal/testutil/threemf.go index f7394ed..83cf637 100644 --- a/internal/testutil/threemf.go +++ b/internal/testutil/threemf.go @@ -105,3 +105,41 @@ func Write(t *testing.T, dir, name string, o Opts) string { } return path } + +// FromDir zips a recorded slice (plate_1.gcode, slice_info.config, project_settings.config, plate_1.json) from src +// into /.gcode.3mf with a matching plate_1.gcode.md5, and returns its path. result.json is the slicer's +// own output, not part of the 3MF, and is skipped. +func FromDir(t *testing.T, src, dir, name string) string { + t.Helper() + entries, err := os.ReadDir(src) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name+".gcode.3mf") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + zw := zip.NewWriter(f) + for _, e := range entries { + if e.Name() == "result.json" { + continue + } + b, err := os.ReadFile(filepath.Join(src, e.Name())) //nolint:gosec // test fixture + if err != nil { + t.Fatal(err) + } + w, _ := zw.Create("Metadata/" + e.Name()) + _, _ = w.Write(b) + if e.Name() == "plate_1.gcode" { + sum := md5.Sum(b) //nolint:gosec // fixture + w, _ := zw.Create("Metadata/plate_1.gcode.md5") + _, _ = w.Write([]byte(hex.EncodeToString(sum[:]))) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return path +} diff --git a/skills/bambu/SKILL.md b/skills/bambu/SKILL.md index 7379290..2f03bc8 100644 --- a/skills/bambu/SKILL.md +++ b/skills/bambu/SKILL.md @@ -31,10 +31,10 @@ bambu auth status --check --json && bambu slicer info --json ## Safety rules for agents (non-negotiable) -1. **Never pass `--confirm` on your own.** `print send --confirm` starts a real print. Only use it after a human explicitly approved **this exact file** and confirmed **the plate is clear**. The same goes for `print pause|resume|stop --confirm`, except that pausing on a visible failure is acceptable (tell the human). +1. **Never pass `--confirm` on your own.** `print send --confirm` starts a real print. Only use it after a human explicitly approved **this exact file** and confirmed **the plate is clear**. The same goes for `print pause|resume|stop --confirm` and `filament load --confirm`, except that pausing on a visible failure is acceptable (tell the human). 2. **Always run `print send --dry-run` first.** It runs every preflight gate and shows the exact payload. Resolve FAILs and report WARNs. 3. **Don't touch a busy printer.** If `status` isn't IDLE/FINISH/FAILED, a job is running; it may be the human's. -4. `bambu` cannot and must not change printer settings. Ask the human for filament swaps, plate swaps and toggles. +4. `bambu` cannot and must not change printer settings. Ask the human to load spools, swap plates and change toggles. Switching the toolhead between spools already in the AMS is `filament load`, only inside an approved job. ## Workflow @@ -61,6 +61,19 @@ bambu monitor --snapshot-at-layer 2 --json # NDJSON events; **Slots** are `1`–`4` (first AMS) or `A1`–`D4`. Preflight checks that the slot's filament type matches the sliced one. +**Two colours, changing at a layer height** (the AMS swaps by itself, from the G-code): + +```bash +bambu slice part.3mf --recipe prototype-pla --filament "Generic PLA" --color FFFFFF \ + --filament "Bambu PLA Matte" --color 000000 --filament-change-z 4.0 --json # Z must be a layer top +bambu print send part-prototype-pla.gcode.3mf --slot A4 --slot A1 --dry-run --json # one --slot per filament, in order +``` + +- Match each `--filament`/`--color` to the spool in the slot you'll map it to (`status`). If two PLA slots are swapped, only the profile WARNs show it, so check the order yourself. +- Report each filament's `used_g` (with `waste_g` purge/prime tower) and `filament_changes` to the human. The preview PNG shows only the first filament's colour. + +**Colour change at a pause** (an approved job that pauses for a different spool): `bambu filament load --slot A1 --confirm --json` exits 0 only once A1 is in the toolhead. Check the camera, then `bambu print resume --confirm`, which exits 0 only once the printer reports RUNNING. + ## Reading results - `status.errors.blocking` is the flag that matters. Informational HMS codes (e.g. `0C00_0300_0003_000B` "Inspecting first layer" around layer 2) are listed but don't block. Each HMS entry has a wiki `url`. @@ -70,6 +83,7 @@ bambu monitor --snapshot-at-layer 2 --json # NDJSON events; - 12: FAILED - 13: PAUSED (e.g. a first-layer or spaghetti halt; needs a human) - 14: `--timeout` hit while still printing +- `filament load` and `print resume` exit 14 when the printer doesn't confirm in time. Don't resume after a failed load: look at the camera first. - Calibration before layer 1 can take about 8 minutes (layer stays 0). That's normal. ## Agent introspection diff --git a/skills/bambu/references/COMMANDS.md b/skills/bambu/references/COMMANDS.md index ac034dd..85d6b1b 100644 --- a/skills/bambu/references/COMMANDS.md +++ b/skills/bambu/references/COMMANDS.md @@ -52,6 +52,16 @@ Save one camera frame as JPEG (X1/H2/P2S series; needs ffmpeg and LAN Mode Livev Print the exit code table. +### `bambu filament load` + +Unload the toolhead and load an AMS slot: a remote colour change at a pause (requires --confirm). + +| flag | type | default | help | +|---|---|---|---| +| `--slot, -s` | string | | AMS slot to load: 1-4 (first AMS) or A1-D4. (required) | +| `--confirm` | bool | | Required: the printer cuts the filament in the toolhead, loads the slot and purges. Only as part of an approved job. | +| `--timeout` | time.Duration | 5m | How long to wait for the printer to report the slot loaded (exit 14 if it doesn't). | + ### `bambu monitor` Follow the current job until it ends; exit code reflects the outcome. @@ -70,7 +80,7 @@ Run the safety gates for a sliced file (read-only). | flag | type | default | help | |---|---|---|---| -| `--slot, -s` | string | | AMS slot: 1-4 (first AMS) or A1-D4. (required) | +| `--slot, -s` | []string | | AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1). (required) | | `--no-ftp` | bool | | Skip the FTPS login check. | ### `bambu print pause` @@ -88,6 +98,7 @@ Resume a paused job (requires --confirm). | flag | type | default | help | |---|---|---|---| | `--confirm` | bool | | Required: only resume once the cause of the pause is fixed. | +| `--wait` | time.Duration | 30s | How long to wait for the printer to report RUNNING (exit 14 if it doesn't). | ### `bambu print send ` @@ -95,7 +106,7 @@ Upload a sliced file and start it (requires --confirm and a passing preflight). | flag | type | default | help | |---|---|---|---| -| `--slot, -s` | string | | AMS slot: 1-4 (first AMS) or A1-D4. (required) | +| `--slot, -s` | []string | | AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1). (required) | | `--confirm` | bool | | Required to actually print. Only pass it after a human approved this exact file and confirmed the plate is clear. | | `--dry-run, -n` | bool | | Run preflight and show the exact upload path and MQTT payload; send nothing. | | `--timelapse` | bool | | Record a timelapse. | @@ -165,7 +176,9 @@ Slice a model headlessly with Bambu Studio and a recipe. | flag | type | default | help | |---|---|---|---| | `--recipe, -r` | string | | Recipe name (see: bambu recipe list). (required) | -| `--filament, -f` | string | | Filament preset to use instead of the recipe's (e.g. "Generic PLA", "Bambu PLA Matte"). | +| `--filament, -f` | []string | | Filament preset to use instead of the recipe's (e.g. "Generic PLA", "Bambu PLA Matte"). Repeat once per filament, in print order, for a multi-colour job. | +| `--color` | []string | | Spool colour per --filament, same order (e.g. FFFFFF). Required with two or more filaments: Bambu Studio sizes the purge between them from the colours. | +| `--filament-change-z` | []float64 | | The next --filament starts on the first layer above Z mm (the top of the previous filament's last layer); the AMS swaps automatically. Once per filament after the first. | | `--set, -s` | []string | | Override a Bambu Studio setting: key=value (repeatable). | | `--name, -n` | string | | Output base name (default -). | | `--out, -o` | string | | Output directory (default: config output_dir, else current dir). |