From 0cbb79732823e9fb7fe75d9c77262185cba833a4 Mon Sep 17 00:00:00 2001 From: physicsG Date: Tue, 18 Aug 2026 17:27:35 +0200 Subject: [PATCH 01/16] feat(ams): Snapmaker U1 + multiACE support (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this brings - **multiACE backend** (`AmsBackendMultiAce`) — 1–4 Anycubic ACE units feeding a U1's four SnapSwap toolheads. Derives from `AmsBackendSnapmaker` rather than forking it: unit 0 stays the U1's heads with `print_task_config`, the `channel_state` load latch and the sensors intact; units 1..N are the ACE hardware. - **Detection** preferring MULTIACE over the AFC compatibility shim. - **Per-unit topology**, so a bound ACE draws as the combiner it is rather than a parallel fan. - **Spool identity** resolved through `spool_binding` → `spools` and the `slot_overrides.json` layer, so an ACE-fed head and its bay share one spool number. - **Dryer and humidity-controlled auto-dry** (`ACE_DRY`, `ACE_SET_AUTO_DRY`) with live temp/duration. - **Toolhead context menu** for a toolchanger surface (Select / Park / Load / Unload). - **ACE-fed dispatch** — `ACE_LOAD_HEAD` / `ACE_UNLOAD_HEAD` for heads the ACE owns, the native U1 path for stock-feeder heads. ## Follow-up physicsG/helixscreen#5 (`fix/multiace-swap-steps`) targets the same development branch and carries the swap-step fixes, hardware-verified on a live U1. **It needs rebasing onto this branch's squashed result after this PR merges** — a squash creates a commit that is not an ancestor of this branch, so until then #5 shows this branch's whole diff as well as its own. --- .gitignore | 4 + Makefile | 49 +- docs/devel/HELIXCTL.md | 2 +- .../plans/2026-08-09-multiace-mockup.html | 2037 +++++++++++++++++ .../2026-08-09-snapmaker-u1-multiace-plan.md | 899 ++++++++ .../2026-08-10-multiace-dryer-mockup.html | 406 ++++ ...08-10-multiace-ui-improvements-mockup.html | 679 ++++++ ...6-07-25-helixctl-ui-test-harness-design.md | 2 +- .../components/helixapp/app_srcs.txt | 2 + include/ams_backend.h | 340 +++ include/ams_backend_ace.h | 6 + include/ams_backend_mock.h | 19 + include/ams_backend_multiace.h | 409 ++++ include/ams_backend_snapmaker.h | 163 +- include/ams_step_operation.h | 79 + include/ams_subscription_backend.h | 40 +- include/ams_types.h | 51 +- include/filament_op_dispatch.h | 11 +- include/filament_slot_override_store.h | 8 + include/keyboard_layout_provider.h | 5 +- include/printer_discovery.h | 65 +- include/ui/ams_drawing_utils.h | 31 +- include/ui_ams_context_menu.h | 74 +- include/ui_ams_environment_overlay.h | 9 + include/ui_ams_sidebar.h | 6 +- include/ui_ams_toolhead_menu.h | 264 +++ include/ui_filament_path_canvas.h | 25 + include/ui_keyboard_manager.h | 3 +- include/ui_panel_ams.h | 8 + include/ui_panel_ams_overview.h | 26 +- include/ui_system_path_canvas.h | 36 + scripts/check_doc_refs.py | 195 +- src/api/moonraker_discovery_sequence.cpp | 34 +- src/printer/ams_backend.cpp | 14 + src/printer/ams_backend_ace.cpp | 11 +- src/printer/ams_backend_mock.cpp | 147 ++ src/printer/ams_backend_multiace.cpp | 1148 ++++++++++ src/printer/ams_backend_snapmaker.cpp | 293 ++- src/printer/ams_state.cpp | 212 +- src/printer/ams_subscription_backend.cpp | 16 + src/printer/filament_slot_override_store.cpp | 7 +- src/system/runtime_config.cpp | 1 + src/ui/ams_drawing_utils.cpp | 98 +- src/ui/keyboard_layout_provider.cpp | 59 + src/ui/ui_ams_context_menu.cpp | 208 +- src/ui/ui_ams_detail.cpp | 34 +- src/ui/ui_ams_edit_overlay.cpp | 9 +- src/ui/ui_ams_environment_overlay.cpp | 73 + src/ui/ui_ams_mini_status.cpp | 65 +- src/ui/ui_ams_sidebar.cpp | 39 +- src/ui/ui_ams_slot.cpp | 232 +- src/ui/ui_ams_toolhead_menu.cpp | 428 ++++ src/ui/ui_filament_mapping_modal.cpp | 9 +- src/ui/ui_filament_path_canvas.cpp | 53 +- src/ui/ui_filament_path_internal.h | 10 + src/ui/ui_filament_path_topology.cpp | 48 +- src/ui/ui_filament_runout_handler.cpp | 1 + src/ui/ui_keyboard_manager.cpp | 96 +- src/ui/ui_panel_ams.cpp | 32 +- src/ui/ui_panel_ams_overview.cpp | 170 +- src/ui/ui_panel_filament.cpp | 10 +- src/ui/ui_system_path_canvas.cpp | 170 +- src/ui/ui_wizard_ams_identify.cpp | 2 + .../u1-multiace-head-mode-idle.json | 1288 +++++++++++ .../test_afc_shared_extruder_toolheads.cpp | 10 +- .../test_afc_toolhead_extruder_identity.cpp | 18 +- .../unit/test_ams_backend_mock_realistic.cpp | 10 + tests/unit/test_ams_backend_multiace.cpp | 1390 +++++++++++ tests/unit/test_ams_backend_snapmaker.cpp | 206 ++ tests/unit/test_ams_context_menu.cpp | 90 +- tests/unit/test_ams_paused_filament_ops.cpp | 61 + tests/unit/test_ams_step_operation.cpp | 132 ++ tests/unit/test_ams_system_tool_layout.cpp | 246 ++ tests/unit/test_ams_toolhead_menu_model.cpp | 278 +++ .../test_filament_path_parallel_states.cpp | 105 + tests/unit/test_keyboard_layout_provider.cpp | 115 + .../test_multiace_vs_anycubic_detection.cpp | 139 ++ tests/unit/test_tool_state.cpp | 238 ++ .../test_ui_ams_context_menu_dispatch.cpp | 37 + translations/en.yml | 6 + ui_xml/ams_context_menu.xml | 19 + ui_xml/ams_environment_overlay.xml | 18 + ui_xml/ams_toolhead_menu.xml | 54 + ui_xml/translations/en.xml | 6 + ui_xml/translations/translations.xml | 6 + 85 files changed, 13796 insertions(+), 358 deletions(-) create mode 100644 docs/devel/plans/2026-08-09-multiace-mockup.html create mode 100644 docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md create mode 100644 docs/devel/plans/2026-08-10-multiace-dryer-mockup.html create mode 100644 docs/devel/plans/2026-08-10-multiace-ui-improvements-mockup.html create mode 100644 include/ams_backend_multiace.h create mode 100644 include/ui_ams_toolhead_menu.h create mode 100644 src/printer/ams_backend_multiace.cpp create mode 100644 src/ui/ui_ams_toolhead_menu.cpp create mode 100644 tests/fixtures/snapmaker_u1/u1-multiace-head-mode-idle.json create mode 100644 tests/unit/test_ams_backend_multiace.cpp create mode 100644 tests/unit/test_ams_toolhead_menu_model.cpp create mode 100644 tests/unit/test_filament_path_parallel_states.cpp create mode 100644 tests/unit/test_multiace_vs_anycubic_detection.cpp create mode 100644 ui_xml/ams_toolhead_menu.xml diff --git a/.gitignore b/.gitignore index 7e8367bc61..e0c667cc59 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,7 @@ docs/superpowers/ # Local crash artifacts (preserved across compactions for ASAN investigation) .crash-files/ + +# Per-machine AMS slot overrides, written at runtime when the app runs with the +# repo as its config dir (no HELIX_CONFIG_DIR). Machine state, not a default. +config/filament_slot_overrides.json diff --git a/Makefile b/Makefile index a4e295408d..4c37aba9ef 100644 --- a/Makefile +++ b/Makefile @@ -180,8 +180,29 @@ ifeq ($(PLATFORM_TARGET),yocto) CFLAGS += -std=c11 -Wall -Wextra -D_GNU_SOURCE -Wno-psabi CXXFLAGS += -std=c++17 -Wall -Wextra -Wno-psabi else - CFLAGS := -std=c11 -Wall -Wextra -O$(OPT) -g -D_GNU_SOURCE -fno-omit-frame-pointer -fstack-protector-strong - CXXFLAGS := -std=c++17 -Wall -Wextra -O$(OPT) -g -fno-omit-frame-pointer -fstack-protector-strong + # DWARF is emitted per translation unit with no cross-TU dedup, so every one + # of the ~800 test files that includes catch_amalgamated.hpp — plus LVGL, + # nlohmann::json and spdlog in almost every file — ships its own full copy of + # those type descriptions. That is why .debug_info reached 2.88 GB against + # 67 MB of actual code, and why a 16 GB object tree kept evicting itself from + # ccache. + # + # -gz compresses the debug sections in place. It shrinks the objects AND the + # binaries, which is what matters here: smaller objects mean more of a build + # fits in ccache and less I/O per rebuild. + # + # MEASURED, not assumed. -fdebug-types-section was tried first, since COMDAT + # type units target the duplication directly: it took only ~5% off the + # binaries and added 36% to the object tree (15.4 GB -> 21 GB), because GCC's + # per-type-unit overhead outweighed the dedup at this TU count. Net loss for + # the thing being fixed, so it is not used. + # + # Probed, not assumed: this block also feeds the cross toolchains (pi, ad5m, + # cc1), where an unsupported flag would break the build outright. + DEBUG_SIZE_FLAGS := $(shell printf 'int main(){return 0;}' | \ + $(CXX) -x c++ -gz -c -o /dev/null - >/dev/null 2>&1 && echo -gz) + CFLAGS := -std=c11 -Wall -Wextra -O$(OPT) -g $(DEBUG_SIZE_FLAGS) -D_GNU_SOURCE -fno-omit-frame-pointer -fstack-protector-strong + CXXFLAGS := -std=c++17 -Wall -Wextra -O$(OPT) -g $(DEBUG_SIZE_FLAGS) -fno-omit-frame-pointer -fstack-protector-strong ifneq ($(OPT),0) CFLAGS += -D_FORTIFY_SOURCE=2 CXXFLAGS += -D_FORTIFY_SOURCE=2 @@ -583,6 +604,8 @@ else LIBHV_LIBS := $(LIBHV_LIB) endif + + # libhv generates include/hv headers during libhv-build. Track json.hpp so a # stale archive cannot be reused when generated headers are missing. ifneq ($(LIBHV_LIB),) @@ -758,6 +781,12 @@ ifeq ($(YOCTO_BUILD),yes) LDFLAGS += -lssl -lcrypto endif LDFLAGS += $(TARGET_LDFLAGS) + # No -gz here on purpose. DEBUG_SIZE_FLAGS is defined only on the non-Yocto + # branch, and there it reaches the linker through CXXFLAGS (mk/rules.mk + # links both binaries with $(CXX) $(CXXFLAGS) ... $(LDFLAGS)), so the debug + # sections stay compressed in the linked output without a separate LDFLAGS + # entry. On this Yocto branch nothing is -gz-compressed to begin with. + PLATFORM := Linux-yocto # No submodule wpa_client to depend on — wpa-supplicant recipe installs libwpa_client. WPA_DEPS := @@ -914,6 +943,22 @@ endif CXXFLAGS += $(ALSA_CXXFLAGS) LDFLAGS += $(ALSA_LIBS) +# Link with mold when available. GNU ld spends ~97s on helix-screen and longer +# on helix-tests, and that link IS the cost of an edit-one-file iteration — +# compiling the changed TU takes about a second. mold is a drop-in: same inputs, +# same output, gdb reads it identically. +# +# MUST live down here for the same reason ASAN's flags do — the per-platform +# `LDFLAGS :=` composition above clobbers anything appended earlier. Probed by +# actually linking rather than `which mold`, because the cross toolchains (pi, +# ad5m, cc1) use a different g++ that may not accept -fuse-ld=mold, and a link +# failure there is worse than a slow link. +HELIX_USE_MOLD := $(shell printf 'int main(){return 0;}' | \ + $(CXX) -x c++ -fuse-ld=mold -o /dev/null - >/dev/null 2>&1 && echo yes) +ifeq ($(HELIX_USE_MOLD),yes) + LDFLAGS += -fuse-ld=mold +endif + # Re-apply AddressSanitizer linker flags AFTER the per-platform LDFLAGS # composition above (lines 580-694) — those use `LDFLAGS :=` which clobbers # anything cross.mk's SANITIZE block injected. Must come last so platform diff --git a/docs/devel/HELIXCTL.md b/docs/devel/HELIXCTL.md index fecd1b330e..6cb3dd619a 100644 --- a/docs/devel/HELIXCTL.md +++ b/docs/devel/HELIXCTL.md @@ -728,7 +728,7 @@ frame doesn't change again a moment later. It combines three things: `unfreeze`. `freeze` is a transient test-mode toggle; a `--remote` dev instance killed or crashed between the two would otherwise leave a real user's config with animations permanently disabled — automated tests never - see this because `--test` uses `settings-test.json`. The handler instead + see this because `--test` uses `config/settings-test.json`. The handler instead reads and writes `DisplaySettingsManager::subject_animations_enabled()` directly (an accessor already public and already used by several widgets to observe this setting), and remembers the real pre-freeze value so `unfreeze` diff --git a/docs/devel/plans/2026-08-09-multiace-mockup.html b/docs/devel/plans/2026-08-09-multiace-mockup.html new file mode 100644 index 0000000000..f89fd040a8 --- /dev/null +++ b/docs/devel/plans/2026-08-09-multiace-mockup.html @@ -0,0 +1,2037 @@ +HelixScreen — Snapmaker U1 + multiACE mockups + + + +
+ +
+

HelixScreen · design record · 2026-08-09 · verified on hardware

+
+

Four toolheads, not one merger

+

The Snapmaker U1 has four physically independent toolheads. HelixScreen + was drawing them fanned into a single merger box feeding one nozzle. Here is why, the + fix, and what multiACE adds on top.

+

Every mockup here is drawn at 480 × 320, + 1:1 — the U1's actual panel. There is no wider frame on this page on purpose: a + layout that only works at 800 px is not a layout for this machine. Each screen is + measured in your browser on load and labelled fits or + overflows.

+

Everything below was read from the U1 at + 192.168.2.242 — Klipper 1.5.2.13, multiACE 0.99.6.1b. Device + screens are drawn at true pixel size in HelixScreen's shipped theme.

+
+
+ plan · 2026-08-09-snapmaker-u1-multiace-plan.md + fixture · tests/fixtures/multiace/u1-multiace-head-mode-idle.json + §01 fixed · §04+ proposed + measuring… +
+
+ + +
+
00

Try it

+ +
+

A working prototype of the panel at the U1's real size. Tap a head to change its + source; the swap runs with its step bar and the head updates. Every action prints the + G-code HelixScreen would actually send, so this reviews as a protocol proposal, + not just a picture.

+

Swaps are time-compressed — a real + ACE_SWAP_HEAD takes up to three minutes.

+
+ +
+
+
+ +
+
+

tap a head to change its source

+
+ +
+
+ Hardware +
+ + + +
+
+ +
+ Layout — the open question +
+ + +
+
+ +
+ Screen +
+ + +
+
+ +
+ Command log +
+
+
+
+
+ + + +
+
01

The merger, and the fix

+ +
+
+
+ before +
+ +
+
‹Multi-Filament: AFC
+
+ + + + + + + + + + E0E1 + E2E3 + + + + + + + + + + + HUB + + + + + + + 1 nozzle + +
+
+
+

PathTopology::HUB — four heads swallowed

+
+ +
+ after +
+ +
+
‹Multi-Filament: Snapmaker U1
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + T0T1 + T2 + T3 + + 4 nozzles + +
+
+
+

PathTopology::PARALLEL — the machine as built

+
+
+

480 × 320, 1:1 — real state at capture: heads 0–2 empty + (wait_insert), head 3 loaded PETG Kingroon #83AFFF

+
+ +
+

Why it happened

+
    +
  • Your U1 runs a community AFC compatibility shim, + extended/klipper/afc.cfg, which declares the four toolheads as four + AFC_lanes so AFC-aware UIs can see them. HelixScreen therefore detects + AFC, not Snapmaker.
  • +
  • The AFC backend infers per-unit topology from three inputs: the unit's + extruders[], its hubs[], and each lane's hub + field. On the shim all three are blank — it declares no + [AFC_extruder] sections, no hubs, and publishes no per-lane + hub key.
  • +
  • With nothing to count, every arm of the chain at + ams_backend_afc.cpp:2903 fails and control reaches the trailing + else { topology = PathTopology::HUB; }.
  • +
  • HUB selects render_linear_hub() — lanes fan into a hub box + and out to one nozzle. The merger.
  • +
+ +
+ Fix +

The answer was in the payload the whole time: every lane names its own extruder + (E0→extruder, E1→extruder1, …), and the backend + already parsed it for other purposes. When a unit reports no extruders of its + own, derive them from its lanes — four distinct extruders means four + independent toolheads. Guarded three ways so a real Box Turtle is untouched: it fires + only when the unit's own list is empty, only when every lane's extruder is known (so a + partial Moonraker delta can't trigger it), and only when they are distinct.

+
+

+ include/ams_backend_afc.h · + src/printer/ams_backend_afc.cpp · + tests/unit/test_afc_shim_unit_topology.cpp (4 cases) +

+
+
+ + +
+
02

Three stacks, one winner

+ +
+

Your printer has three filament-management stacks installed at once. All three publish + Klipper objects. HelixScreen's detection picks exactly one — and which one it picks is + decided by object names, so changing what is installed silently changes which + backend you get.

+
+ +
+ + + + + + + + + + + + + + + + + + + +
StackObjectsWhat it knows
Native Snapmakerfilament_detect
filament_feed left/right
print_task_config
The truth. Per-head type/vendor/colour, channel_state, + RFID, and the 32→4 extruder_map_table.
AFC shim selectedAFC
AFC_unit U1
AFC_lane E0…E3
A hand-written adapter: 4 lanes → extruder…extruder3, + each with its own toolhead sensor, plus macros forwarding colour/material edits to + the native API. No extruder sections, no hubs, no steppers.
multiACEace
ace_bg_swap
ace_tipform
1× ACE Pro 2, mode: head, per-head + source/feeder/manual maps, dryer, Spoolman binding.
+
+ +
+

So should you delete the shim?

+

No — not yet. It is currently load-bearing, and removing it makes things worse:

+
+ +
+ + + + + + + + + + + + + + + + +
If you…HelixScreen picksResult
change nothingAFCworks Four heads, drawn correctly after the §01 fix.
delete afc.cfgACEbroken The bare name ace + matches the Anycubic backend, which needs a top-level + slots[]. multiACE publishes + aces[].slots[] instead, so it falls through to a REST + bridge multiACE doesn't serve, 404s, and gives up. Empty panel.
delete bothSNAPMAKERcorrect, but the native 4-head backend — and no + multiACE.
+
+ +
+
+

The ACE misdetection is a real latent bug for every multiACE user who doesn't + have the shim. Next step is to detect multiACE on shape, not name — + an ace object carrying aces[] and device_count is + multiACE; one carrying slots[] is Anycubic. Then the shim becomes optional + and you can delete it.

+
+
+
+ + +
+
03

The pixel budget

+ +
+

The U1's panel is 480 × 320 — HelixScreen's TINY tier and the + smallest resolution it supports. Before any of this work, the + 480×320 audit already lists the filament panel + as having cards pushed off-screen. Every layout decision downstream is a consequence of + these numbers.

+
+ +
+
+ Panel width + + 480 px +
+
+ − nav rail + + 42 px +
+
+ − panel padding + + 16 px +
+
+ Usable width + + 422 px +
+
+ Per head, 4 across + + 103 px +
+
+ Usable height + + 279 px +
+
+ +
+
+

What 103 px buys

+

About six characters of body text, or one 28 px spool ring with a two-line caption + under it. A four-deep source stack with readable labels does not fit — which is the + single constraint driving the design in §05.

+
+
+

So turn the grid

+

103 px is enough for one head with one source (§04). It is not enough for a head with + four. The fix is not to page like the stock UI does — it is to swap the + axis: one row per head gives each 406 px instead of 103, and four rows still + leave 60 px of vertical slack (§05). Same data, same panel, no truncation.

+
+
+
+ + +
+
04

Your rig, drawn honestly

+ +
+

Not a hypothetical three-ACE setup — what the printer actually reports: + mode: head, one ACE Pro 2, heads 0–2 on stock feeders, + head 3 fed from ACE 1 slot 1. Three of the four columns have a source stack of + exactly one, and the layout has to look deliberate when that is true, not like + a table with empty cells.

+
+ +
+ scale + + + +
+ +
+
+
+ +
+
‹Multi-Filament
+
+
+
ACE 132° · 33%
+
head mode
+
+
+
+ T0 +
+ + Empty + feeder +
+ +
insert
+
+
+ T1 +
+ + Empty + feeder +
+ +
insert
+
+
+ T2 +
+ + Empty + feeder +
+ +
insert
+
+
+ T3 +
+ + PETG + A1·1 +
+ +
+3 slots
+
+
+
+
+
+
+

480 × 320 · live state · three feeder heads, one ACE-fed head

+
+ +
+
+

Feeder heads get a one-line source row that reads as a statement + (“feeder”), not a truncated list. Only the ACE-fed head shows a count chip, + because only it has somewhere to go. The asymmetry is the information.

+
+
+
+ + +
+
05

When a head has more than one source

+ +
+

Add ACE units, or switch to multi mode, and a head gains a + stack of candidates — up to four ACE slots plus the stock feeder plus a hand-fed bypass. + Four columns of 103 px cannot show that. Turn the grid ninety degrees: + one row per head gives each one the full 406 px of usable width, which is enough for + the live source, its state, and a count chip — no truncation, no tap required to see + what is loaded.

+
+ +
+ scale + + + +
+ +
+
+
+ +
+
+ ‹ + Multi-Filament + 3 ACE · multi +
+
+ +
+ T0 + + PLA BasicGreen · feeder + +2 + › +
+ +
+ T1 + + PLA Magentafeeder + +1 + › +
+ +
+ T2 + + PLA Cyan→ A3·3 Silk Violet + 1:47 + › +
+ +
+ T3 + + PETGKingroon · ACE 1·1 + +3 + › +
+ +
+
HOME
+
TIP
+
RETRACT
+
FEED
+
PURGE
+
+ +
+
+
+
+

480 × 320 · row view · a swap in flight on T2

+
+ +
+
+

Two layouts, one panel: columns (§04) keep the spatial mapping to + the four physical heads and are right when each head has a single source — your rig + today. Rows earn their keep the moment a head has somewhere else to go. + Both fit 480 × 320; the backend picks, or it becomes a setting.

+
+
+
+ + +
+
06

Choosing a source

+ +
+

One head, every spool that can physically reach it, and what each choice costs. A swap + is up to three minutes; the sheet says so before you commit. The chosen row + emits one command.

+
+ +
+
+
+ +
+
‹Multi-Filament
+
+
+
+
+

Source for T3

currently ACE 1 · slot 1
+
+
+ + ACE 1 · 1 + PETG · Kingroon Sky Blue + loaded +
+
+ + ACE 1 · 2 + Slot empty + — +
+
+ + ACE 1 · 3 + Slot empty + — +
+
+ + feeder 3 + Stock feeder · hand-load + switch mode +
+
+
+ Cancel + Keep ACE 1 · 1 +
+
+
+
+
+

480 × 320 · source sheet → + ACE_SWAP_HEAD HEAD=3 ACE=0 SLOT=n

+
+
+ + +
+
07

Before the print: the swap bill

+ +
+

A job can use more colours than there are heads, so the number that decides whether you + press Start is how many swaps and how long they add. Underneath, two native U1 + commands: SET_PRINT_EXTRUDER_MAP routes logical tools onto physical heads, + then SET_PRINT_USED_EXTRUDERS stops the slicer's baked prestart block + auto-feeding heads the job never touches — which also fixes a standing false-runout on a + bare U1.

+
+ +
+
+
+ +
+
+ ‹ + Filament plan + lamp-shade_6c +
+
+ + + +
+
T0 + H0 feeder PLA Green + seated
+
T1 + H1 feeder PLA Magenta + seated
+
T2 + H2 feeder PLA Cyan + seated
+
T3 + H3 ACE 1·1 PETG + seated
+
T4 + H3 ACE 1·2 Copper + L84 + 2:40
+
T5 + H3 ACE 1·3 Silver + L140 + 2:40
+
+ +
+ Edit mapping + Start print +
+ +
+
+
+
+

480 × 320 · swaps land only on ACE-fed heads — a feeder head + cannot change filament mid-print

+
+
+ + +
+
08

Driving it

+ +
+

Against your real printer, with an isolated socket and config dir so it cannot collide + with another instance:

+
+ +
export HELIX_SOCK=/tmp/helix-u1.sock HELIX_CONFIG_DIR=/tmp/helix-config-u1
+mkdir -p "$HELIX_CONFIG_DIR"
+./build/bin/helix-screen --moonraker 192.168.2.242:7125 -vv --remote-socket "$HELIX_SOCK" &
+./build/bin/helix-screen ctl -s "$HELIX_SOCK" navigate ams
+./build/bin/helix-screen ctl -s "$HELIX_SOCK" screenshot /tmp/u1-ams.png
+
+# or with no printer at all — mock modes
+HELIX_MOCK_AMS=u1 ./build/bin/helix-screen --test -vv          # exists today
+HELIX_MOCK_AMS=multiace HELIX_MOCK_ACE_COUNT=1 \
+  HELIX_MOCK_ACE_MODE=head HELIX_MOCK_ACE_FEEDER=0,1,2         # your rig (to build)
+ +
+
+

No rebuild for layout

+

Everything visual lives in ui_xml/*.xml, loaded at runtime. Set + HELIX_HOT_RELOAD=1, save the file, and the running panel rebuilds within + about half a second. Change spacing, wording, colour without a compiler.

+
+
+

Still open

+
    +
  • Do project colours map to heads or to (ACE, slot)? Orca has the + same question open; both surfaces should answer it the same way.
  • +
  • Four columns at 480×320, or page them like the stock UI does? Decide after + seeing it on the real panel.
  • +
  • Keep the AFC shim long-term, or retire it once multiACE is detected natively?
  • +
+
+
+
+ + +
+
09

The "Currently Loaded" card

+ +
+

The card has three facts available to it and currently renders only one of them. + Mounted (which head is on the carriage) comes from the extruder pins. + Present (filament is in this head) comes from two independent sensors + that agree. Fed (it reached the nozzle) comes from the + channel_state latch — the only one of the three that can be wrong, because + it is derived from a transition HelixScreen has to witness.

+

Below: what the card should say in each real state, and the signals behind it.

+
+ +
+
+ +
+ nothing mounted +
+ Currently Loaded +
No tool mounted
+ Idle +
LoadUnload
+
+

mounted — · present — · fed —

+
+ +
+ mounted, empty +
+ Currently Loaded +
+ + EmptyT2 mounted +
+ Idle +
LoadUnload
+
+

mounted T2 · present no · fed no

+
+ +
+ today's T3 +
+ Currently Loaded +
+ + PETGKingroon · T3 +
+ In toolhead · heat to extrude +
LoadUnload
+
+

mounted T3 · present yes · fed latch lost

+
+ +
+ loaded & hot +
+ Currently Loaded +
+ + PETGKingroon · T3 +
+ Ready · 245 °C +
LoadUnload
+
+

mounted T3 · present yes · fed yes

+
+ +
+ loading +
+ Currently Loaded +
+ + PLA BasicGreen · T0 +
+ Feeding… 2 of 5 +
LoadUnload
+
+

mounted T0 · present yes · fed in progress

+
+ +
+

the sidebar card at panel scale · buttons shown enabled/disabled as they should gate

+
+ +
+
+

The load-bearing change is the third case. Present — not + fed — should decide whether the card names a filament and whether + Unload is offered, because presence is what two sensors actually measure. The latch then + only refines the state line: "In toolhead · heat to extrude" versus "Ready". + That way a latch HelixScreen never witnessed downgrades the wording, not your ability to + get the filament out.

+
+
+
+ +
+

HelixScreen · docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md · + §01 is implemented and tested; §04 onward are proposals.

+
+ +
+ + diff --git a/docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md b/docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md new file mode 100644 index 0000000000..395827cfa5 --- /dev/null +++ b/docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md @@ -0,0 +1,899 @@ +# Snapmaker U1 + multiACE — support plan + +> ## ▶ START HERE (new session, 2026-08-11) +> +> **Branch:** `feat/snapmaker-multiace`, **31 commits**. It IS pushed — +> `origin/feat/snapmaker-multiace` is at `5053dd26b`, with the last 6 local only. The plan's +> older "nothing pushed" note is obsolete. +> +> **State: Phases 1-2 are done and hardware-verified, and the ACE dryer works.** The U1 draws +> four independent toolheads; multiACE is detected as its own `AmsType`; the ACE appears as +> unit 1 ("ACE 2 Pro") with live temp/RH; ACE-fed heads load and unload through +> `ACE_LOAD_HEAD`/`ACE_UNLOAD_HEAD`; drying runs from the stock environment panel. +> +> **Read §2's ⚠️ banner and §3 before trusting any "implemented" claim in this doc.** Three +> were wrong and are corrected in place: §2's AFC topology fix was never built, §3's +> shape-based discriminator is impossible, and the fixture it cited was never committed (a +> real one now lives at `tests/fixtures/snapmaker_u1/u1-multiace-head-mode-idle.json`). +> +> ### Open, in the order I would take them +> +> 1. **Auto-dry toggle — ✅ unblocked and built (2026-08-11).** The parameter names are no +> longer unknown; see § "Auto-dry" below for the full surface. No hardware probe was +> needed and none should be run. +> 2. **§10.2 stuck unload — fixed but never re-observed.** The dispatch is unit-tested against +> the captured payload; nobody has run an actual unload on T3 since. Confirm before closing. +> 3. **Stale spool duplicates in Moonraker's DB.** `5122216e5` stops an empty MMU bay claiming +> a tool's spool, but assignments are cached in Moonraker's database as well as +> `tool_spools.json` — deleting the local file restores the old values. Existing duplicates +> (tool 0+1 both spool 3, tool 2+3 both spool 6) outlive the fix and need clearing. +> 4. **Phase 3's richer half.** The duplicate nozzle is gone and each unit only draws lines to +> heads it feeds, but the mockup's per-head *source stack* in the seat ("PETG · ACE bay 1") +> and the rows-vs-columns switch of §6 are not built. +> Mockup: `2026-08-10-multiace-ui-improvements-mockup.html`. +> 5. **Phase 1's AFC generalisation** (§2 banner) — never built, still open. Your U1 is fine +> either way; another AFC-shim-over-toolchanger machine still draws one merger. +> +> **Decided against:** replacing the dryer panel's preset dropdown with pills. That panel +> (`ams_environment_overlay`) is **upstream stock**, added by Preston in `211596fba` +> (2026-03-25), and is shared with the QIDI Box and CFS dryers; its presets are material-based +> ("PLA 45 °C/4h"), which carries more than bare temperatures. Per Gordian: keep it as stock as +> possible. This branch's only change there is a 10-line per-unit humidity fix. +> Mockup (with pills, NOT built): `2026-08-10-multiace-dryer-mockup.html`. +> +> ### Traps this session cost hours to find — read before debugging anything +> +> - **Moonraker sends DELTAS: absent ≠ cleared.** Treating a missing `head_source` as "no +> longer seated" wiped the ACE→head binding a second after it arrived, so bays lost their +> tool badges and the unit detail fell back to hub-only. Every parse must leave untouched +> what the frame does not mention. Invisible to any test that feeds one full frame. +> - **`` loses to a later imperative `lv_obj_clear_flag()`.** Three buttons in +> the slot menu are shown that way and needed a C++ gate as well as the binding. +> - **`ACED__DRY_START_n` is NOT the dryer API.** Those are multiACE's parameterless Fluidd +> macro buttons, and reading only the README's macro table produced a wrong conclusion that +> temp/duration need a config edit plus a Klipper restart. The real commands take parameters: +> `ACE_DRY ACE=n [TEMP=] [DURATION=]`, `ACE_STOP_DRYING [ACE=n]`. Check `gcode/help` on the +> machine, and OrcaSlicer's `resources/web/multiace/index.html`, before believing a doc. +> - **`ctl click` cannot reach the filament canvas's hit regions** — it sends a widget event +> with no coordinates. Use `ctl press ` / `ctl release`; nozzles sit at +> `canvas.y + canvas.h * 0.55`. +> - **Run `"[ams]"`, never `"[snapmaker]"`.** Half the relevant tests carry neither tag pair. +> - **A slot's colour is drawn by FILL.** `display_fill_level()` is 0 when not present, and +> every spool visual sizes its coloured ring by fill — so an assigned-but-empty lane renders +> as bare grey chrome unless the fill is floored (`SPOOL_ASSIGNED_MIN_FILL_PCT`). +> +> **Test state:** `"[ams]"` = 1681/1681. Full suite = 2780 cases with **1 pre-existing +> failure**: `test_clock_widget.cpp:157` segfaults in the unfiltered run, passes under +> `"[clock_widget]"` alone, and **reproduces with this branch stashed** — inherited from +> `main`, do not chase it here. `scripts/quality-checks.sh` also fails pre-existing on +> "Missing icon codepoints" (needs `./scripts/regen_mdi_fonts.sh`); every gate this branch can +> affect is clean, with the imperative-UI ratchet held at its 384 baseline. +> +> **Formatting — the old note here was wrong twice.** CI pins **clang-format 18.1.8**, not 14 +> (`requirements.txt`), and the CI check is **non-blocking** — `scripts/quality-checks.sh` has +> `EXIT_CODE=1` commented out under "Don't fail CI for formatting". So drift will not reject a +> PR; it just gets reflowed by the next machine whose pre-commit hook has the binary. +> +> The binary is now available (2026-08-11): `python3 -m venv .venv && .venv/bin/pip install -r +> requirements.txt`, which needed `sudo apt install python3.14-venv` first. `.venv` is +> gitignored. **The branch has real drift in 18 of its touched files** — 31 commits were made +> with no formatter present. Fix it as its OWN commit and add that SHA to +> `.git-blame-ignore-revs`, the way `54650149f` (the tree-wide 18.1.8 reflow, an ancestor of +> this branch) is recorded. The baseline itself is clean; `include/filament_database.h` is the +> one exception and is long string literals clang-format cannot break. +> +> **Driving the real printer from a dev box** (no deploy, no SSH — this is the whole loop): +> ```bash +> export HELIX_SOCK=/tmp/helix-u1.sock HELIX_CONFIG_DIR=/tmp/helix-cfg-u1 +> mkdir -p "$HELIX_CONFIG_DIR" && cp /tmp/helix-seed/settings.json "$HELIX_CONFIG_DIR/" +> ./build/bin/helix-screen --moonraker 192.168.2.242:7125 -s tiny -vv \ +> --log-dest file --log-file /tmp/helix-app.log --remote-socket "$HELIX_SOCK" & +> ./build/bin/helix-screen ctl -s "$HELIX_SOCK" click tour_skip_btn +> ./build/bin/helix-screen ctl -s "$HELIX_SOCK" navigate filament +> ./build/bin/helix-screen ctl -s "$HELIX_SOCK" click ams_bars_container # -> ams_panel +> ./build/bin/helix-screen ctl -s "$HELIX_SOCK" screenshot /tmp/x.png +> ``` +> `-s tiny` **is** the U1's 480x320. `--log-dest file` is mandatory — the default routes to +> journal and stdout looks silent. `navigate ams` does not exist. **It is read-write against +> the live machine**: navigate and screenshot freely, but any Load/Unload really moves +> filament. +> +> **Seed config caveat:** `/tmp/helix-seed/settings.json` is a completed-wizard config and +> lives in `/tmp` — it will not survive a reboot. Without it every run hits the first-run +> wizard plus an 8-step tour. If it is gone, run once without it, click through the wizard by +> hand, then copy the resulting `settings.json` back to `/tmp/helix-seed/`. + + +> **v2, 2026-08-09 — verified against live hardware** (U1 at `192.168.2.242`, Klipper +> `1.5.2.13_20260722102206`, multiACE `0.99.6.1b`). v1 of this plan was written from source +> reading alone and guessed the wrong root cause for the merged-toolhead drawing; §2 is the +> real one, read off the machine. Mockups: `2026-08-09-multiace-mockup.html`. +> ~~Captured payload: `tests/fixtures/snapmaker_u1/u1-afc-shim-and-multiace-idle.json`.~~ +> **That file does not exist** — never committed, and not on disk anywhere (checked +> 2026-08-10). The live values quoted in §1 are the only surviving record of the capture. +> Re-capture from the machine if a fixture is needed: +> `curl -s '192.168.2.242:7125/printer/objects/query?ace&print_task_config&filament_feed%20left&filament_feed%20right'`. + +--- + +## 0. TL;DR + +1. **The merger is a topology-inference fallthrough, and it is fixed.** Your U1 runs a + community *AFC compatibility shim* (`extended/klipper/afc.cfg`) that declares its four + toolheads as four `AFC_lane`s. HelixScreen detects AFC, tries to infer whether the unit is + a hub or four independent heads, finds nothing to count, and falls through to `HUB`. Four + independent toolheads get drawn merged into one merger box. Fix in §2. +2. **Do not delete the shim.** It is the only reason your heads appear at all, and it bridges + colour/material edits to the U1's native API. Removing it lands you on a *worse* path (§3). +3. **Three filament stacks coexist on your machine** — native Snapmaker, the AFC shim, and + multiACE — and HelixScreen can only pick one. That is the real architectural problem, and + it is what the phase plan is about. +4. **Your rig is head-mode with one ACE**, not multi-mode with several. That changes what the + default view should optimise for (§4). + +--- + +## 1. What is actually running on your printer + +Three filament-management stacks are installed simultaneously. All three publish Klipper +objects; HelixScreen's detection picks exactly one. + +| Stack | Objects | What it really knows | +|---|---|---| +| **Native Snapmaker** | `filament_detect`, `filament_feed left`/`right`, `print_task_config`, `extruder`…`extruder3` | The truth. Per-head filament type/vendor/colour, `channel_state`, RFID, the 32→4 `extruder_map_table`. | +| **AFC shim** (`extended/klipper/afc.cfg`) | `AFC`, `AFC_unit U1`, `AFC_lane E0…E3` | A hand-written adapter: 4 lanes → `extruder`/`extruder1`/`extruder2`/`extruder3`, each with its own `toolhead_sensor`, plus `SET_COLOR`/`SET_MATERIAL`/`SET_VENDOR` macros that forward to the native `SET_PRINT_FILAMENT_CONFIG`. No `[AFC_extruder]` sections, no hubs, no steppers. | +| **multiACE** | `ace`, `ace_bg_swap`, `ace_tipform` | 1 ACE Pro 2 (`protocol: v2`), `mode: head`, per-head source/feeder/manual maps, Spoolman binding. | + +**Live state when captured:** + +``` +ace.mode = "head" ace.device_count = 1 +ace.head_feeder = {0:true, 1:true, 2:true, 3:false} ← heads 0-2 on stock feeders +ace.head_ace = {0:0, 1:1, 2:2, 3:0} ace.ace_heads = [3] ← only head 3 is ACE-fed +ace.head_source = {0:null, 1:null, 2:null, 3:{ace_index:0, slot:0}} +ace.aces[0] = connected, 32 °C, 33 % RH, gate_status [1,0,0,0] + +print_task_config.filament_type = ["NONE","NONE","NONE","PETG"] +print_task_config.filament_vendor= ["NONE","NONE","NONE","Kingroon"] +filament_feed = e0/e1/e2 "wait_insert", e3 "load_finish" +``` + +So: four independent heads, three on direct stock feeders, one fed from a single ACE Pro 2 — +and at capture time only head 3 was loaded (PETG Kingroon `#83AFFF`). + +--- + +## 2. Why the four toolheads were drawn as one merger — root cause + +`AmsBackendAfc::parse_afc_unit_object()` infers per-unit topology. The documented rule is +"1 extruder → HUB, N extruders (N == lane count) → PARALLEL". Three inputs feed it, and on +this machine **all three are blank**: + +| Input | Real AFC hardware | Your shim | +|---|---|---| +| `AFC_unit U1.extruders[]` | one entry per `[AFC_extruder]` section | `[]` — the shim declares none | +| `AFC_unit U1.hubs[]` | one entry per `[AFC_hub]` | `[]` | +| `AFC_lane E*.hub` | `"direct"` or a hub name | **absent** — the key is not published | + +With no lane carrying a `hub` field, `lane_hub_routing_` has no entry for any lane, so both +`has_direct` and `has_hub_routed` stay false. Every arm of the chain in +`ams_backend_afc.cpp:2903-2921` then fails in turn, and control reaches: + +```cpp +} else { + unit_info.topology = PathTopology::HUB; // default +} +``` + +`PathTopology::HUB` makes `render_linear_hub()` draw entry lanes fanning into a hub box and +out to **one** nozzle. That is the merger you saw. Nothing about it was Snapmaker-specific — +any AFC shim over a real toolchanger hits it. + +**The information was there the whole time.** Each lane publishes its own extruder: + +``` +AFC_lane E0.extruder = "extruder" map = "T0" +AFC_lane E1.extruder = "extruder1" map = "T1" +AFC_lane E2.extruder = "extruder2" map = "T2" +AFC_lane E3.extruder = "extruder3" map = "T3" +``` + +and the backend already parses it (`slot.extruder_name`, `ams_backend_afc.cpp:2513`) — it +just never fed topology inference. + +### The second half: the canvas never saw the per-unit answer + +Deriving the unit's extruders was necessary but **not sufficient**. Verified on hardware: +the log said `→ Parallel (Tool Changer)` while the panel still drew the merger. + +`AmsState` publishes `path_topology_` — the subject the canvas observes — from the +**system-wide** `backend->get_topology()` (`ams_state.cpp:1385`), not from +`get_unit_topology()`. And `AmsBackendAfc::get_topology()` was a hardcoded +`return PathTopology::HUB;`. So the per-unit inference could be perfectly correct and +never reach the drawing. + +That constant also fed `AmsBackend::slot_has_independent_path()`, which decides +load-vs-swap: every lane looked shared, forcing an unload-before-load that a machine with +four independent toolheads never needs. So this was a behavioural bug, not only a visual one. + +The intended answer — also **not built**, see the banner below — was for `get_topology()` to +derive from the parsed units: their common value, `MIXED` when they disagree, `HUB` before any +unit has been seen (unchanged for a Box Turtle). Because `get_unit_topology()` calls it *while +holding* `mutex_`, the logic would live in a `topology_locked()` helper with `get_topology()` +as the locking wrapper; calling the public form from inside the lock would self-deadlock. + +### ⚠️ The fix below was NEVER IMPLEMENTED (verified 2026-08-10) + +Everything in this subsection describes an approach that was designed and then **not built**. +Verified against the tree, not inferred: `AmsBackendAfc::get_topology()` still returns a +hardcoded `PathTopology::HUB` (`ams_backend_afc.cpp:555-558`), `lane_extruder_` and +`topology_locked()` do not exist, `tests/unit/test_afc_shim_unit_topology.cpp` does not +exist, and `git diff main...HEAD` touches no `*afc*` file at all. + +**What actually fixed the merger** was `a1a6c33da` (§3): detection stopped claiming the bare +`ace` object, so the U1 falls through to the **native Snapmaker backend**, which draws its +four heads correctly — AFC is never selected on this machine, so its HUB constant no longer +matters *here*. The render half was `01f77654c`'s `compute_slot_render_states()` change in +`ui_filament_path_topology.cpp`. + +**So this is still open, and Phase 1's stated generalisation is not delivered:** any OTHER +AFC-shim-over-toolchanger machine — one without the U1's `filament_detect` signature to fall +through on — still hits the `HUB` fallthrough and still draws four heads as one merger. The +analysis above is sound and worth building; treat it as a design, not a record. + +The unbuilt design was: `parse_afc_unit_object()` falls back to the unit's own lanes when the +unit reports no extruders — if every lane's extruder is known and they are **distinct**, that +count becomes the unit's extruder set, and the existing `extruders.size() > 1 → PARALLEL` arm +answers correctly. Four distinct extruders → four independent toolheads. + +Deliberately conservative in three ways, because guessing PARALLEL on a genuine hub unit +de-merges a merger that physically exists: + +- fires **only** when `unit.extruders` is empty — a real AFC install is untouched; +- requires **every** lane's extruder to be known, so a partial Moonraker delta cannot + trigger it (the same hazard the hub-routing parse already guards against, #1229 defect 4); +- requires the extruders to be **distinct** — four lanes into one extruder stays a hub. + +The files it *would* touch (none of these changes exist — this is the build list, not a +changelog): + +| File | Change | +|---|---| +| `include/ams_backend_afc.h` | new `lane_extruder_` map (mirrors `lane_hub_routing_`); `topology_locked()` decl | +| `src/printer/ams_backend_afc.cpp` | populate `lane_extruder_` in the lane parse; derive-from-lanes fallback in `parse_afc_unit_object()`; `get_topology()` derives from units instead of returning a constant | +| `tests/unit/test_afc_shim_unit_topology.cpp` | 6 cases: U1 shim frame → PARALLEL (per-unit **and** system-wide); partial delta → unchanged; same-extruder lanes → HUB (both); unit-declared extruders → unchanged | + +**What WAS verified on the live printer** (2026-08-09, `192.168.2.242`, run locally at +`-s tiny`): the panel draws four independent toolheads with T3 highlighted holding its PETG, +instead of four lanes fanning into a hub box and one nozzle. That is the native Snapmaker +backend doing it after the §3 fall-through — not AFC, and not the design above. + +--- + +## 3. Detection precedence — why deleting the shim makes it worse + +Three stacks, one winner. `printer_discovery.h:543` runs `if (has_mmu_) … else if +(has_snapmaker_) …`, and `has_mmu_` is set by the first MMU-ish object name seen. + +| Scenario | HelixScreen picks | Result | +|---|---|---| +| Today (AFC + ace + native) | **AFC** | Four heads visible. Merged before the §2 fix; correct after it. | +| Delete `afc.cfg` | **ACE** — `printer_discovery.h:311` matches the bare name `ace` | **Broken.** `AmsBackendAce` requires a top-level `slots[]` array (`ams_backend_ace.cpp:972`). multiACE publishes `aces[].slots[]` instead — verified live, there is no top-level `slots` key. It falls through to a `/server/ace/*` REST bridge multiACE does not serve, 404s, and gives up. Empty panel. | +| Delete `afc.cfg` **and** multiACE | **Snapmaker** | The native 4-head backend, which is correct — but you have thrown away multiACE. | + +So the shim is currently load-bearing, and the ACE misdetection is a real latent bug for +every multiACE user who does *not* have the shim installed. Both need fixing in HelixScreen; +neither is fixed by changing your printer. + +**Fix for the ACE collision — ✅ shipped in `a1a6c33da`, but NOT by the mechanism proposed +here.** Disambiguating on shape is impossible at this point: detection runs off +`objects.list`, which carries **names only**, so no payload exists to inspect. Do not try to +rebuild it this way. + +What shipped is name-based, in `finalize_ams_detection()` once the whole object list is +visible (`printer_discovery.h:556-580`). Either signal is sufficient: + +``` +ace + (ace_bg_swap | ace_tipform) → multiACE (the extras multiACE always ships) +ace + filament_detect → multiACE (U1 firmware signature; no Anycubic has it) +ace, neither marker → AmsType::ACE (Anycubic, unchanged) +``` + +On a multiACE match it **leaves the object unclaimed** and falls through to the native +Snapmaker backend. `AmsType::MULTIACE = 9` exists in `ams_types.h` with its string, but +**nothing ever assigns it** — deliberately, "until `AmsBackendMultiAce` exists". Regression +tests both directions: `tests/unit/test_multiace_vs_anycubic_detection.cpp` (121 lines). + +So Phase 2 step 1 is half done: the misdetection is fixed and tested; the affirmative +`MULTIACE` claim is the line to flip when the backend lands. + +--- + +## 4. Phase plan + +### Phase 1 — draw the U1 as four toolheads ✅ done for the U1 (§2, §3) + +The immediate ask, delivered — but by routing the U1 to its native backend, **not** by the +§2 AFC fix, which was never built. So the stated generalisation to "any AFC-shim-over- +toolchanger machine" is **not** delivered: a shim machine without the U1 signature still +draws one merger. Needs no printer-side change either way. + +### Phase 2 — multiACE detection and backend + +1. **Detect it** (§3) — ✅ **done 2026-08-10.** `AmsType::MULTIACE` is now claimed outright. + **Claiming the type is only half the wiring**, and the missing half is invisible to a + type-only assertion: `has_mmu_` skips the `has_snapmaker_` fallback that populated + `detected_ams_systems_`, and `AmsState` builds backends by iterating *that list*. With no + MULTIACE arm the list came back empty, no backend was built, and the panel logged + `navigate_to_ams_panel called with no backend` on a U1 that had worked seconds earlier. + Caught on live hardware, now pinned by a test. Four more sites needed the type too: + `is_tool_changer()`, `is_filament_system()`, the `AmsBackend::create` factory, and the + discovery sequence's subscription block (which is where `ace` gets subscribed at all). +2. ✅ **done 2026-08-10.** `AmsBackendMultiAce` derives from `AmsBackendSnapmaker` as planned; + the §7 risk was real but small — `NUM_TOOLS` and `validate_slot_index()` had to move from + `private` to `protected`, nothing more. Three payload facts that no doc states and that + cost a live debugging round each: + - `head_ace` carries an index for **every** head (`{0:0,1:1,2:2,3:0}` while only head 3 is + ACE-fed), so it cannot decide *whether* a head is ACE-fed. `head_feeder`/`head_manual` + are the authority. + - The per-head maps are keyed by **string** (`"0"`..`"3"`). An int-keyed lookup finds + nothing and every head silently reads feeder-fed. + - `slots[].color` is an `[r,g,b]` **array**, not a hex string. + + And one that cost the most: `handle_status_update` must unwrap `params[0]` exactly as the + base does. Reaching for a `"status"` key instead compiles, passes every unit test that + feeds the bare object, logs nothing, and leaves the backend behaving exactly like the plain + Snapmaker one. There is now a test using the real `notify_status_update` wrapper. + + *Original plan text follows.* The U1's four heads stay + unit 0 with all the hard-won native behaviour intact (the `channel_state` load latch, + `is_stuck_motion_sensor_runout`, `prepare_for_resume`, the 5-step load model, + `print_task_config` parsing). The subclass adds the `ace` subscription, units 1..N for the + ACE hardware, `head_source[h]` → which unit+slot is seated at head *h*, and + `ACE_SWAP_HEAD` dispatch for ACE-fed heads with fall-through to the inherited `FEED_AUTO` + path for feeder heads. Not a fork — CLAUDE.md's "extend the near-fit helper" applies hard + here, because the U1 half is subtle. +3. **Retire the shim's role.** Once the native backend covers everything, the AFC shim + becomes redundant and you can delete it — but only then, and detection must prefer + MULTIACE over AFC so the order stops mattering. + +**Everything needed is on the WebSocket.** `MultiAce.get_status()` publishes `mode`, +`device_count`, `active_device`, `head_ace`, `head_feeder`, `head_manual`, `head_source`, +`swap_phase`, and full per-unit inventory. No dependency on multiACE's optional FastAPI +service, no second HTTP client, no auth story. + +Control surface, all plain gcode: `ACE_SWAP_HEAD HEAD=h ACE=a [SLOT=s]`, `ACE_LOAD_HEAD` / +`ACE_UNLOAD_HEAD`, `ACE_SWITCH TARGET=n`, `ACE_SET_HEAD_ACE|FEEDER|MANUAL`, `ACE_BG_SWAP`, +`ACED__Dry_Start_0..3` / `ACED__Dry_Stop`. + +### Auto-dry — `ACE_SET_AUTO_DRY`, the full surface + +Read off **multiACE's own web UI on the machine**, not guessed and not probed: +`http://192.168.2.242/multiace/app.js` (`setAutoDry`, `_AUTO_DRY_RANGE`, `autoDryPairInvalid`) +plus the template in `.../multiace/`. That UI is the FastAPI service nginx mounts at +`/multiace/`; the local `multiACE/` checkout is **older than the installed firmware** and has +no auto-dry at all, so do not read it for this. + +``` +ACE_SET_AUTO_DRY ACE= [ENABLE=0|1] [TEMP=35..80] [RH_START=5..95] + [RH_END=1..94] [MASTER=] [ADD_TIME=0..600] +``` + +- **Every field is independent.** The web UI sends one per edit. Arming must therefore send + `ENABLE` *alone* — restating a threshold would silently overwrite one set elsewhere, and the + setting persists. +- **`RH_END` must be strictly below `RH_START`.** The firmware rejects the pair otherwise + (`autoDryPairInvalid` is `end >= start`), and the UI refuses to send it. +- **`TEMP` is not auto-dry-only** — it is the unit's one drying temperature, shared with the + manual `ACE_DRY`. multiACE's own UI puts it in the manual row for that reason. +- **v2 vs v1 is the real split.** Only the ACE 2 Pro (`protocol: v2`) has a humidity sensor, so + only it evaluates thresholds. A v1 unit FOLLOWS a v2 unit's cycle instead: `MASTER` + + `ADD_TIME`, and it cannot be armed until a master is picked. `ace.auto_dry_masters` (live + `[0]`) lists the units eligible to be one. + +Live shape, confirmed against the machine and present in the committed fixture: +`ace.aces[i].auto_dry = {enabled, rh_start, rh_end, temp, master, add_time}`, plus the +**sibling** `ace.aces[i].auto_dry_running` — a separate key, not a field inside the block, so +it needs its own parse or a frame carrying only it is dropped. + +Built 2026-08-11 as `AutoDryInfo` + `get_auto_dry_info()` / `set_auto_dry_enabled()` on +`AmsBackend`, overridden in `AmsBackendMultiAce`, with a `compact_toggle_row` in the stock +`ams_environment_overlay` gated on its own visibility subject. Deliberately a **toggle only** — +thresholds are displayed, not editable, keeping that upstream-stock panel close to stock. + +**Closed, not open: how auto-dry and Start/Stop interact.** Per Gordian (2026-08-11), the +current behaviour is fine and is not to be changed. For the record, so nobody "fixes" it +again: while the rule is armed and humidity is above `rh_start` it will restart a cycle that +Stop has just ended; the panel's Temp box is a local value while the rule uses its own +persisted `auto_dry.temp`; and the countdown is drawn from `duration`/`remain_time`, which do +not govern a humidity-ended cycle (`auto_dry_running` is what distinguishes one). All known, +all accepted. + +### Binding a spool used to pin a material forever (2026-08-12) + +The SnapSwap panel showed materials the printer disagreed with — a head reporting PLA read +PETG, an empty head read PLA — and it survived every restart. Four links, each of which had to +be broken: + +1. Binding a Spoolman spool routes through `apply_spool_to_slot()`, which writes the **spool's** + material into `SlotInfo`. +2. `set_slot_info(persist=true)` then stamped `user_locked_material = !material.empty()`, so + **linking a spool manufactured a material lock** the user never asked for. +3. That override persists to **Moonraker's `lane_data` namespace on the printer**, not just to + the local config dir. +4. `apply_overrides()` applied material unconditionally, never consulting the lock, so it + replayed over `print_task_config` on every parse. + +**A clean local config dir does NOT clear this** — link 3 is why. A "clean config" run that +still shows the wrong material is not evidence the override store is innocent; check +`curl ':7125/server/database/item?namespace=lane_data'` before concluding anything. +That mistake cost several rounds here. + +Fixed by making `apply_overrides()` lock-aware (firmware's material wins unless the user +genuinely locked one — ACE bays are unaffected because firmware states nothing for them), and +by comparing against `last_firmware_material_` when stamping the lock, so a bind that agrees +with firmware locks nothing. Mirrors AD5X's `last_firmware_color_` guard (#965). + +**How it was found:** four temporary `spdlog::info` probes — one at each writer of +`slot->material` (ptc / RFID / override) and one where `AmsState` publishes the subject, each +logging *old → new*. One run named the overwriter outright. Worth repeating for any +"where does this value come from" question; inference had been wrong twice by then. + +### A bay's spool identity is in the TABLE, not in `slots[]` (2026-08-11) + +`aces[].slots[]` carries `material`, `brand`, `color` — and on real hardware they are all +**empty**, because those fields are filled from RFID only. Everything a user types into +multiACE's web UI lands in a spool table instead: + +``` +ace.spool_binding = {"0_0": "15", "0_1": "10", "0_3": "16"} # "_" -> spool id +ace.spools = { "15": {material, vendor, color, label, weight_g, sku, spoolman_id}, ... } +``` + +Reading only `slots[]` meant the ACE panel showed none of it, falling back to HelixScreen's +own override store — so the panel disagreed with the machine entirely. Four traps, each +pinned by a test in `[spools]`: + +- **`spoolman_id` is a STRING here**, an int everywhere else in this codebase. +- **`color` is bare hex with no `#`**, while `slots[].color` is an `[r,g,b]` array. +- **Unbinding DELETES the key** rather than nulling it, so `spool_binding` must be replaced + wholesale — merging strands a removed spool forever. +- **The table and the bindings are INDEPENDENT deltas.** Moving a spool between bays sends + `spool_binding` with no `spools`, so they must be cached separately; resolving them in one + pass threw every description away on the next binding change. + +multiACE's table deliberately **outranks** the local override store for a bay it has a binding +for — the same doctrine `slot_identity_owner_unit()` states — or a stale local edit keeps +masking what the user typed. And `SlotInfo` objects are reused across rebuilds (only a change +in unit COUNT reallocates them), so `spool_name`/`brand`/`spoolman_id`/`remaining_weight_g` +must be cleared each pass or a bay keeps the name of the spool taken out of it. + +**There are THREE identity layers, and only two are on the WebSocket.** In precedence order, +lowest first: + +| Layer | Where | Covers | +|---|---|---| +| `aces[].slots[]` | `ace` object | RFID only — **empty** for every hand-entered spool | +| `spools` + `spool_binding` | `ace` object | bays with a spool bound; carries `spoolman_id` | +| `slot_overrides.json` | **a FILE** | material/brand/subtype/colour, incl. bays with NO spool bound | + +multiACE's own web UI resolves from the top one — every bay it reports comes back +`source: "override"`. The file is **not published in the `ace` object at all**, which is why a +bay carrying a material and colour but no spool binding read as empty here. + +It is reachable through Moonraker's file API at +`config/extended/multiace/slot_overrides.json`, so this needs nothing beyond multiACE — the +optional FastAPI service stays unnecessary, as § 4 intended. Nothing in the `ace` object can +say the file changed, so the fetch is triggered off `event_seq` (multiACE's +bump-on-any-state-change counter) with the first frame fetching it at all. + +**Colour is encoded three different ways across these layers** — `"#RRGGBB"` in the override +file, bare `"RRGGBB"` in the spool table, `[r,g,b]` in `slots[]`. All three are parsed. + +**Weights come from SPOOLMAN, not multiACE.** Its `weight_g` is a local copy, so taking +remaining from it while total came from Spoolman computed a percentage across two sources. +Only `spoolman_id` is taken from the table; `tracks_weight_locally()` stays false so +SpoolmanManager fills remaining AND total as one pair. Both are cleared each rebuild — +clearing only remaining left a stale total behind a wrong percentage. + +**Still open — the SnapSwap side.** `helix-screen/tool_spool_assignments` in Moonraker's DB +holds assignments for all four heads (0=Red/23, 1=SIlver/15, 2=Black/3, 3=Gray/10) while +`print_task_config.filament_exist` reads `[true,false,false,true]`, so T1/T2 display filament +that is not there — and spool 15 is claimed by both tool 1 and ACE bay 0. This is § 10's open +item 3. Two separable questions: clearing the stale rows (a write to the user's DB), and +whether an assignment should display at all once the head reports empty — note +`slot_has_retained_identity()` is a deliberate feature, so today's behaviour may be intended. + +### Spool numbering — the label is not the index (2026-08-11) + +The ACE's bays were badged **5-8** for a rig with seven spools. Global slot indices are dense +over every *addressable* slot — the U1's four heads take 0-3, so the ACE starts at 4 — while +the badge should count *spools*, and the ACE-fed head and the bay behind it are one spool +counted twice. + +`owned_spool_slots()` already existed and already answered this (`[0,1,2,4,5,6,7]`); nothing +labelled from it. Added `AmsBackend::spool_display_number()` plus +`slot_identity_owner_slot()` — the companion to the existing `slot_identity_owner_unit()`, +resolving a viewing slot to the slot that actually holds the spool. The U1's heads now read +1,2,3 and the ACE's bays 4,5,6,7; T3 shares **4** with the bay feeding it rather than +consuming a number. + +**Do not "fix" this by changing `first_slot_global_index`.** The index is the addressing key — +subjects, `get_slot_info()`, load/unload dispatch and the active/target comparison all use it, +and two slots cannot share one. Presentation and addressing are separate on purpose. Backends +whose slots all own their spools are unaffected: `owned_spool_slots()` is then every slot in +order and the number is `slot_index + 1` exactly. + +### Numeric keyboard — the default one existed and was dead (2026-08-11) + +`keyboard_hint="numeric"` routed to the `?123` symbol page. The intended numpad +(`kb_map_num_improved`) was registered against LVGL's `LV_KEYBOARD_MODE_NUMBER` and **never +displayed** — nothing calls `lv_keyboard_set_mode()` with that mode, because KeyboardManager +drives the button matrix directly. Two things had therefore never been exercised: + +- **`LV_KEYBOARD_CTRL_BUTTON_FLAGS` does not include `CUSTOM_1`**, which is this codebase's + non-printing marker. Every action key on that map would have inserted its raw icon bytes. +- **Four keys had no handler at all** (`+/-`, `ICON_CHECK`, both chevrons). + +Now lives in `keyboard_layout_provider.cpp` as `KEYBOARD_LAYOUT_NUMERIC` with both fixed. + +**Trap worth keeping:** `LV_BUTTONMATRIX_WIDTH_MASK` is `0x000F` — a key width above **15** +overflows into the flag bits and silently drops keys from the rendered row rather than failing. +A first attempt used widths of 16 and 20 and lost a whole row. Pinned by a test. + +### Phase 3 — head-major layout + +With Phase 1, four heads draw as four columns. With multiACE, each head also has a **stack of +candidate sources** behind it (up to 4 ACE slots + stock feeder + manual). Group every slot in +the system by `mapped_tool` and render the stack under its head. Pure regroup of data the +model already holds — no new topology enum — and it also fixes the existing +`HELIX_MOCK_AMS=mixed` scenario (12 slots → 6 toolheads, drawn today as three disconnected +unit cards). + +Your rig makes the *asymmetric* case the default: three feeder heads with exactly one source +each, one ACE-fed head with four. The layout has to look right when most columns have a stack +of one — see the mockup. + +### Phase 4 — logical tools and >4 colours + +`extruder_map_table[32]` maps logical `T0..T31` → physical `0..3`. Full API in +`SNAPMAKER_U1_PRINT_TASK_CONFIG.md`. HelixScreen should show the plan and the swap bill before +the print, then send `SET_PRINT_EXTRUDER_MAP` per remap and `SET_PRINT_USED_EXTRUDERS +EXTRUDERS=` before start — the latter also fixes the standing empty-head false-runout on +a bare U1. Leave `ACE_SWAP_HEAD` emission to multiACE's post-processor; display and validate, +don't rewrite gcode. + +**Open question:** map project colours to **heads** or directly to **(ACE, slot)**? Orca has +the same question open; both surfaces should answer it the same way. + +### Phase 5 — the rest + +Per-ACE dryer (`get_dryer_info(unit)` already takes a unit index), humidity/temp per unit, +mode switching with a confirmation step, saved loadouts, `swap_phase`/`last_swap_result` +routed through `classify_error()` so a failed swap becomes an actionable card, and Spoolman +binding (your `ace.spool_binding` is already populated). + +--- + +## 5. Testing locally + +`HELIX_MOCK_AMS=u1` already exists (undocumented — `ams_backend.cpp:177`). Phase 2 adds +`HELIX_MOCK_AMS=multiace` parameterised to reproduce your rig and the ones you don't have: + +```bash +HELIX_MOCK_AMS=multiace HELIX_MOCK_ACE_COUNT=1 HELIX_MOCK_ACE_MODE=head \ + HELIX_MOCK_ACE_FEEDER=0,1,2 ./build/bin/helix-screen --test -vv # your machine +HELIX_MOCK_ACE_COUNT=3 HELIX_MOCK_ACE_MODE=multi # the 3-ACE case +HELIX_MOCK_ACE_SWAP=3 # mid-swap, step bar live +``` + +Against the real printer, with an isolated socket and config dir so it cannot collide with +another instance: + +```bash +export HELIX_SOCK=/tmp/helix-u1.sock HELIX_CONFIG_DIR=/tmp/helix-config-u1 +mkdir -p "$HELIX_CONFIG_DIR" +./build/bin/helix-screen --moonraker 192.168.2.242:7125 -vv --remote-socket "$HELIX_SOCK" & +./build/bin/helix-screen ctl -s "$HELIX_SOCK" navigate ams +./build/bin/helix-screen ctl -s "$HELIX_SOCK" screenshot /tmp/u1-ams.png +``` + +XML is loaded at runtime, so layout iteration needs no rebuild: set `HELIX_HOT_RELOAD=1`, edit +`ui_xml/*.xml`, and the running panel rebuilds within ~500 ms. + +--- + +## 6. Display budget — 480 × 320 + +The U1's panel is 480×320 (3.5", TLSC6x touch, DRM/KMS), HelixScreen's **TINY** tier and the +smallest resolution it supports. `docs/devel/480x320_UI_AUDIT.md` already lists the filament +panel as having cards pushed off-screen at this size, before any of this work. + +Real budget, measured from `ams_panel.xml` and the tokens: + +| Region | Cost | +|---|---| +| Nav rail | 42–56 px of the 480 | +| Panel header | 34 px of the 320 | +| Usable content | **~424 × 278 px** | +| Per head, 4 across | **~103 px wide** | + +103 px is about six characters of body text — enough for one head with one source, not enough +for a head with four. + +**The resolution: two layouts, both fitting 480×320, chosen by the backend.** + +| Layout | Per head | Right when | +|---|---|---| +| **Columns** (4 across) | 103 px | Each head has a single source — the stock U1, and your rig today. Keeps the spatial mapping to the four physical heads. | +| **Rows** (4 stacked) | 406 px | A head has somewhere else to go. Fits the live source, its state and a count chip with no truncation; four rows still leave ~60 px of vertical slack. | + +Swapping the axis is a better answer than paging (which is how the U1's own stock UI dodges +the problem) because it keeps all four heads on screen at once — the thing the merger bug was +hiding in the first place. Both forms are mocked up at 1:1 in the companion HTML, which +measures each screen in the browser on load and labels it `fits` or `overflows` rather than +asking you to take the numbers on trust. + +--- + +## 7. Open risks + +- **The shim's `map` field is `T0..T3`.** The AFC docs warn that `map` is a *virtual* tool + number, not a physical one, and must not be used to count nozzles. Here it happens to agree + with the physical head. Do not build on that agreement — use topology, as the fix does. +- **`mode: head` vs `multi` changes the slot→head mapping.** In multi mode slot *s* of every + ACE feeds head *s*; in head mode an ACE binds to one head and all four of its slots feed + *that* head — a hub, not a parallel fan. So per-unit topology genuinely differs by mode and + `get_unit_topology()` has to answer dynamically. Your machine is in head mode, so this is + the shape to build first. +- **Three stacks, one winner** stays true until Phase 2 lands. Any change to what is installed + on the printer silently changes which backend HelixScreen picks. +- **`AmsBackendMultiAce : AmsBackendSnapmaker`** means the U1 backend gains a subclass its + `protected` surface was not designed for. Expect a small refactor and re-run the U1 + regression tests hard. + +--- + +## 10. Handoff — open items (2026-08-10) + +Everything below is on `feat/snapmaker-multiace`, nothing pushed. + +### 10.1 Failing unit tests — ✅ fixed (2026-08-10) + +**It was 6 failures, not 3.** `[snapmaker]` showed 3; the other three live in +`test_ams_realtime_filament_state.cpp` under tags that do not include `[snapmaker]`, so only +`"[ams]"` sees the whole family. Now `[ams]` = **1656 cases, 0 failed** (was 1653 / 6). + +| Test | Line | +|---|---| +| `can_unload_from_toolhead offers unload for every loaded toolhead` | `test_ams_backend_snapmaker.cpp:497` | +| `motion-sensor runout path is independent of the loaded latch` | `test_ams_backend_snapmaker.cpp:1067` | +| `overrides slot LIVE accessors from sensor + LOADED status` | `test_ams_realtime_filament_state.cpp:113` | +| `AmsState publishes per-slot LIVE subjects on sync` | `test_ams_realtime_filament_state.cpp:186` | +| `Active-loaded subject is the single highlight source on unload` | `test_ams_realtime_filament_state.cpp:314` | +| `AMS clears filament_loaded after unload completes` | `test_ams_realtime_filament_state.cpp:483` | + +The diagnosis held: all six encode "mounted + spool present ⇒ LOADED", a weaker and more +defensible claim than the mounted+empty conflation `70ce3345b` actually removed. + +**The candidate fix recorded here was wrong** — it would have fixed one of the six. Four of +them assert `get_slot_info(i).status`, a **stored** field that `filament_present_at_tool_locked()` +does not feed; adding a term to that predicate leaves the status untouched. The real fix is +three parts: + +1. **The third presence term** (as proposed) in `filament_present_at_tool_locked()`. Needed + because `port_sensor_filament_present_` starts false and stays false until a + `filament_feed` frame names the tool, so a machine publishing only `print_task_config` + answers "nothing loaded" on the two sensor terms alone. +2. **Recompute the mounted slot's `status` per frame**, next to the `filament_loaded` + recompute. It was written *only* by the election, which fires on `active != current_tool`, + so a load completing while the tool stayed mounted — the normal case — left the slot + reading `AVAILABLE` indefinitely. Same staleness `5174f0f91` fixed for `filament_loaded`, + left behind on the status. +3. **Fold the motion-sensor runout clear into that recompute.** It was its own block *above* + the recompute, which then put `filament_loaded` straight back on the strength of the loaded + latch. Latent since `5174f0f91` and invisible because the test that covers it aborted on an + earlier `REQUIRE`. The runout gates `filament_loaded` only, never + `filament_present_at_tool_locked()` — after a runout the canvas must break the line to the + nozzle while Unload stays offered. + +Three regression tests added, each verified by mutation (revert the fix → the test goes red): +mounted-but-EMPTY is not loaded (the `70ce3345b` case, which shipped with no test at all), +a load completing without a tool change promotes the slot, and runout clears `filament_loaded` +while keeping Unload. + +### 10.2 ACE-fed head: unload never terminates in the UI — ✅ addressed 2026-08-10 + +The hypothesis below was right and is now implemented: `AmsBackendMultiAce::do_unload_filament` +sends `ACE_UNLOAD_HEAD HEAD=n` for a head the ACE feeds, and leaves feeder heads on the +inherited native path. **Not yet re-observed on hardware** — the fix is unit-tested against the +live payload, but nobody has run an unload on T3 since. Confirm before closing. + +The second defect (the 5-step LOAD model rendered during an unload) is untouched and still +open; it lives in the step-model selection, not the dispatch. + +*Original analysis:* + +Live repro on T3 (the only ACE-fed head): Unload from the multi-filament panel **succeeds on +the printer** — `channel_state` reaches `preload_finish`, the toolhead motion sensor drops to +false, `print_stats` stays `standby` — but the panel stays on "Unloading" forever. + +Two defects, probably one cause: + +1. The step list rendered during the unload is the **5-step LOAD model** + (Home/Select/Heat nozzle/Feed filament/Purge), not the 4-step unload model. +2. The operation never terminates, even though `preload_finish` is marked + `is_terminal` in the channel-state table. + +Hypothesis: HelixScreen dispatches the U1's native unload and waits for `unload_finish`, but +an ACE-fed head terminates at `preload_finish` because the ACE performs the retract. This is +the first concrete case of the Phase 2 rule — **an ACE-fed head must be driven with +`ACE_UNLOAD_HEAD HEAD=n`, not the native path** — and it will not be fixed properly until the +backend knows which heads the ACE feeds (`ace.head_ace` / `ace.head_feeder`). + +### 10.3 Toolhead context menu — ✅ built and hardware-verified (2026-08-10) + +Built as designed: a second canvas callback (`ui_filament_path_canvas_set_toolhead_callback`) +plus `AmsToolheadMenu`, modelled on `AmsSelectorMenu`. Unregistered, both canvas regions still +go to `slot_callback`, so no other panel changes behaviour. + +- **Select** — `select_slot()`, which already emits `T{n}` on this backend. No new gcode. +- **Park** — new `AmsBackend::park_toolhead()` + `supports_toolhead_park()`, Snapmaker sends + `PARK_EXTRUDER`. **Confirmed on the live machine**, since it is absent from `gcode/help`: + `configfile.settings['gcode_macro print_end']` calls it bare and parameterless, right after + `SM_PRINT_END_AUTO_UNLOAD_FILAMENT`. It is a carriage op — a docked head keeps its filament, + so Park must never unload. +- **Load / Unload** — mutually exclusive on `can_unload_from_toolhead()`. + +The rule is a pure function (`toolhead_menu_model()`), tested without LVGL in +`test_ams_toolhead_menu_model.cpp`. Entry visibility is published as subjects and bound with +``; hiding from C++ would have pushed the imperative-UI ratchet above its 384 +baseline. A head with no applicable action shows no menu rather than an empty card. + +**Verified against the U1** (T3 mounted, T0–T2 parked): tapping T3 offered **Park + Load**, +tapping T0 offered **Select T0** alone. Load rather than Unload on T3 is correct and worth +keeping in mind — `filament_exist[3]` is true but `channel_state` is `preload_finish`, so the +PETG is staged in the channel and not at the nozzle. `retraction_seen_` catches exactly that, +and the sidebar agrees ("Currently Loaded: ---", Unload greyed). + +The canvas hit-test only reaches this on PARALLEL topology, and `ctl click` cannot reach it at +all — it sends a widget event with no coordinates. Drive it with the synthetic pointer +(`ctl press ` / `ctl release`); nozzles sit at `canvas.y + canvas.h * 0.55`. + +### 10.4 Misleading wording in existing tests + +Several Snapmaker tests and comments say filament is retracted "to the buffer". The U1 has no +buffer — that term is borrowed from AFC's TurtleNeck. The U1's own vocabulary is **preload** +(`preloading` / `preload_finish`). Worth a comment-only pass so the next reader is not sent +looking for hardware that does not exist. + +### 10.5 From the code review (2026-08-14) — A/B done, C/D/E open + +A four-angle review (reuse / simplification / efficiency / altitude) of the whole branch. The +mechanical findings are fixed in `e0f22f471`; the items below were held back because they change +architecture rather than tidy it. **The two latent bugs (A, B) are fixed in `23a0cc95c` +(2026-08-15). C, D and E remain open** — they alter behaviour rather than close a hole, so they +want doing deliberately rather than folded into a cleanup. + +**A. `park_toolhead()` was outside the NVI gate — ✅ fixed 2026-08-15 (`23a0cc95c`).** +`Park` joined `FilamentOp`; `park_toolhead()` is `final` on `AmsSubscriptionBackend` and routes +through `run_filament_op()`; backends implement only the protected `do_park_toolhead()` hook, +whose default refuses. Snapmaker's hand-written gate is deleted. Mutating the gate away +reproduces the original bug and `[park]` catches it, asserting no gcode leaks on refusal. +Original diagnosis retained below. + +**A (original).** `park_toolhead()` is outside the NVI gate — latent, ordered first. +`load_filament` / `unload_filament` / `select_slot` / `change_tool` are `final` on +`AmsSubscriptionBackend` precisely so a backend *cannot* forget the print-active gate; that +class's own comment records that opt-in gating already shipped one backend with no gate at all +(`329e731e9` added it to seven and missed the eighth). `park_toolhead()` (`ams_backend.h`) is a +plain virtual whose only enforcement is a `@warning` telling each implementer to hand-write +`check_preconditions(true)`, and it skips the `FilamentOpClaim` test-and-set, so a park can +dispatch while a load is in flight. Latent only because `supports_toolhead_park()` is true on +exactly one backend today — the second one to implement it docks the head mid-print. +*Fix:* add `Park` to `FilamentOp`, make `park_toolhead()` `final` on `AmsSubscriptionBackend` +dispatching to a protected `do_park_toolhead()`. Snapmaker then drops its hand-written gate. + +**B. The toolhead menu keyed slot-indexed APIs with a VIRTUAL tool number — ✅ fixed 2026-08-15 +(`23a0cc95c`).** Resolved once via `mapped_tool` in both `show_at()` and the shared dispatch, +falling back to the raw index for a backend publishing no mapping. Worse than first described: +`can_unload_from_toolhead(int slot_index)` is slot-indexed *despite its name*, and the dispatch +was passing the tool number to three further slot-indexed calls — so every backend call in the +menu took a slot while receiving a tool. `[tool_index]` pins it with a deliberately remapped +machine alongside the U1's identity case. Original diagnosis retained below. + +**B (original).** The toolhead menu keys slot-indexed APIs with a VIRTUAL tool number — latent. +`ui_system_path_canvas.h` documents the callback argument as "the VIRTUAL tool number shown on +the badge, not the physical column". `AmsToolheadMenu::show_at()` passes it unconverted to +`get_slot_info()`, `can_unload_from_toolhead()`, `select_slot()`, `load_filament()` and +`unload_filament()` — all slot-indexed. It works only because tool == slot on the U1; +`ams_backend.h` records that toolchanger tool numbers diverge from slots under `ASSIGN_TOOL` +remapping, and the overview registers this callback for every backend. (The missing bounds +check is already restored in `e0f22f471`; the index is still the wrong *kind* of index.) +*Fix:* resolve tool → slot once via `tool_layout.virtual_to_physical` + `mapped_tool` at the +top of `show_at()`. + +**C. OPEN — the toolhead menu bypasses `plan_load()`.** +The sidebar, filament panel, runout handler and print-status widget all funnel through +`plan_load()` / `BackendCaps`. This menu calls `load_filament()` / `unload_filament()` / +`select_slot()` directly, so it gets none of `requires_slot_selection_for_load`, +`needs_unload_before_load`, the already-mounted refusal, the preheat flow, the step bar — or +`change_tool_completes_load`, the capability this very branch added to that planner. Related: +its print-blocks gate is a fifth copy of the same preamble and omits the `is_busy()` term the +per-slot menu carries. + +**D. OPEN — `change_tool_completes_load()` should be derived, not declared.** +The planner arm it guards substitutes `change_tool(mapped_tool)` for "load slot N", which is +valid exactly when the tool number *identifies* the slot. On an ACE in head mode four bays share +one `mapped_tool`, so it is ambiguous — and that is visible in the `AmsSystemInfo` `plan_load()` +already holds. Deriving it ("take this arm only when `target_slot` is the only slot with that +`mapped_tool`") deletes the virtual, the `BackendCaps` field and its three call sites, keeps +AFC/HH/CFS behaviour (they map lanes 1:1), and protects the next many-to-one backend for free. + +**E. OPEN — also flagged, smaller.** `detect_step_operation()`'s guard fixes the UNLOAD direction only — +the same mid-operation transient resets the bar during a LOAD. `slot_identity_owner_unit()` and +`slot_identity_owner_slot()` are one concept split in two with an unenforced invariant. Bars and +badge text in `ui_ams_slot.cpp` are read once at widget construction, so they go stale on any +inventory change that does not alter the slot count. `override_refetch_wanted_` is armed on every +`event_seq` bump — confirm multiACE does not bump it for telemetry, or that is a continuous HTTP +loop. + +--- + +--- + +## 11. Session log — 2026-08-10/11 + +25 commits. Grouped by what they were for, since the order they landed in is not the order +they make sense in. + +### Phase 2 — the backend + +| Commit | What | +|---|---| +| `2c568b983` | `AmsBackendMultiAce`, deriving from `AmsBackendSnapmaker`. Unit 0 stays the U1; units 1..N are the ACE. ACE-fed heads dispatch `ACE_LOAD_HEAD`/`ACE_UNLOAD_HEAD`. | +| `29c8e5a46` | Stop rebuilding the ACE units wholesale each frame — it discarded user edits and reset the view. | +| `910400da2` | **A partial `ace` frame no longer wipes head sources.** The delta bug; see the traps list. | +| `5053dd26b` | The dryer: `ACE_DRY` / `ACE_STOP_DRYING` + `get_dryer_info()`. | + +Three payload facts no document states, all found by reading the live machine: + +- `head_ace` carries an index for **every** head (`{0:0,1:1,2:2,3:0}` while only head 3 is + ACE-fed), so it cannot decide *whether* a head is ACE-fed. `head_feeder`/`head_manual` can. +- The per-head maps are keyed by **string** (`"0"`..`"3"`). An int key silently finds nothing. +- `slots[].color` is an `[r,g,b]` **array**, not a hex string. + +And one that cost the most: `handle_status_update` must unwrap `params[0]` exactly as the base +does. Reaching for a `"status"` key compiles, passes every unit test that feeds a bare object, +logs nothing, and leaves the backend behaving like the plain Snapmaker one. + +### Detection + +`AmsType::MULTIACE` needed **five** sites, not one. `has_mmu_` skips the `has_snapmaker_` +fallback that populates `detected_ams_systems_` — the list `AmsState` iterates to build +backends — so claiming the type alone produced NO backend and an empty panel. The others: +`is_tool_changer()`, `is_filament_system()`, the `AmsBackend::create` factory, and the +subscription block (which is where `ace` gets subscribed at all). + +### UI + +| Commit | What | +|---|---| +| `6f8fcbac0` | Recompute the mounted slot's load state every frame (§10.1's real fix). | +| `024ac964f`, `1c5a1180d` | Per-toolhead context menu, on both the detail canvas and the overview's nozzle row. | +| `b83cf6bbd` | An ACE-fed head's spool identity belongs to the ACE: the slot menu drops Edit/Spoolman/Clear and offers "Open in ACE". | +| `6366ee716`, `e74823a48` | One nozzle per head, and a unit draws lines only to heads it feeds. | +| `7c0cb0b0e`, `4925782d1` | Count spools rather than slots (7, not 8); the home widget's bar cap is per row. | +| `64dc47738`, `d6e0aa7d7` | No hub box on a shared toolhead; dim heads that are not active; outline an externally-fed slot. | +| `5fc8c6024` | A unit feeding one known head draws through to that toolhead. | +| `65ee32bf9`, `a02bd6fab`, `5122216e5` | Spool assignment on ACE bays; assigned-but-empty lanes show their colour; an empty bay cannot claim a tool's spool. | +| `be61ed894` | The environment overlay reads the unit it is showing (10 lines; upstream's bug). | + +### What was verified how + +Everything above was checked against the live U1 at `192.168.2.242`, not just unit tests — +mostly by driving the running app with `ctl` and reading screenshots. Two findings came from +**sampling rendered pixels** rather than eyeballing: the assigned-but-empty lane really drew +zero coloured pixels (not a dim tint), and the fix produced 70. When a screenshot and a theory +disagree, decode the PNG. + diff --git a/docs/devel/plans/2026-08-10-multiace-dryer-mockup.html b/docs/devel/plans/2026-08-10-multiace-dryer-mockup.html new file mode 100644 index 0000000000..f806b1cb68 --- /dev/null +++ b/docs/devel/plans/2026-08-10-multiace-dryer-mockup.html @@ -0,0 +1,406 @@ +HelixScreen — ACE dryer in the environment panel + + + +
+ +
+ +
+ Snapmaker U1 + multiACE — design memo, revised 2026-08-11 +

Drying the ACE from the environment panel

+

HelixScreen already has the whole dryer UI — readout, progress bar, + temperature and duration inputs, start/stop — in ams_environment_overlay.xml. + It never appeared on this printer because no Snapmaker backend reported a dryer, so + get_dryer_info() answered supported = false and the panel showed + its "no dryer" branch. The ACE has one, reports it live, and takes its settings as command + parameters.

+

Screens are 1:1 at 480 × 320 and self-measure on load. + Live values from the ACE 2 Pro at 192.168.2.242.

+
+ +
+
✕

Correction to the first version

+ +
+ The first version of this memo was wrong +

It claimed temperature and duration cannot be set without editing + multiACE's config and restarting Klipper, and mocked them read-only. That came from + reading only multiACE's README macro table, where ACED__DRY_START_0..3 are + listed as "uses config settings".

+

Those macros are the parameterless Fluidd buttons. The actual commands take parameters, + and OrcaSlicer's own multiACE page uses them. Nothing needs a restart.

+
+ + + + + + +
CommandWhat it does
ACE_DRY ACE=n [TEMP=] [DURATION=]Start drying, temperature in °C and duration in minutes
ACE_STOP_DRYING [ACE=n]Stop
ACE_SET_AUTO_DRYHumidity-controlled drying, "live + persist"
+ +

All three confirmed in the live printer's gcode/help. The consequence for this + design is simple: the panel's temperature and duration controls are real, and + auto-dry can be a toggle rather than a readout.

+
+ +
+
A

Idle, and drying

+

Presets as pills for the common cases — the same three OrcaSlicer offers — with an + editable value beside them for anything else. The field is the truth and the + pills are shortcuts into it: tapping a pill fills the field, typing a value the pills do not + offer just deselects them. Both write into ACE_DRY's TEMP= and + DURATION=, and duration is entered in minutes, which is the unit + the command takes — no conversion between what you type and what is sent.

+

Live state comes from dryer_status, so the panel reflects a cycle started + anywhere, including from Fluidd or OrcaSlicer.

+ +
+
+ idle — your rig now +
+ +
+
‹ACE 2 Pro — environment
+
+
+
+
Chamber
31 °C
+
Humidity
35 %
+ Not drying +
+
+
+ Dry at +
+ temp + 45°55°65° + 55°C +
+
+ for + 2 h4 h6 h + 240min +
+
Start drying
+
+
+
+ Auto-dry + +
+

Start at 45 % RH, stop at 35 %.

+
+
+
+
+

Two taps and Start. 70 °C is multiACE's own safety cap, so the + pill set stops below it rather than letting a number through that the firmware would + clamp anyway.

+
+ +
+ drying +
+ +
+
‹ACE 2 Pro — environment
+
+
+
+
Chamber
48 °C
+
Humidity
22 %
+ Drying +
+
+
+
+ Drying to 55 °C + 3 h 12 m left +
+
+
Stop drying
+
+
+
+ Auto-dry + +
+

Started automatically at 45 % RH. Stops at 35 %.

+
+
+
+
+

The pills are replaced by what is actually running, so the card never + shows a setting that is not the one in effect. Progress and remaining come from + remain_time / duration, which + DryerInfo::get_progress_pct() already computes.

+
+ +
+ typing an exact value +
+ +
+
‹ACE 2 Pro — dry at
+
+
+
+ Temperature + 58°C +
+

35–70 °C. Above 70 is refused by multiACE.

+
+
+ 123 + 456 + 789 + ⌫0✓ +
+
+
+
+

Tapping the value field opens the numeric keypad the text_input widget + already raises. Typing a value the pills do not offer simply deselects them — the + field is the truth, the pills are shortcuts into it.

+
+
+
+ +
+
B

Several ACEs

+

Up to four. The panel is per unit, so chips select which one and everything below follows. + ACE_STOP_DRYING takes an explicit ACE=, so selecting a unit does not + have to make it the active one — the stop always names its target.

+ +
+
+ 3 ACEs, the middle one drying +
+ +
+
‹ACE drying
+
+
+ ACE 1ACE 2ACE 3 +
+
+
+
Chamber
48 °C
+
Humidity
22 %
+ Drying +
+
+
+
+ Drying to 55 °C + 3 h 12 m left +
+
+
Stop ACE 2
+
+
+
+
+

The stop button names its unit. With four ACEs on one printer, a bare + "Stop" that acts on whichever is current is the kind of button that dries the wrong + spools.

+
+
+
+ +
+
?

What is still open

+ +
+
Auto-dry's parameter names
+
ACE_SET_AUTO_DRY exists and persists, so the toggle above is buildable — but + neither multiACE's bundled docs nor OrcaSlicer's page call it, so its arguments are unknown. + The live object shows the shape (enabled, rh_start, + rh_end, temp, master, add_time). Running + it bare in a console would print the usage; probing it blind from here would change machine + state, which is why it is not wired yet.
+ +
Preset values
+
45/55/65 °C and 2/4/6 h follow OrcaSlicer, and are three numbers in one place + if your filaments want different ones. Nothing is lost either way now that any value can be + typed.
+
+ +

Already built: + get_dryer_info() maps aces[].dryer_status onto the existing + DryerInfo; start_drying() emits ACE_DRY and + stop_drying() emits ACE_STOP_DRYING. Confirmed working on the + machine. What remains is this panel's presentation.

+
+ +
+ + diff --git a/docs/devel/plans/2026-08-10-multiace-ui-improvements-mockup.html b/docs/devel/plans/2026-08-10-multiace-ui-improvements-mockup.html new file mode 100644 index 0000000000..f4d5b564c3 --- /dev/null +++ b/docs/devel/plans/2026-08-10-multiace-ui-improvements-mockup.html @@ -0,0 +1,679 @@ +HelixScreen — multiACE UI improvements (4 changes) + + + +
+ +
+ +
+ Snapmaker U1 + multiACE — design memo +

Four fixes to the multi-filament panel

+

The multiACE backend landed and the data is right: four U1 heads as unit 0, + the ACE 2 Pro as unit 1, head_source[3] pointing at bay 1. What is + left is entirely presentation — the aggregate canvas still draws units side by side, which is + how T3 ends up on screen twice. These four changes are the ask, in the order given.

+

Every screen below is 1:1 at 480 × 320 — the U1's real panel. + Measured in your browser on load and labelled fits or + overflows; nothing here is taken on trust. + Companion to 2026-08-09-multiace-mockup.html.

+
+ + + +
+
1

T3 appears once

+

In head mode one ACE binds to one head, so all four of its bays map to T3. The aggregate + canvas lays units out left to right and gives each its own nozzle row — so T3 is drawn twice + and the panel claims five toolheads on a four-head machine. The fix is to group every slot in + the system by mapped_tool and render the ACE bays as a stack behind the + head they feed, which is the head-major layout the plan already calls Phase 3.

+ +
+
+ now +
+ +
+
‹Multi-Filament Overview
+
+
+
+
SnapSwap
4 slots
+
ACE 2 Pro
31°C  35%
+
+
+
T0
+
T1
+
T2
+
T3
+
T3
+
+
+
+ Currently loaded +
———
+
Unload
+
Reset
+
+
+
+
+

Five nozzles, T3 twice. The second one is the ACE unit's own row — + correct data, drawn as if it were a fifth physical head.

+
+ +
+ proposed +
+ +
+
‹Multi-Filament Overview
+
+
+
+
+
T0
+
feeder
+
+
+
+
T1
+
feeder
+
+
+
+
T2
+
feeder
+
+
+
+
T3
+
PETGACE bay 1
+
+
+
+
+
+ Currently loaded +
PETG
T3 · ACE bay 1
+
Unload
+
Reset
+
+
+
+
+

Four heads, four nozzles. The ACE is not a peer of the SnapSwap — + it is the source behind T3, and says so in the seat.

+
+
+
+ + + +
+
2

Toolheads carry the context menu here too

+

The per-toolhead menu — Select / Park / Load / Unload — is + already built and hardware-verified, but it is wired only to the detail panel's canvas. The + overview is where you actually land when there is more than one unit, so the same nozzle tap + has to open the same menu. Entries stay computed from the backend, so a parked empty head + offers only Select, and during a print the menu does not open at all.

+ +
+
+ proposed — tap T1's nozzle +
+ +
+
‹Multi-Filament Overview
+
+
+
+
T0
+
feeder
+
+
T1
+
feeder
+
+
T2
+
feeder
+
+
T3
+
PETGACE bay 1
+
+
+
+
+ Currently loaded +
PETG
T3 · ACE bay 1
+
Unload
+
Reset
+
+
+ +
+
+

T1 is parked and its channel is empty, so the only honest offer is + Select. No Park (not mounted), no Load/Unload (nothing to move).

+
+ +
+ proposed — tap T3's nozzle +
+ +
+
‹Multi-Filament Overview
+
+
+
+
T0
+
feeder
+
+
T1
+
feeder
+
+
T2
+
feeder
+
+
T3
+
PETGACE bay 1
+
+
+
+
+ Currently loaded +
PETG
T3 · ACE bay 1
+
Unload
+
Reset
+
+
+ +
+
+

T3 is the mounted head holding filament: Park and Unload. Unload + dispatches ACE_UNLOAD_HEAD here, not the native path.

+
+
+
+ + + +
+
3

The ACE-fed spool is not edited from SnapSwap

+

T3's slot in the SnapSwap unit shows filament that the ACE owns. Editing material, colour or + spool there would write to print_task_config and be silently overwritten the next + time the ACE reports its inventory — two sources of truth for one spool. So the seat stops + opening the edit sheet and explains where the real control lives, with a one-tap route to it. + Load and Unload stay available — those act on the head, which is still the U1's + job; only the spool's identity belongs to the ACE.

+ +
+
+ proposed — tap T3's spool +
+ +
+
‹Multi-Filament: SnapSwap
+
+
+
+
T0
+
empty
+
T1
+
empty
+
T2
+
empty
+
T3
+
PETG🔒 ACE
+
+
+
+ Currently loaded +
PETG
T3
+
Unload
+
+
+
+
+

T3 is fed by the ACE 2 Pro

+

This spool's material, colour and Spoolman link live on the ACE, in bay 1. + Editing them here would be overwritten the next time the ACE reports in.

+

Loading and unloading T3 still works from this panel.

+
CloseOpen ACE 2 Pro
+
+
+
+

A padlock on the seat marks it at a glance; the sheet says why and + offers the one useful action rather than just refusing.

+
+ +
+ the quieter alternative +
+ +
+
‹Multi-Filament: SnapSwap
+
+
+
+
T0
+
empty
+
T1
+
empty
+
T2
+
empty
+
T3
+
PETG🔒 ACE
+
+
+
+ Currently loaded +
PETG
T3
+
Unload
+
+
+ +
+
+

No modal at all: the existing slot menu simply drops Edit and Spoolman + for an ACE-fed slot and gains "Open in ACE". Fewer taps, less to dismiss.

+
+
+ +

Recommendation: ship the quieter one. The padlock already carries the + message, and a full-screen sheet to say "not here" is a lot of ceremony for a slot you tapped + by accident. Keep the sheet's wording for the menu's header row.

+
+ + + +
+
4

The ACE view is a combiner

+

In head mode all four ACE bays feed one head — that is a hub, not a parallel fan, and the + renderer already knows how to draw it: render_linear_hub() is the combiner view + the AFC unit used, lanes converging through a box to a single nozzle. The backend already + reports PathTopology::HUB for an ACE unit in head mode, so this is a matter of + routing the unit detail to that renderer rather than the parallel one.

+ +
+
+ proposed — ACE unit detail, head mode +
+ +
+
‹ACE 2 Pro — feeding T3
+
+
+
+
+
1 · PETG
+
2
+
3
+
4
+
+ +
COMBINER
+ +
+
T3
+
+
+
+
+ Unit +
31°C · 35% RH
ready
+
Dry
+
Bind head…
+
+
+
+
+

Four bays, one combiner, one nozzle — the shape the hardware actually + has in head mode. The live lane is the only one drawn in filament colour.

+
+ +
+ the same unit in multi mode +
+ +
+
‹ACE 2 Pro — one bay per head
+
+
+
+
bay 1
+
PETG
+
T0
+
bay 2
+
empty
+
T1
+
bay 3
+
empty
+
T2
+
bay 4
+
empty
+
T3
+
+
+
+ Unit +
31°C · 35% RH
ready
+
Dry
+
Mode…
+
+
+
+
+

Not your rig, but the same code path has to answer for it: in multi + mode bay s feeds head s, so the unit is a parallel fan and must not + draw a combiner.

+
+
+ +
+
Topology is per unit, and already correct
+
The backend sets HUB for head mode and PARALLEL for multi + mode on each ACE unit. The unit detail has to read get_unit_topology(), not the + system-wide answer — reading the system-wide one is the exact mistake that drew the U1's + four heads as a merger in the first place.
+ +
Nothing here needs new backend data
+
All four changes are renderer and menu wiring. mapped_tool, + head_source, per-unit topology and the ACE inventory are already parsed and + unit-tested against the captured payload.
+
+
+ +
+ + diff --git a/docs/devel/specs/2026-07-25-helixctl-ui-test-harness-design.md b/docs/devel/specs/2026-07-25-helixctl-ui-test-harness-design.md index 6d95dc37c8..74e8b4eb2f 100644 --- a/docs/devel/specs/2026-07-25-helixctl-ui-test-harness-design.md +++ b/docs/devel/specs/2026-07-25-helixctl-ui-test-harness-design.md @@ -63,7 +63,7 @@ One `freeze` command combining: transient test-mode action; persisting it means a `--remote` dev instance that is killed or crashes between `freeze` and `unfreeze` leaves the user's real config with animations permanently disabled. Set the subject directly, or add a non-persisting setter for this - case. Automated tests are insulated by the `settings-test.json` split, which is exactly why + case. Automated tests are insulated by the `config/settings-test.json` split, which is exactly why this would not have surfaced in testing. - Pausing periodic timers individually, **with a skip list** (see below); the codebase has 85 `lv_timer_create` sites driving repaints. diff --git a/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt b/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt index 742a943fab..6f2026f12b 100644 --- a/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt +++ b/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt @@ -124,6 +124,7 @@ src/printer/ams_backend_ad5x_ifs.cpp src/printer/ams_backend_afc.cpp src/printer/ams_backend_cfs.cpp src/printer/ams_backend_happy_hare.cpp +src/printer/ams_backend_multiace.cpp src/printer/ams_bypass_policy.cpp src/printer/ams_backend_qidi.cpp src/printer/ams_backend_snapmaker.cpp @@ -310,6 +311,7 @@ src/ui/ui_ams_selector_menu.cpp src/ui/ui_afc_fault_path.cpp src/ui/ui_ams_sidebar.cpp src/ui/ui_ams_slot.cpp +src/ui/ui_ams_toolhead_menu.cpp src/ui/ui_buffer_meter.cpp src/ui/ui_busy_overlay.cpp src/ui/ui_button.cpp diff --git a/include/ams_backend.h b/include/ams_backend.h index dfa3bdfe76..b3249cc47e 100644 --- a/include/ams_backend.h +++ b/include/ams_backend.h @@ -678,6 +678,62 @@ class AmsBackend { return false; } + /** + * @brief Can a filament op dispatched by this backend emit a G28 that + * HelixScreen itself sends? + * + * The inverse question to filament_ops_self_home(), and a different one: + * that flag is about a home buried inside FIRMWARE where we cannot see it, + * this one is about the home WE send, in + * AmsSubscriptionBackend::ensure_homed_then(). + * + * Exists so a UI surface can decide whether to ask "home printer first?" + * before it starts a preheat. The prompt is worth moving that early only + * when the op it precedes can actually home; asking on a backend that never + * emits G28 requests consent for something that will not happen, and a + * decline cancels the load outright (Snapmaker U1: do_load_filament() + * dispatches `AUTO_FEEDING ... LOAD=1` straight to firmware, which feeds + * without moving the toolhead at all). + * + * False here because a plain AmsBackend has no ensure_homed_then() to route + * through — the machinery lives on AmsSubscriptionBackend, which answers + * true, and the two subclasses that bypass it answer false again. Every + * answer is therefore derivable from what that class's dispatch actually + * does, rather than being a fact to remember. + * + * This does NOT gate the G28 itself. ensure_homed_then() still decides that + * from toolhead.homed_axes, and still asks its own confirmation — a backend + * that answers false simply never reaches it. + */ + [[nodiscard]] virtual bool filament_ops_may_home() const { + return false; + } + + /** + * @brief Does changing to slot @p slot_index's mapped tool COMPLETE its load? + * + * plan_load()'s swap arm turns "load slot N" into `change_tool(N's mapped + * tool)` and stops there, on the strength of that being the whole operation: + * ACE's change_tool() is literally `return load_filament(...)`, AFC's is + * `CHANGE_TOOL LANE={n}`, QIDI's load prepends its own unload. Nothing + * chains a second command, so a backend where the tool change is only HALF + * the job silently performs half. + * + * multiACE is that backend. Its ACE bays report mapped_tool = the head they + * feed, so tapping Load on a bay planned `T3` -- which mounts head 3, moves + * the carriage, and feeds nothing. The bay still has to be named: + * `ACE_LOAD_HEAD HEAD=h ACE=a SLOT=s`. + * + * Per-slot rather than per-backend because one backend can be both: on + * multiACE the U1's own heads keep their filament at the head, so a tool + * change really is the whole load there, while a bay four slots later is + * not. Default true -- the behaviour every backend had before this existed. + */ + [[nodiscard]] virtual bool change_tool_completes_load(int slot_index) const { + (void)slot_index; + return true; + } + /** * @brief Record that the user has already agreed to a pre-operation home for * the NEXT dispatch, so ensure_homed_then() does not ask a second @@ -1148,6 +1204,236 @@ class AmsBackend { return loaded_hint; } + /** + * @brief Which unit owns this slot's filament IDENTITY, when not this one. + * + * On multiACE a U1 head is fed from an ACE bay, so the head's material, + * colour and Spoolman link are the ACE's to state. Editing them on the head + * would write `print_task_config` and be overwritten the moment the ACE + * reports its inventory again — two sources of truth for one spool. The + * slot menu uses this to drop its edit actions and offer a route to the + * owning unit instead. + * + * Identity only. Loading and unloading still act on the head and stay + * available; this says who describes the filament, not who moves it. + * + * @param slot_index Global slot index. + * @return Owning unit index, or nullopt when the slot describes itself + * (every backend except multiACE, always). + */ + [[nodiscard]] virtual std::optional slot_identity_owner_unit(int slot_index) const { + (void)slot_index; + return std::nullopt; + } + + /** + * @brief Global indices of the slots that hold a spool of their OWN, in order. + * + * A slot fed from another unit is not a spool position — it is a view of one. + * On multiACE, the U1's ACE-fed head and the ACE bay behind it are a single + * physical spool, so counting both double-counts it: a 4-head U1 with one + * 4-bay ACE has 7 spool positions, not 8. + * + * This is what user-facing COUNTS and per-spool rows should iterate. + * `total_slots` remains the indexing bound and is unchanged — every slot + * here is still addressable, still loadable, still unloadable. + * + * Derived from slot_identity_owner_unit(), so a backend that answers that + * question needs nothing further. + */ + [[nodiscard]] std::vector owned_spool_slots() const { + return owned_spool_slots(get_system_info()); + } + + /// Overload for callers that already hold the system info. + /// + /// get_system_info() returns BY VALUE under the backend mutex — every unit, + /// every SlotInfo, every std::string in them. These helpers are called per + /// slot and per frame, so re-fetching it inside each one was the dominant + /// cost of drawing a badge. Callers with `info` in hand should pass it. + [[nodiscard]] std::vector owned_spool_slots(const AmsSystemInfo& info) const { + std::vector out; + out.reserve(static_cast(info.total_slots)); + for (int i = 0; i < info.total_slots; ++i) { + if (!slot_identity_owner_unit(i).has_value()) { + out.push_back(i); + } + } + return out; + } + + /** + * @brief Which slot holds the spool that @p slot_index is showing. + * + * The companion to slot_identity_owner_unit(): that says WHICH UNIT + * describes the filament, this says which slot of it. An ACE-fed head and + * the ACE bay behind it are one spool, so the head resolves to the bay. + * + * @param slot_index Global slot index. + * @return Global index of the owning slot, or nullopt when the slot holds + * its own spool (every backend except multiACE, always). + */ + [[nodiscard]] virtual std::optional slot_identity_owner_slot(int slot_index) const { + (void)slot_index; + return std::nullopt; + } + + /** + * @brief The 1-based number a slot should be LABELLED with. + * + * Not the same as the global index, and deliberately so. Global indices are + * the addressing key and must stay dense over every addressable slot; the + * label counts SPOOLS. On a U1 with one ACE those disagree: the U1's four + * heads take global 0-3, so the ACE's bays start at global 4 and used to be + * labelled 5-8 — eight numbers for seven spools, because the ACE-fed head + * and the bay feeding it are the same spool counted twice. + * + * A slot that only views another slot's spool resolves to it, so the head + * and its bay share one number instead of consuming two. + * + * Backends whose slots all own their spools are unaffected: owned_spool_slots() + * is then every slot in order, and this returns slot_index + 1 exactly. + */ + [[nodiscard]] int spool_display_number(int slot_index) const { + return spool_number_in(owned_spool_slots(), slot_index); + } + + /// Overload for callers that already hold the system info — see + /// owned_spool_slots(const AmsSystemInfo&). + [[nodiscard]] int spool_display_number(int slot_index, const AmsSystemInfo& info) const { + return spool_number_in(owned_spool_slots(info), slot_index); + } + + /// The badge number for @p slot_index given a PRE-BUILT owned-slot list. + /// + /// Split out so spool_display_label() can build that list once instead of + /// once per candidate slot — it used to call spool_display_number() inside + /// its loop, and each call deep-copied the whole AmsSystemInfo and took the + /// backend mutex once per slot. + [[nodiscard]] int spool_number_in(const std::vector& owned, int slot_index) const { + const int target = slot_identity_owner_slot(slot_index).value_or(slot_index); + for (size_t i = 0; i < owned.size(); ++i) { + if (owned[i] == target) { + return static_cast(i) + 1; + } + } + // Not a spool position and nothing owns it — fall back to the raw index + // rather than showing nothing. + return slot_index + 1; + } + + /** + * @brief What a slot's badge should READ — a number, or a range. + * + * A slot holding its own spool is simply its number ("3"). + * + * A slot that only views another unit's spools is not one spool but a + * position any of them can reach, so it reads as the range ("4-7"). On a U1 + * in head mode all four ACE bays feed the one ACE-fed head; naming only the + * seated bay would be a number that changes under the user every time the + * ACE swaps, and would hide the other three entirely. In multi mode exactly + * one bay feeds each head, so the range collapses back to a single number + * with no special-casing. + * + * Unchanged for backends whose slots all own their spools. + */ + [[nodiscard]] std::string spool_display_label(int slot_index) const { + // ONE system fetch and ONE owned-slot build for the whole function. This + // used to fetch per call and again per loop iteration — roughly five deep + // copies of AmsSystemInfo and fifty mutex acquisitions to render one badge. + const AmsSystemInfo info = get_system_info(); + const std::vector owned = owned_spool_slots(info); + + const auto owner_unit = slot_identity_owner_unit(slot_index); + if (!owner_unit.has_value()) { + return std::to_string(spool_number_in(owned, slot_index)); + } + if (*owner_unit < 0 || *owner_unit >= static_cast(info.units.size())) { + return std::to_string(spool_number_in(owned, slot_index)); + } + // Which of the owner's slots can feed this position: the ones mapped to + // the same tool. + const int tool = get_slot_info(slot_index).mapped_tool; + const auto& unit = info.units[static_cast(*owner_unit)]; + int lo = -1; + int hi = -1; + for (int s = 0; s < static_cast(unit.slots.size()); ++s) { + if (unit.slots[static_cast(s)].mapped_tool != tool) { + continue; + } + const int n = spool_number_in(owned, unit.first_slot_global_index + s); + if (lo < 0 || n < lo) { + lo = n; + } + if (hi < 0 || n > hi) { + hi = n; + } + } + if (lo < 0) { + // Owned by a unit that maps none of its slots here — say what we can. + return std::to_string(spool_number_in(owned, slot_index)); + } + if (lo == hi) { + return std::to_string(lo); + } + return std::to_string(lo) + "-" + std::to_string(hi); + } + + /** + * @brief How many slots on @p unit_index hold a spool of their own. + * @see owned_spool_slots() + */ + [[nodiscard]] int unit_spool_slot_count(int unit_index) const { + return unit_spool_slot_count(unit_index, get_system_info()); + } + + /// Overload for callers that already hold the system info — the overview + /// builds one card per unit and had been re-fetching for each. + [[nodiscard]] int unit_spool_slot_count(int unit_index, const AmsSystemInfo& info) const { + if (unit_index < 0 || unit_index >= static_cast(info.units.size())) { + return 0; + } + const auto& unit = info.units[unit_index]; + int n = 0; + for (int s = 0; s < unit.slot_count; ++s) { + if (!slot_identity_owner_unit(unit.first_slot_global_index + s).has_value()) { + ++n; + } + } + return n; + } + + /** + * @brief Whether the backend can dock the mounted toolhead without unloading. + * @return true if park_toolhead() is implemented (toolchangers only). + */ + [[nodiscard]] virtual bool supports_toolhead_park() const { + return false; + } + + /** + * @brief Return the mounted toolhead to its dock, leaving filament alone. + * + * Parking is a carriage operation, not a filament one: on a toolchanger a + * head is routinely docked with filament still threaded to its nozzle, and + * the next pick-up finds it exactly as it was. So this must NOT unload — + * the two are separate actions with separate menu entries. + * + * Only meaningful while a head is actually mounted; with an empty carriage + * there is nothing to dock. Callers gate on MountState/mounted_tool. + * + * Gated like every other toolhead-motion op: AmsSubscriptionBackend makes + * this `final` and routes it through run_filament_op(), so an implementer + * writes only the protected do_park_toolhead() hook and cannot forget the + * print refusal or the single-op-in-flight claim. It used to be a plain + * virtual carrying a warning to hand-write check_preconditions(true). + * + * @return AmsError indicating success or failure. + */ + virtual AmsError park_toolhead() { + return AmsErrorHelper::not_supported("Toolhead park"); + } + /** * @brief Whether the backend can position the selector at a gate without loading. * @return true if select_gate() is implemented (selector-based systems only). @@ -1516,6 +1802,41 @@ class AmsBackend { return get_default_drying_presets(); } + /** + * @brief Get humidity-controlled ("auto") drying state for a unit + * + * A dryer is not necessarily an auto-dryer: this is a separate capability + * from get_dryer_info() and a backend may support one without the other. + * Check AutoDryInfo::supported before showing any auto-dry UI. + * + * @param unit AMS unit index (0-based) + * @return AutoDryInfo struct (supported=false if the unit has no auto-dry) + */ + [[nodiscard]] virtual AutoDryInfo get_auto_dry_info(int unit = 0) const { + (void)unit; + return AutoDryInfo{}; + } + + /** + * @brief Arm or disarm humidity-controlled drying + * + * Arming does not start a cycle — it hands the dryer to the humidity rule, + * which starts one when the reading crosses AutoDryInfo::rh_start_pct. + * Backends are expected to persist the setting across reboots. + * + * Refuse rather than send when AutoDryInfo::can_enable() is false; a + * follower with no master is a state the firmware rejects anyway. + * + * @param enabled true to arm the rule, false to disarm it + * @param unit AMS unit index (0-based) + * @return AmsError with SUCCESS result on success, or error with reason + */ + virtual AmsError set_auto_dry_enabled(bool enabled, int unit = 0) { + (void)enabled; + (void)unit; + return AmsErrorHelper::not_supported("Auto-dry"); + } + // ======================================================================== // Endless Spool Control // ======================================================================== @@ -2090,6 +2411,25 @@ class AmsBackend { ///@} public: + /** + * @brief Whether firmware states what filament is in each slot + * + * A DIFFERENT question from has_firmware_spool_persistence(), and conflating + * the two is a real bug: the Snapmaker U1 publishes filament_type and + * filament_vendor per head in `print_task_config` while keeping no Spoolman + * id at all, so it answers false there and true here. + * + * When true, ToolState's persisted assignments must not be pushed back onto + * slots — the firmware already knows, and a cached assignment that disagrees + * is stale by definition. Without this, a head the printer reported as PLA + * displayed PETG, resolved from a spool that was physically in an ACE bay. + * + * @return true if slot material/vendor come from firmware + */ + [[nodiscard]] virtual bool has_firmware_filament_identity() const { + return false; + } + /** * @brief Whether this backend unloads the toolhead automatically after a print * diff --git a/include/ams_backend_ace.h b/include/ams_backend_ace.h index a2328866ce..cef23422cf 100644 --- a/include/ams_backend_ace.h +++ b/include/ams_backend_ace.h @@ -115,6 +115,12 @@ class AmsBackendAce : public AmsSubscriptionBackend { } public: + /// See AmsBackend::filament_ops_may_home(). The ACE dispatches its own + /// `ACE_*` macros directly rather than through ensure_homed_then(), so + /// HelixScreen never emits a G28 for one of its filament ops. + [[nodiscard]] bool filament_ops_may_home() const override { + return false; + } // ======================================================================== // Recovery Operations // ======================================================================== diff --git a/include/ams_backend_mock.h b/include/ams_backend_mock.h index 8195810ef5..e2c58fca15 100644 --- a/include/ams_backend_mock.h +++ b/include/ams_backend_mock.h @@ -490,6 +490,20 @@ class AmsBackendMock : public AmsBackend { */ void set_snapmaker_mode(bool enabled); + /// Simulate a U1 with TWO ACE units, one bound to each of the first two + /// heads. The shape the real rig cannot show on one machine: heads 0 and 1 + /// ACE-fed (so each draws its feeder's four bays), heads 2 and 3 on their + /// stock feeders. Exercises the presentation an ACE-fed position gets — + /// bars, a spool-number range, no tool badge, no Load — without a printer. + void set_multiace_mode(bool enabled); + + /// In multiace mode heads 0 and 1 are fed by ACE 1 and ACE 2, so their + /// spool identity belongs to that unit — exactly as AmsBackendMultiAce + /// reports for the real thing. Every other mode answers nullopt, which is + /// the base behaviour. + [[nodiscard]] std::optional slot_identity_owner_unit(int slot_index) const override; + [[nodiscard]] std::optional slot_identity_owner_slot(int slot_index) const override; + /** * @brief Seed per-tool→slot firmware mappings from a "tool:slot" CSV. * @@ -760,6 +774,11 @@ class AmsBackendMock : public AmsBackend { bool htlf_toolchanger_mode_ = false; ///< Simulate HTLF + Toolchanger mixed topology bool torture_mode_ = false; ///< Simulate 5 units / 16 lanes / 4 shared extruders bool snapmaker_mode_ = false; ///< Simulate Snapmaker U1 (4 slots, PARALLEL, non-editable) + bool multiace_mode_ = false; ///< Simulate a U1 with two ACE units (see set_multiace_mode) + /// Which ACE bay is seated at each U1 head in multiace mode, -1 = none. + /// Drives slot_identity_owner_slot(), so the head and its bay share one + /// spool number instead of each consuming one. + std::array multiace_seated_{{-1, -1, -1, -1}}; std::vector unit_topologies_; ///< Per-unit topology storage // Endless spool simulation state diff --git a/include/ams_backend_multiace.h b/include/ams_backend_multiace.h new file mode 100644 index 0000000000..7e8e38dbd8 --- /dev/null +++ b/include/ams_backend_multiace.h @@ -0,0 +1,409 @@ +// Copyright (C) 2025-2026 356C LLC +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ams_backend_snapmaker.h" + +#include +#include +#include +#include + +/** + * @file ams_backend_multiace.h + * @brief decay71/multiACE — 1-4 Anycubic ACE units bolted onto a Snapmaker U1 + * + * multiACE does not replace the U1's filament handling, it feeds it. The four + * SnapSwap toolheads stay exactly what they were — `print_task_config`, the + * `channel_state` load latch, the motion/port sensors, the native load model — + * and each head is independently either on its stock feeder or fed from one of + * the ACE units. So this derives from AmsBackendSnapmaker rather than forking + * it: unit 0 remains the U1's four heads with all of that behaviour intact, and + * units 1..N are the ACE hardware. + * + * ## What the `ace` Klipper object carries + * + * Everything needed arrives over the WebSocket via MultiAce.get_status(); the + * optional FastAPI service is not required. + * + * - `mode` — "head" (an ACE binds to ONE head and all its slots feed that head) + * or "multi" (slot *s* of every ACE feeds head *s*). This changes the + * slot→head mapping, so per-unit topology genuinely differs by mode. + * - `device_count`, `active_device` + * - `head_ace` / `head_feeder` / `head_manual` — per-head source *kind*, keyed + * by head index as a STRING ("0".."3"), not an int. + * - `head_source[h]` — `{ace_index, slot}` when the head is ACE-fed, else null. + * - `aces[]` — per-unit inventory: connected, temp, humidity, `gate_status[]`, + * `slots[]`, dryer state. + * + * ## Why the dispatch override exists + * + * An ACE-fed head must be driven with `ACE_LOAD_HEAD` / `ACE_UNLOAD_HEAD`, not + * the U1's native path. Sending the native unload to an ACE-fed head does move + * the filament, but it terminates at `preload_finish` because the ACE performs + * the retract — the UI waits for `unload_finish` and hangs on "Unloading" + * forever. Heads on their stock feeder keep the inherited native path. + * + * @see docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md § 4 Phase 2 + * @see tests/fixtures/snapmaker_u1/u1-multiace-head-mode-idle.json + */ +class AmsBackendMultiAce : public AmsBackendSnapmaker { + friend class MultiAceTestAccess; + + public: + AmsBackendMultiAce(IMoonrakerAPI* api, helix::IMoonrakerClient* client); + ~AmsBackendMultiAce() override = default; + + /// Upper bound on ACE units multiACE supports. + static constexpr int MAX_ACE_UNITS = 4; + /// Slots per ACE unit (Pro / Pro 2 are both 4-bay). + static constexpr int ACE_SLOTS_PER_UNIT = 4; + + [[nodiscard]] AmsType get_type() const override { + return AmsType::MULTIACE; + } + + /** + * @brief How a given U1 head is fed. + * + * Mirrors multiACE's own three-way split. UNKNOWN is the pre-first-frame + * answer and must not be confused with FEEDER — before `ace` has been seen + * we do not yet know, and guessing FEEDER would dispatch the native path to + * a head the ACE actually owns. + */ + enum class HeadSource { UNKNOWN, FEEDER, ACE, MANUAL }; + + /// Per-unit topology. The base falls back to the SYSTEM-wide answer, which + /// is PARALLEL here (the U1's four heads) — so without this override an ACE + /// unit in head mode drew as a parallel fan instead of the combiner it + /// physically is. Note `compute_system_tool_layout()` prefers the backend's + /// answer over `AmsUnit::topology`, so populating the struct alone is not + /// enough; it has to be answered here too. + [[nodiscard]] PathTopology get_unit_topology(int unit_index) const override; + + /// An ACE-fed head's spool is described by the ACE, not by the U1 — see the + /// base declaration. Returns the ACE's global unit index for such a head, + /// nullopt for a feeder head or any ACE bay (those describe themselves). + [[nodiscard]] std::optional slot_identity_owner_unit(int slot_index) const override; + + /// The ACE bay behind an ACE-fed head, as a global slot index. Pairs with + /// slot_identity_owner_unit() so the head and its bay share one spool + /// number instead of each consuming one. + [[nodiscard]] std::optional slot_identity_owner_slot(int slot_index) const override; + + /// How head @p head is fed, per the last `ace` frame. + [[nodiscard]] HeadSource head_source_kind(int head) const; + + /// See AmsBackendSnapmaker::preload_finish_ends_unload(). True for an + /// ACE-fed head: `ACE_UNLOAD_HEAD` hands the retract to the ACE, so the + /// U1's channel_state stops at preload_finish and `unload_finish` never + /// arrives. A feeder or manual head still runs the native sequence and + /// keeps the stock answer. + [[nodiscard]] bool preload_finish_ends_unload(int head) const override { + // Reads head_kind_ DIRECTLY. The caller is the channel_state parse, + // which already holds mutex_, and head_source_kind() takes it again -- + // a non-recursive std::mutex, so that self-deadlocked the main thread + // the first time an ACE-fed head reached preload_finish, i.e. on the + // first real unload. The whole UI froze. + return head >= 0 && head < NUM_TOOLS && head_kind_[head] == HeadSource::ACE; + } + + /// See AmsBackend::change_tool_completes_load(). True for the U1's own four + /// heads, whose filament sits at the head already -- `T{n}` really is the + /// whole load there. False for every ACE bay: mounting the head the bay + /// feeds moves the carriage and feeds nothing, and the bay has to be named + /// (`ACE_LOAD_HEAD HEAD=h ACE=a SLOT=s`). + [[nodiscard]] bool change_tool_completes_load(int slot_index) const override { + return slot_index < NUM_TOOLS; + } + + /// An ACE bay, split out of its GLOBAL slot index. + /// + /// Global layout: slots 0..3 are the U1's heads (unit 0), then each ACE + /// contributes ACE_SLOTS_PER_UNIT bays in unit order. `head` is the head + /// THIS BAY feeds — in head mode the one head its ACE is bound to, in multi + /// mode the same-numbered head — or -1 when nothing is bound. It is the same + /// rule that gives the bay its `mapped_tool`, by construction: both go + /// through bay_feeds_head_locked(). They used to be two hand-written copies, + /// and the dispatch copy only knew head mode, so in multi mode a bay's Load + /// either named the wrong head or refused a perfectly valid bay. + struct BaySource { + int ace_index = -1; ///< multiACE's own 0-based device index + int bay = -1; ///< Bay within that ACE + int head = -1; ///< Head this bay feeds, or -1 + }; + /// nullopt when @p slot_index is a head rather than a bay, or out of range. + [[nodiscard]] std::optional bay_source(int slot_index) const; + + /// The U1's own step model, with the unload's last step naming its + /// destination when the head being unloaded is ACE-fed. + /// + /// The filament does not just leave the nozzle, it travels back into the + /// ACE, and "Retract" alone gave no sign of that -- the step sits there for + /// the length of the whole retract, which is most of the operation. + /// + /// A RENAME, not an added step: the firmware drives the step index (phase 3 + /// for unload_doing), so a fifth step would have nothing to advance it and + /// would sit Pending forever. + [[nodiscard]] OperationStepModel get_operation_step_model(StepOperationType op) const override; + + /// A BAY is actively loaded when it is the one currently feeding a head. + /// + /// The base rule is `slot_index == get_current_slot()`, and the current slot + /// is a HEAD (3), so no bay ever matched and a loaded bay drew exactly like + /// its idle neighbours. Answered only for bays: the U1's own heads keep the + /// inherited rule, which is what the SnapSwap page has always shown. + /// Deliberately NOT done by claiming has_per_slot_loaded_authority(), which + /// would switch the heads over to a per-slot latch as a side effect. + [[nodiscard]] bool slot_is_actively_loaded(int slot_index) const override; + + // Every ACE reports temperature and humidity. Answered at the BACKEND level, + // as the interface asks it: unit 0 is the U1 itself and has no sensor, but + // the per-unit `ams_env_ind__visible` subject already hides the indicator + // there. Inherited from AmsBackendSnapmaker this was false, which hard-hid + // the indicator on the unit detail page -- and with it the only route to the + // dryer and auto-dry controls for a drilled-into ACE. + [[nodiscard]] bool has_environment_sensors() const override { + return true; + } + + // Dryer. multiACE exposes parameterised commands -- ACE_DRY ACE=n [TEMP=] + // [DURATION=] and ACE_STOP_DRYING [ACE=n] -- so temperature and duration are + // set live, with no config edit and no Klipper restart. (The ACED__DRY_START_n + // macros in multiACE's macro table are the parameterless Fluidd buttons and + // are NOT what this uses.) + [[nodiscard]] DryerInfo get_dryer_info(int unit = 0) const override; + AmsError start_drying(float temp_c, int duration_min, int fan_pct = -1, int unit = 0) override; + AmsError stop_drying(int unit = 0) override; + + // Humidity-controlled drying. `ACE_SET_AUTO_DRY ACE=n ...` takes each field + // independently, so arming the rule never restates thresholds the user set + // elsewhere. Its full surface, read off multiACE's own web UI rather than + // guessed (see the plan's § "Auto-dry"): + // ACE=n [ENABLE=0|1] [TEMP=35..80] [RH_START=5..95] [RH_END=1..94] + // [MASTER=] [ADD_TIME=0..600] + [[nodiscard]] AutoDryInfo get_auto_dry_info(int unit = 0) const override; + AmsError set_auto_dry_enabled(bool enabled, int unit = 0) override; + + /// Which (unit, slot) is seated at head @p head, or nullopt when the head is + /// not ACE-fed. `unit_index` is the GLOBAL unit index (ace_index + 1), since + /// unit 0 is the U1 itself. + struct SeatedSource { + int unit_index = -1; ///< Global AmsUnit index (ace_index + 1) + int ace_index = -1; ///< multiACE's own 0-based device index + int slot = -1; ///< Slot within that ACE + }; + [[nodiscard]] std::optional seated_source(int head) const; + + protected: + void handle_status_update(const nlohmann::json& notification) override; + const char* backend_log_tag() const override { + return "[AMS multiACE]"; + } + + // ACE-fed heads take the ACE command path; feeder heads fall through to the + // inherited native U1 path. See the class comment. + AmsError do_load_filament(int slot_index) override; + AmsError do_unload_filament(int slot_index) override; + + /// The one network call fetch_slot_overrides() makes, split out so a test + /// can hold the callbacks and complete them in an order of its choosing -- + /// the in-flight/re-issue rule is not observable any other way. Production + /// asks Moonraker's file API for `extended/multiace/slot_overrides.json`. + /// Both callbacks may run on a BACKGROUND thread. + virtual void download_slot_overrides(std::function on_success, + std::function on_error); + + private: + /// Which head ACE @p ace_index actually FEEDS, or -1. Caller must hold mutex_. + /// + /// multiACE's own `aceHeadForAce()` reverse lookup, and it MUST test the + /// head's source kind as well as the index: `head_ace` names an ACE for + /// every head, not just the ones it feeds — a live U1 reports + /// `{0:0, 1:1, 2:2, 3:0}` — so matching on the index alone picks head 0, a + /// stock feeder head, for ACE 0's bays. That is exactly how a bay load was + /// dispatched to the wrong toolhead, and the rule was written out twice. + [[nodiscard]] int head_fed_by_ace_locked(int ace_index) const; + + /// Which head bay @p bay of ACE @p ace_index feeds, or -1. Caller must hold + /// mutex_. THE rule for the slot->head relation, and the only copy of it: + /// rebuild_ace_units_locked() writes it into `mapped_tool`, bay_source() + /// hands it to the dispatchers as `BaySource::head`. + /// + /// Head mode: the one head the ACE is bound to (head_ace, falling back to + /// whatever of this ACE is seated when the wiring map has not arrived). + /// Multi mode: bay s feeds head s -- multiACE's own web UI sends + /// `ACE_LOAD_HEAD HEAD=` there, and its aceHeadForAce() reverse lookup + /// is used ONLY under `state.mode === "head"`. + [[nodiscard]] int bay_feeds_head_locked(int ace_index, int bay) const; + + /// bay_source() without the lock. Caller must hold mutex_. + [[nodiscard]] std::optional bay_source_locked(int slot_index) const; + + /// Parse the `ace` object. Caller must hold mutex_. + void parse_ace_object_locked(const nlohmann::json& ace, bool& changed); + /// Resolve each bay's spool through `spool_binding` -> `spools`. Caller must + /// hold mutex_. See AceUnitState::BaySpool for why this is not `slots[]`. + void parse_spool_table_locked(const nlohmann::json& ace, bool& changed); + + /// Per-bay identity that multiACE keeps in a FILE, not in its Klipper object. + /// + /// `slot_overrides.json` is what multiACE's own web UI resolves from — every + /// bay it reports comes back `source: "override"` — and it covers bays the + /// spool table does not: a bay can carry a material and colour with no spool + /// bound to it at all, which read as empty here until this was added. + /// + /// Fetched over Moonraker's file API rather than multiACE's optional FastAPI + /// service, so it needs nothing installed beyond multiACE itself. + struct BayOverride { + bool set = false; + std::string material; + std::string brand; + std::optional color_rgb; + }; + using OverrideMap = std::array, MAX_ACE_UNITS>; + + /// Pure, and static so it can run on the HTTP thread without touching `this`. + [[nodiscard]] static OverrideMap parse_slot_overrides(const std::string& content); + /// Ask Moonraker for the file. Main thread; applies its result via the + /// lifetime token. + void fetch_slot_overrides(); + /// Rebuild units 1..N from the parsed ACE inventory. Caller must hold mutex_. + void rebuild_ace_units_locked(); + + /// multiACE's `mode`: true when one ACE binds to one head (all its slots + /// feed that head) rather than slot *s* feeding head *s*. + bool head_mode_ = true; + int device_count_ = 0; + + std::array head_kind_{ + {HeadSource::UNKNOWN, HeadSource::UNKNOWN, HeadSource::UNKNOWN, HeadSource::UNKNOWN}}; + /// The two source maps as LAST SEEN, per head, nullopt until a frame has + /// carried that map. Klippy diffs the `ace` object per field, and + /// `head_manual` and `head_feeder` are separate fields: toggling a head + /// between ACE and MANUAL resends `head_manual` alone while `head_feeder`, + /// value-identical, is omitted. Deciding the kind from the frame's maps + /// alone read the absent one as "false for every head" and relabelled the + /// stock-feeder heads ACE-fed. The decision has to be made from the union + /// of what is known, so both maps are cached and re-derived from here. + std::array, NUM_TOOLS> head_manual_{}; + std::array, NUM_TOOLS> head_feeder_{}; + std::array, NUM_TOOLS> head_seated_{}; + /// Which ACE feeds each head, off `head_ace`. Meaningful ONLY once + /// head_kind_ says the head is ACE-fed — head_ace names an ACE for every + /// head, including ones on their stock feeder. Unlike head_seated_ this + /// survives an empty head, so it is what the wiring questions ask. + std::array head_ace_index_{{-1, -1, -1, -1}}; + + /// Per-ACE inventory as last parsed, indexed by multiACE's ace_index. + struct AceUnitState { + bool connected = false; + /// Wire protocol generation ("v1" / "v2") — the only field that says + /// which ACE model this is. See ace_model_name(). + std::string protocol; + int temp = 0; + int humidity = 0; + /// Live dryer state, straight off `aces[].dryer_status`. + bool drying = false; + int dryer_target_c = 0; + int dryer_duration_min = 0; + int dryer_remaining_min = 0; + /// Humidity-controlled drying, off `aces[].auto_dry` and its sibling + /// `auto_dry_running`. `has_auto_dry` is what gates the UI: a unit that + /// has never reported the block has no auto-dry at all, which is a + /// different thing from one reporting it disabled. + bool has_auto_dry = false; + bool auto_dry_enabled = false; + bool auto_dry_running = false; + float auto_dry_rh_start = 0.0f; + float auto_dry_rh_end = 0.0f; + int auto_dry_temp_c = 0; + /// multiACE's own ACE index of the followed unit, or -1 for none. + int auto_dry_master = -1; + int auto_dry_add_time_min = 0; + std::array gate_present{{false, false, false, false}}; + std::array material{}; + /// What the user entered in multiACE, resolved from `spools` through + /// `spool_binding`. This — not `slots[].material` — is where a bay's + /// identity actually lives: the inline fields are only filled from RFID + /// and read empty on every non-RFID spool. + struct BaySpool { + bool bound = false; + std::string material; + std::string vendor; + std::string label; + std::string sku; + int spoolman_id = 0; + std::optional color_rgb; + // No weight here on purpose — Spoolman owns remaining AND total, and + // multiACE's `weight_g` is only a copy of it. See where this is applied. + + // Every field, so a vendor-only edit counts as a change. The + // hand-written comparison this replaces listed five of the seven + // and left `vendor` out -- correcting a spool's brand in multiACE + // updated the cache and told nobody, and the bay kept its old + // brand until something else moved. + bool operator==(const BaySpool& o) const { + return bound == o.bound && material == o.material && vendor == o.vendor && + label == o.label && sku == o.sku && spoolman_id == o.spoolman_id && + color_rgb == o.color_rgb; + } + bool operator!=(const BaySpool& o) const { + return !(*this == o); + } + }; + std::array spool{}; + /// nullopt = the ACE reported no colour, so keep SlotInfo's default + /// rather than painting the slot black. + std::array, ACE_SLOTS_PER_UNIT> color_rgb{}; + + /// Whole-state equality, so the parser can say whether a frame actually + /// moved anything. It used to mark every `aces` entry as changed + /// unconditionally, and Klippy resends the whole array whenever one + /// element ticks -- so a dryer's temperature ramp forced a full + /// AmsState resync and UI rebuild for the length of every dry cycle. + bool operator==(const AceUnitState& o) const { + return connected == o.connected && protocol == o.protocol && temp == o.temp && + humidity == o.humidity && drying == o.drying && + dryer_target_c == o.dryer_target_c && + dryer_duration_min == o.dryer_duration_min && + dryer_remaining_min == o.dryer_remaining_min && has_auto_dry == o.has_auto_dry && + auto_dry_enabled == o.auto_dry_enabled && + auto_dry_running == o.auto_dry_running && + auto_dry_rh_start == o.auto_dry_rh_start && + auto_dry_rh_end == o.auto_dry_rh_end && auto_dry_temp_c == o.auto_dry_temp_c && + auto_dry_master == o.auto_dry_master && + auto_dry_add_time_min == o.auto_dry_add_time_min && + gate_present == o.gate_present && material == o.material && spool == o.spool && + color_rgb == o.color_rgb; + } + bool operator!=(const AceUnitState& o) const { + return !(*this == o); + } + }; + std::array ace_units_{}; + + /// multiACE's spool table, keyed by spool id, and the bay->id bindings. + /// Cached SEPARATELY because the two arrive independently: a frame that + /// moves a spool between bays carries `spool_binding` and no `spools`, so + /// resolving them together lost every detail on the next binding change. + std::map spool_table_; + std::array, MAX_ACE_UNITS> bay_spool_id_{}; + + /// Last fetched contents of slot_overrides.json, and the `ace.event_seq` + /// that fetch was for. The file is not on the WebSocket, so event_seq — which + /// multiACE bumps on its own state changes — is what says it may have moved. + OverrideMap slot_overrides_{}; + int64_t overrides_fetched_seq_ = -1; + bool override_fetch_in_flight_ = false; + /// "A fetch is owed." Set under mutex_ when a frame's event_seq outruns + /// overrides_fetched_seq_, and CONSUMED only by the fetch that actually goes + /// out (fetch_slot_overrides(), which must be called with the lock dropped). + /// While a fetch is in flight the flag stays set, and whichever completion + /// handler lands next re-issues it. It used to be cleared by the caller + /// before the fetch was attempted, so a second event_seq that arrived during + /// the download was recorded as fetched and never was: the pre-edit file was + /// applied and the bay showed a stale material until some unrelated bump. + bool override_refetch_wanted_ = false; +}; diff --git a/include/ams_backend_snapmaker.h b/include/ams_backend_snapmaker.h index be07528278..c0bf4f1ce5 100644 --- a/include/ams_backend_snapmaker.h +++ b/include/ams_backend_snapmaker.h @@ -71,6 +71,22 @@ class AmsBackendSnapmaker : public AmsSubscriptionBackend { ~AmsBackendSnapmaker() override; + /// The U1's four heads are independent toolheads, each drawn and labelled + /// below its slot — so a "T3" badge on the spool repeats what the toolhead + /// already says, and did it twice on an ACE-fed head whose spool is a range. + /// Same reasoning AmsBackendToolChanger gives; the U1 simply never opted in. + [[nodiscard]] bool should_hide_slot_tool_badge() const override { + return true; + } + + /// `print_task_config` carries filament_type and filament_vendor per head, + /// so the printer states what is loaded even though it stores no Spoolman + /// id (has_firmware_spool_persistence() is separately false). Inherited by + /// AmsBackendMultiAce, where the ACE's own table covers its bays. + [[nodiscard]] bool has_firmware_filament_identity() const override { + return true; + } + [[nodiscard]] AmsType get_type() const override { return AmsType::SNAPMAKER; } @@ -141,19 +157,51 @@ class AmsBackendSnapmaker : public AmsSubscriptionBackend { } public: + /// See AmsBackend::filament_ops_may_home(). do_load_filament() dispatches + /// `AUTO_FEEDING ... LOAD=1` (firmware's FEED_AUTO) and do_unload_filament() + /// its UNLOAD counterpart, both straight to firmware — neither routes + /// through ensure_homed_then(), and FEED_AUTO feeds without moving the + /// toolhead, so there is no unhomed axis for it to trip over. Inherited by + /// AmsBackendMultiAce, whose ACE-fed heads dispatch `ACE_LOAD_HEAD` the + /// same way. + [[nodiscard]] bool filament_ops_may_home() const override { + return false; + } // The base PARALLEL gate offers Unload for any tool with filament in its // buffer (is_present()). On the U1 that keeps offering Unload after a tool // is already unloaded — the firmware retracts the filament to the buffer // (channel_state preload_finish/unload_finish) but filament_exist stays - // true, so the slot remains AVAILABLE. Override to additionally require the - // channel_state load latch (loaded_at_toolhead_), which is true only while - // filament is loaded at the toolhead (between load_finish and the next - // unload_finish). The motion sensor was tried first but fails to clear after - // an unload on current firmware; channel_state is the authoritative signal - // (u1_channel_state_reference.md). Still offers Unload for every toolhead - // physically loaded (active or parked), preserving the per-tool unload fix. + // true, so the slot remains AVAILABLE. Override to ask three signals in + // turn, any one of which says "filament is AT this toolhead" + // (filament_present_at_tool_locked()): + // 1. the channel_state load latch (loaded_at_toolhead_) — sufficient, but + // derived from a transition, so a restart leaves it false with filament + // sitting in the head; gating on it alone made that filament + // unremovable from the panel; + // 2. motion AND port sensor both present, unless a retraction has been + // witnessed since (retraction_seen_ — the 20260608 firmware left the + // motion sensor true after an unload; newer firmware clears it); + // 3. the MOUNTED head only: a spool in its channel (filament_exist), again + // unless a retraction has been witnessed. + // Signals 2 and 3 are what let filament be removed after a restart. The + // cost, accepted knowingly: on 20260608 firmware a head unloaded BEFORE a + // restart still reads loaded (both sensors stay true, retraction_seen_ is + // process-local), so Unload is offered once for a head with nothing at the + // nozzle — a pointless heat-and-retract, not a wedge. Still offers Unload + // for every toolhead physically loaded (active or parked), preserving the + // per-tool unload fix. [[nodiscard]] bool can_unload_from_toolhead(int slot_index) const override; + // The U1 is a toolchanger: `PARK_EXTRUDER` returns the mounted head to its + // dock and leaves the filament where it is. It is firmware-native and ships + // with no description, so it does NOT appear in `gcode/help` — verified + // instead by its use in the machine's own `print_end` macro, bare and + // parameterless, right after the auto-unload. + [[nodiscard]] bool supports_toolhead_park() const override { + return true; + } + AmsError do_park_toolhead() override; + // Recovery (not supported) AmsError recover() override; AmsError reset() override; @@ -238,7 +286,18 @@ class AmsBackendSnapmaker : public AmsSubscriptionBackend { const std::map& remap) const override; // Static parsers (public for testing) - static ExtruderToolState parse_extruder_state(const nlohmann::json& json); + /// Parse one extruder object into tool state. + /// + /// @param json The (possibly PARTIAL) extruder object from a status frame. + /// @param prev State to start from. Moonraker sends deltas, and an extruder + /// emits `temperature` constantly, so most frames carry ONLY + /// that — with a default-constructed start those frames erased + /// state/park_pin/active_pin a fraction of a second after a + /// toolchange set them, and the mounted tool stopped being + /// elected. Seed with the previous state so absent keys are + /// preserved rather than reset. + static ExtruderToolState parse_extruder_state(const nlohmann::json& json, + ExtruderToolState prev = {}); static SnapmakerRfidInfo parse_rfid_info(const nlohmann::json& json); protected: @@ -248,13 +307,64 @@ class AmsBackendSnapmaker : public AmsSubscriptionBackend { return "[AMS Snapmaker]"; } + /// Is `preload_finish` the LAST channel_state an unload of head @p head + /// will produce? + /// + /// On a stock feeder head it is not: an unload passes through + /// preload_finish (lane staged in the buffer) while the nozzle is still + /// heating, and resolving the action to IDLE there killed the step display + /// mid-heat (#u1-unload-steps). Only `unload_finish` ends it, so the parse + /// deliberately excludes preload_finish from the terminal branch. + /// + /// On an ACE-fed head there is no `unload_finish` to wait for. The ACE + /// performs the retract, the U1's own sequence runs + /// unload_picking → unload_heating → unload_doing → preload_finish and + /// stops, and the action stayed UNLOADING forever — which reads as "system + /// busy" and greys out Load/Unload on every slot of every unit, including + /// the ACE's own bays. Observed on hardware: `ACE_UNLOAD_HEAD HEAD=3` + /// dispatched correctly, the ACE came back `status: ready` with the bay + /// empty, and the UI sat on Unloading for 20 minutes. + /// + /// Default false — the stock behaviour, unchanged. AmsBackendMultiAce + /// answers true for heads it knows are ACE-fed. + /// + /// @warning CALLED WITH mutex_ HELD, from inside the channel_state parse. + /// An override MUST read state directly and MUST NOT call a public accessor + /// that locks — mutex_ is a plain std::mutex, so re-entering it deadlocks + /// the calling thread outright. That thread is the main one (the parse runs + /// from UpdateQueue::process_pending), so the whole UI freezes. This hook + /// only fires when a head reports preload_finish, which is the end of a + /// real unload — so the first override to get it wrong froze on hardware + /// and in no test. + [[nodiscard]] virtual bool preload_finish_ends_unload(int head) const { + (void)head; + return false; + } + + /// The U1's fixed head count. Protected rather than private because + /// AmsBackendMultiAce derives from this backend and indexes the same four + /// heads; it is a compile-time constant of the hardware, not mutable state. + static constexpr int NUM_TOOLS = 4; + + /// Validate slot index is within range. Protected for the same reason as + /// NUM_TOOLS — AmsBackendMultiAce overrides do_load_filament/ + /// do_unload_filament and must apply the identical bounds check before + /// deciding whether the ACE or the native path owns the head. + AmsError validate_slot_index(int slot_index) const; + + /// Layer the user's configured override over a slot. Protected because + /// AmsBackendMultiAce builds its ACE units AFTER this class's convergence + /// point has run, so those slots would otherwise never see the override + /// layer — a spool assigned to an ACE bay was written and then discarded. + void apply_overrides_for(SlotInfo& slot, int slot_index) { + apply_overrides(slot, slot_index); + } + private: friend class ::SnapmakerTestAccess; friend class ::SnapmakerRealtimeTestAccess; friend class ::RunoutScopeTestAccess; - static constexpr int NUM_TOOLS = 4; - /// Per-extruder cached state std::array extruder_states_; @@ -269,6 +379,19 @@ class AmsBackendSnapmaker : public AmsSubscriptionBackend { /// spool→toolhead line when the active tool has run out. std::array sensor_filament_present_{{true, true, true, true}}; + /// What `print_task_config.filament_type` last said for each head. + /// + /// Needed to tell a real material EDIT from a side effect of binding a + /// Spoolman spool: apply_spool_to_slot() writes the spool's material into + /// SlotInfo, and set_slot_info(persist=true) then stamped + /// user_locked_material from "is the material non-empty", so simply linking + /// a spool silently locked a material against the printer's own report — + /// which apply_overrides then replayed forever. Compared against here so a + /// bind that agrees with firmware locks nothing. Mirrors the AD5X IFS + /// backend's last_firmware_color_ guard for the same class of self-inflicted + /// lock (#965). + std::array last_firmware_material_{}; + /// Per-slot port/buffer sensor state — the filament_feed left/right /// .extruder{N}.filament_detected flag. Reads the physical-presence /// sensor at the spool/buffer side, NOT the encoder-based motion sensor. @@ -302,10 +425,24 @@ class AmsBackendSnapmaker : public AmsSubscriptionBackend { /// slot_has_filament_at_toolhead() and can_unload_from_toolhead(). The /// motion sensor (sensor_filament_present_) still owns mid-print runout — /// a different question ("did the ACTIVE lane run out during extrusion"). - std::array loaded_at_toolhead_{{false, false, false, false}}; + /// Set when the firmware POSITIVELY reported a retraction for this tool -- + /// the terminal `unload_finish` / `preload_finish` states, never the idle + /// `wait_insert`. Cleared on load_finish and when the port sensor drops. + /// + /// Exists so the presence sensors can be believed by default while still + /// suppressing the old-firmware quirk (captured on U1 20260608) where the + /// motion sensor stayed true after an unload. On 20260722+ the sensors clear + /// themselves, so this is normally inert. + std::array retraction_seen_{{false, false, false, false}}; + + /// "Filament is in this toolhead" -- the gate for Unload being offered and + /// for a slot reading LOADED. Any of three signals, in descending strength: + /// the witnessed-load latch; both presence sensors agreeing; or, for the + /// MOUNTED tool only, a spool in its channel (filament_exist). The last two + /// yield to a witnessed retraction. Caller must hold mutex_. + [[nodiscard]] bool filament_present_at_tool_locked(int slot_index) const; - /// Validate slot index is within range - AmsError validate_slot_index(int slot_index) const; + std::array loaded_at_toolhead_{{false, false, false, false}}; /// Layer a configured FilamentSlotOverride for `slot_index` over `slot`, /// mutating `slot` in place. Override wins for every non-default field. diff --git a/include/ams_step_operation.h b/include/ams_step_operation.h index 5e1a00c3ca..9da6ede24c 100644 --- a/include/ams_step_operation.h +++ b/include/ams_step_operation.h @@ -35,6 +35,63 @@ struct StepOperationResult { int jump_to_step = -1; ///< If >= 0, jump stepper to this step after creation }; +/** + * @brief Whether the operation on screen is one this UI started. + * + * Was inferred from `target_load_slot_ < 0`, which conflated "no slot" with + * "not ours" and got the answer wrong in the one case that matters. The + * sidebar cleared that field whenever the action was not a running one — but + * start_operation() optimistically sets HEATING and the backend's still-IDLE + * truth lands on top of it before the firmware picks the op up. That pre-start + * lag looks identical to completion, so a UI-initiated unload was declared + * foreign mid-flight and detect_step_operation() re-read it as the unload half + * of a swap, replacing the 4-step bar with the 5-step load one. + * + * The fix is to tell those two apart, which needs one extra bit: has the + * backend confirmed the operation actually started? A non-running action only + * means "finished" AFTER a running one has been seen. Before that it means + * "not started yet", and ownership must survive it. + * + * Deterministic — no timers, no debounce on a transient whose length is a + * property of the printer. + */ +struct OperationOwnership { + bool ui_initiated = false; ///< start_operation() was called for this op + bool progress_seen = false; ///< ...and the backend has since reported it running + + /// This UI just dispatched an operation. + void on_start() { + ui_initiated = true; + progress_seen = false; + } + + /// @param action_is_progress the AMS action names a running operation. + void on_action(bool action_is_progress) { + if (action_is_progress) { + progress_seen = true; + return; + } + // Idle AFTER running = finished, so release ownership and let the next + // externally-started operation be detected as one. Idle BEFORE running + // is the pre-start lag above — keep it. + if (progress_seen) { + ui_initiated = false; + progress_seen = false; + } + } + + /// The dispatch never reached the printer (refused, or it threw): there is + /// no operation to own, and no running action will ever arrive to end it. + void on_abandon() { + ui_initiated = false; + progress_seen = false; + } + + [[nodiscard]] bool is_external() const { + return !ui_initiated; + } +}; + /** * @brief Detect which step operation type to show based on action transitions * @@ -58,6 +115,28 @@ inline StepOperationResult detect_step_operation(AmsAction action, AmsAction pre action == AmsAction::FORMING_TIP || action == AmsAction::UNLOADING || action == AmsAction::LOADING); + // An explicit UNLOAD that is currently unloading is never reinterpreted. + // + // Everything below guesses at an operation nobody told us about, and the + // guess is only safe while there is nothing better to go on. Here there is: + // the caller already built an UNLOAD bar. Without this, an ordinary Unload + // press landed in the swap arm below — UNLOADING with filament loaded reads + // as "the unload half of a swap" — and the 4-step unload bar was rebuilt as + // the 5-step load one, parked on "Feed filament" for the rest of the + // operation. + // + // `is_external` cannot be trusted to exclude it: it means "target_load_slot_ + // < 0", and the caller clears that on any non-progress action, so a single + // transient IDLE mid-unload makes the UI's own operation look foreign. That + // same transient is what puts prev_action at IDLE, so both conditions of the + // arm below are met by a UI-initiated unload. + // + // A real swap still arrives: LOADING while current_op is UNLOAD hits the + // upgrade arm at the bottom, which is the designed route for exactly that. + if (current_op == StepOperationType::UNLOAD && action == AmsAction::UNLOADING) { + return result; // no change — keep the unload bar + } + // External operation just started (transitioned from IDLE to any active action) if (is_external && is_active_action && prev_action == AmsAction::IDLE) { result.should_recreate = true; diff --git a/include/ams_subscription_backend.h b/include/ams_subscription_backend.h index f1512f2a91..9780e41f6e 100644 --- a/include/ams_subscription_backend.h +++ b/include/ams_subscription_backend.h @@ -68,6 +68,13 @@ class AmsSubscriptionBackend : public AmsBackend { AmsError unload_filament(int slot_index) final; AmsError select_slot(int slot_index) final; AmsError change_tool(int tool_number) final; + /// Parking moves the CARRIAGE, so it belongs in the same gate as the rest. + /// It was a plain virtual whose only enforcement was a @warning telling each + /// implementer to hand-write check_preconditions(true) — the exact opt-in + /// shape this NVI set exists to abolish, and which had already shipped one + /// backend with no gate at all. It also skipped the in-flight claim, so a + /// park could dispatch while a load was mid-flight. + AmsError park_toolhead() final; // --- Shared utilities (public for AmsState and tests) --- void emit_event(const std::string& event, const std::string& data = ""); @@ -135,6 +142,14 @@ class AmsSubscriptionBackend : public AmsBackend { home_preconfirmed_ = false; } + /// See AmsBackend::filament_ops_may_home(). True here because this class is + /// where ensure_homed_then() lives and every subclass reaches it by default; + /// the two that dispatch their filament ops straight to firmware instead + /// (Snapmaker, ACE) override it back to false. + [[nodiscard]] bool filament_ops_may_home() const override { + return true; + } + protected: // --- Hooks for derived classes --- @@ -172,6 +187,24 @@ class AmsSubscriptionBackend : public AmsBackend { /// Handle incoming Moonraker status notification. Called from background thread. virtual void handle_status_update(const nlohmann::json& notification) = 0; + /// The status object inside a Moonraker notification, or nullptr. + /// + /// notify_status_update arrives as `{"method":..., "params":[{...}, ts]}`, + /// while the initial query response is the bare status object; both must + /// parse. THE unwrapping, in one place: five backends had spelled it out by + /// hand, one of them noting "it must match exactly" what its base class + /// did one call earlier -- the exact fork that would have had the U1 half + /// of a multiACE frame parse and the ACE half silently go inert. + [[nodiscard]] static const nlohmann::json* + unwrap_status_notification(const nlohmann::json& notification) { + const nlohmann::json* status = ¬ification; + if (notification.contains("params") && notification["params"].is_array() && + !notification["params"].empty()) { + status = ¬ification["params"][0]; + } + return status->is_object() ? status : nullptr; + } + /// Return log tag like "[AMS AFC]" for log messages. virtual const char* backend_log_tag() const = 0; @@ -194,6 +227,11 @@ class AmsSubscriptionBackend : public AmsBackend { virtual AmsError do_unload_filament(int slot_index) = 0; virtual AmsError do_select_slot(int slot_index) = 0; virtual AmsError do_change_tool(int tool_number) = 0; + /// Only reached when supports_toolhead_park() is true; the default refuses + /// so a backend that does not park needs no override. + virtual AmsError do_park_toolhead() { + return AmsErrorHelper::not_supported("Toolhead park"); + } /// Does a slot SELECT move the toolhead on this backend? /// @@ -256,7 +294,7 @@ class AmsSubscriptionBackend : public AmsBackend { private: /// The four gated operations, so motion can be classified per METHOD in one /// place instead of per backend at every call site. - enum class FilamentOp { Load, Unload, SelectSlot, ChangeTool }; + enum class FilamentOp { Load, Unload, SelectSlot, ChangeTool, Park }; /// Motion classification, stated ONCE for every backend. [[nodiscard]] bool op_moves_toolhead(FilamentOp op) const; diff --git a/include/ams_types.h b/include/ams_types.h index 64bbb27e38..6fd701aa63 100644 --- a/include/ams_types.h +++ b/include/ams_types.h @@ -48,7 +48,12 @@ enum class AmsType { AD5X_IFS = 5, ///< FlashForge AD5X IFS (Intelligent Filament Switching) CFS = 6, ///< Creality Filament System (K2 series, RS-485) SNAPMAKER = 7, ///< Snapmaker U1 SnapSwap toolchanger - QIDI_BOX = 8 ///< QIDI Box filament changer (PLUS4, Q2, MAX4 — hub AMS, 4 slots chainable to 16) + QIDI_BOX = 8, ///< QIDI Box filament changer (PLUS4, Q2, MAX4 — hub AMS, 4 slots chainable to 16) + /// multiACE (decay71/multiACE): 1-4 Anycubic ACE Pro / ACE 2 units bolted onto a + /// Snapmaker U1's four toolheads. Registers a Klipper object literally named `ace`, + /// which is ALSO the community Anycubic driver's name — see PrinterDiscovery for how + /// the two are told apart. Superset of SNAPMAKER: the U1's own heads plus the ACE units. + MULTIACE = 9 }; /** @@ -74,6 +79,8 @@ inline const char* ams_type_to_string(AmsType type) { return "Snapmaker"; case AmsType::QIDI_BOX: return "QIDI Box"; // i18n: do not translate - product name + case AmsType::MULTIACE: + return "multiACE"; // i18n: do not translate - product name default: return "None"; } @@ -129,7 +136,11 @@ inline AmsType ams_type_from_string(std::string_view str) { * @return true if this is a physical tool changer */ inline bool is_tool_changer(AmsType type) { - return type == AmsType::TOOL_CHANGER || type == AmsType::SNAPMAKER; + // MULTIACE is a Snapmaker U1 with ACE units bolted on — still four physical + // toolheads, so every tool-changer behaviour applies exactly as it does to + // SNAPMAKER. + return type == AmsType::TOOL_CHANGER || type == AmsType::SNAPMAKER || + type == AmsType::MULTIACE; } /** @@ -145,7 +156,7 @@ inline bool is_tool_changer(AmsType type) { inline bool is_filament_system(AmsType type) { return type == AmsType::HAPPY_HARE || type == AmsType::AFC || type == AmsType::ACE || type == AmsType::AD5X_IFS || type == AmsType::CFS || type == AmsType::SNAPMAKER || - type == AmsType::QIDI_BOX; + type == AmsType::QIDI_BOX || type == AmsType::MULTIACE; } /** @@ -1517,6 +1528,40 @@ struct DryerInfo { } }; +/** + * @brief Humidity-controlled ("auto") drying — the standing rule, not the run + * + * A dryer that arms itself off a humidity reading instead of being started by + * hand. Deliberately separate from DryerInfo: that one describes the cycle + * currently running (target, remaining, progress), this one describes the rule + * that starts a cycle. Both are live at once while the rule has the heater on, + * which is what `running` distinguishes from a manual Start. + * + * A unit with no humidity sensor of its own cannot evaluate a threshold, so it + * FOLLOWS another unit's cycle instead. Such a unit stays unarmable until a + * master is picked — which is why can_enable() is not simply `supported`. + */ +struct AutoDryInfo { + bool supported = false; ///< Does this unit expose humidity-controlled drying? + bool enabled = false; ///< Is the rule armed? + bool running = false; ///< Is the cycle running right now this rule's doing? + + // Thresholds. Only meaningful on a unit that measures its own humidity. + float rh_start_pct = 0.0f; ///< Arm the dryer at or above this %RH + float rh_end_pct = 0.0f; ///< Stop it below this %RH (always < rh_start_pct) + int temp_c = 0; ///< Temperature the rule dries at + + // Follower units only. + bool follows_master = false; ///< No humidity sensor — mirrors another unit + int master_unit = -1; ///< AMS unit index it follows (-1 = none picked) + int add_time_min = 0; ///< Minutes it keeps running past the master + + /// A follower with no master picked cannot be armed — firmware refuses it. + [[nodiscard]] bool can_enable() const { + return supported && (!follows_master || master_unit >= 0); + } +}; + /** * @brief Get default drying presets * diff --git a/include/filament_op_dispatch.h b/include/filament_op_dispatch.h index ed4fea1f01..f9b40c47c6 100644 --- a/include/filament_op_dispatch.h +++ b/include/filament_op_dispatch.h @@ -63,6 +63,9 @@ struct BackendCaps { /// direct-fed lane and a hub-routed one get different answers. bool needs_unload_before_load = false; bool is_tool_changer = false; ///< get_type() == AmsType::TOOL_CHANGER + /// AmsBackend::change_tool_completes_load() — answered for the SAME slot the + /// plan targets, because one backend can answer differently per lane. + bool change_tool_completes_load = true; }; /** @@ -118,7 +121,13 @@ struct BackendCaps { // one command per user action and lets the firmware refuse (#1229). An // unasked-for eject is the harm that rule exists to prevent, and it is // exactly what the old arm did. - if (caps.needs_unload_before_load && sys.current_slot != target_slot) { + // ...and only where that tool change IS the load. Where it is merely the + // first half (a multiACE ACE bay: `T3` mounts the head, `ACE_LOAD_HEAD + // HEAD=h ACE=a SLOT=s` feeds it), fall through to the plain load below + // and let the backend emit the whole sequence -- the same answer QIDI + // already relies on. + if (caps.needs_unload_before_load && caps.change_tool_completes_load && + sys.current_slot != target_slot) { const SlotInfo* slot_info = sys.get_slot_global(target_slot); if (slot_info && slot_info->mapped_tool >= 0) { return {FilamentTier::AmsBackend, FilamentRefusal::None, AmsCall::ChangeTool, diff --git a/include/filament_slot_override_store.h b/include/filament_slot_override_store.h index 2e54fe5906..dbaa0b0780 100644 --- a/include/filament_slot_override_store.h +++ b/include/filament_slot_override_store.h @@ -269,6 +269,14 @@ struct MergeOptions { int suppress_rebind_firmware_old_id = 0; /// The just-written id (see suppress_rebind_firmware_old_id). 0 = none. int suppress_rebind_firmware_new_id = 0; + /// True on backends whose firmware NAMES the filament in each slot + /// (AmsBackend::has_firmware_filament_identity) — the U1 states + /// filament_type per head in print_task_config. There the override may only + /// replace a material firmware actually named when the user explicitly + /// locked one; otherwise firmware wins, because it is the truth about what + /// is physically loaded. Everywhere else the override is the only source of + /// a material and takes the field unconditionally, as before. + bool firmware_states_material = false; }; struct MergeResult { diff --git a/include/keyboard_layout_provider.h b/include/keyboard_layout_provider.h index 6bf2e1831a..12fc81fd84 100644 --- a/include/keyboard_layout_provider.h +++ b/include/keyboard_layout_provider.h @@ -22,8 +22,9 @@ enum keyboard_layout_mode_t { KEYBOARD_LAYOUT_ALPHA_LC, ///< Lowercase alphabet KEYBOARD_LAYOUT_ALPHA_UC, ///< Uppercase alphabet - KEYBOARD_LAYOUT_NUMBERS_SYMBOLS, ///< Numbers and symbols - KEYBOARD_LAYOUT_ALT_SYMBOLS ///< Alternative symbols (#+= mode) + KEYBOARD_LAYOUT_NUMBERS_SYMBOLS, ///< Numbers and symbols (the ?123 page) + KEYBOARD_LAYOUT_ALT_SYMBOLS, ///< Alternative symbols (#+= mode) + KEYBOARD_LAYOUT_NUMERIC ///< Digits only — for keyboard_hint="numeric" fields }; /** diff --git a/include/printer_discovery.h b/include/printer_discovery.h index 39fe485935..26ae5da681 100644 --- a/include/printer_discovery.h +++ b/include/printer_discovery.h @@ -311,13 +311,32 @@ class PrinterDiscovery { else if (name == "ace" || name == "filament_hub" || name.rfind("ace_instance", 0) == 0) { ace_object_names_.push_back(name); - if (!has_mmu_) { + // `filament_hub` and `ace_instance_N` are unambiguously Anycubic, so + // they claim the type here as before. The bare name `ace` is NOT: + // decay71/multiACE registers its own Klipper object under exactly + // that name on a Snapmaker U1, and its schema is unrelated + // (aces[].slots[] rather than a top-level slots[]). Claiming it as + // Anycubic points AmsBackendAce at a payload it cannot read, and it + // then falls through to a /server/ace/* REST bridge multiACE does + // not serve — an empty panel plus a "bridge not found" warning. + // Defer the bare-`ace` decision to finalize_ams_detection(), which + // can see the whole object list at once. + if (name == "ace") { + has_bare_ace_object_ = true; + } else if (!has_mmu_) { has_mmu_ = true; mmu_type_ = AmsType::ACE; spdlog::info( "[PrinterDiscovery] Detected ACE (Anycubic ACE Pro) via '{}' object", name); } } + // multiACE ships these two extras alongside its `ace` object and the + // Anycubic stacks have no equivalent, so either one is a positive + // multiACE marker. Both are always installed by multiACE's own + // installer ("always shipped so opting in is a config-only step"). + else if (name == "ace_bg_swap" || name == "ace_tipform") { + has_multiace_markers_ = true; + } // MMU encoder discovery (Happy Hare) else if (name.rfind("mmu_encoder ", 0) == 0) { std::string encoder_name = name.substr(12); // Remove "mmu_encoder " prefix @@ -535,6 +554,37 @@ class PrinterDiscovery { // Collect all detected AMS systems detected_ams_systems_.clear(); + // Resolve the ambiguous bare `ace` object now that the whole object list + // has been seen. Two stacks register that exact name: + // + // Anycubic community drivers (ValgACE/BunnyACE/DuckACE) — a real ACE hub + // decay71/multiACE — 1-4 ACE units on a Snapmaker U1 + // + // Either multiACE marker (`ace_bg_swap` / `ace_tipform`), or the U1's own + // firmware signature (`filament_detect`, which no Anycubic printer has), + // identifies it as multiACE. Until AmsBackendMultiAce exists, fall through + // rather than claim it: the U1's native Snapmaker backend reads the same + // four heads correctly from print_task_config, whereas AmsBackendAce reads + // a schema multiACE does not publish and ends up with nothing at all. + if (has_bare_ace_object_ && !has_mmu_) { + if (has_multiace_markers_ || has_snapmaker_) { + // AmsBackendMultiAce derives from the Snapmaker backend, so the + // U1's four heads keep every bit of their native handling and + // the ACE units are added alongside as units 1..N. Claiming the + // type here is what routes `ace` into that backend at all; until + // it existed the only safe answer was to fall through. + has_mmu_ = true; + mmu_type_ = AmsType::MULTIACE; + spdlog::info("[PrinterDiscovery] Detected multiACE (markers={}, snapmaker={}) — " + "U1 heads plus ACE units", + has_multiace_markers_, has_snapmaker_); + } else { + has_mmu_ = true; + mmu_type_ = AmsType::ACE; + spdlog::info("[PrinterDiscovery] Detected ACE (Anycubic ACE Pro) via 'ace' object"); + } + } + // Register the filament management backend. When a real MMU (AFC, Happy // Hare, etc.) is present, it always wins — even on Snapmaker U1 hardware // that also reports filament_detect. The Snapmaker backend is a basic @@ -554,6 +604,14 @@ class PrinterDiscovery { } else if (mmu_type_ == AmsType::QIDI_BOX) { // i18n: do not translate - product name detected_ams_systems_.push_back({AmsType::QIDI_BOX, "QIDI Box"}); + } else if (mmu_type_ == AmsType::MULTIACE) { + // multiACE claims has_mmu_, which means the has_snapmaker_ + // fallback below is skipped — so this arm is not optional. Its + // absence registered NO system at all and left AmsState with no + // backend, i.e. an empty multi-filament panel on a U1 that had + // been working a moment earlier. + // i18n: do not translate - product name + detected_ams_systems_.push_back({AmsType::MULTIACE, "multiACE"}); } } else if (has_snapmaker_) { // Native Snapmaker filament system (no aftermarket MMU) @@ -1429,6 +1487,11 @@ class PrinterDiscovery { bool has_speaker_ = false; bool has_fan_feedback_ = false; bool is_kalico_ = false; + /// Set when a Klipper object named exactly `ace` was seen. Ambiguous on its + /// own — resolved in finalize_ams_detection() using has_multiace_markers_. + bool has_bare_ace_object_ = false; + /// Set by `ace_bg_swap` / `ace_tipform`, which only multiACE registers. + bool has_multiace_markers_ = false; AmsType mmu_type_ = AmsType::NONE; std::vector detected_ams_systems_; diff --git a/include/ui/ams_drawing_utils.h b/include/ui/ams_drawing_utils.h index c217b4f45d..6d69ee7fbe 100644 --- a/include/ui/ams_drawing_utils.h +++ b/include/ui/ams_drawing_utils.h @@ -46,8 +46,19 @@ SlotError::Severity worst_unit_severity(const AmsUnit& unit); // Data Helpers // ============================================================================ +/** + * Minimum rendered fill for a lane that HAS an assigned spool. + * + * display_fill_level() is 0 for anything not present, and every spool visual + * sizes its coloured ring by fill — so without a floor, a lane you just + * assigned a spool to renders as bare grey chrome with no hint of its colour. + * Shared so the mini-status strip, the overview bars and the slot widget all + * agree on what "assigned but empty" looks like. + */ +constexpr int SPOOL_ASSIGNED_MIN_FILL_PCT = 5; + /** Calculate fill percentage from SlotInfo weight data (returns min_pct..100, or 100 if unknown) */ -int fill_percent_from_slot(const SlotInfo& slot, int min_pct = 5); +int fill_percent_from_slot(const SlotInfo& slot, int min_pct = SPOOL_ASSIGNED_MIN_FILL_PCT); /** * Calculate bar width to fit slot_count bars in container_width. @@ -180,6 +191,15 @@ struct BarStyleParams { constexpr int32_t STATUS_LINE_HEIGHT_PX = 3; constexpr int32_t STATUS_LINE_GAP_PX = 2; +/// Mini slot-bar geometry. Shared because two surfaces draw the same bars: the +/// overview's unit cards, and the slot that stands for a position fed from +/// another unit (an ACE-fed head shows its ACE's bays rather than one spool). +/// Keeping them here is what stops the two drifting into near-identical. +constexpr int32_t MINI_BAR_MIN_WIDTH_PX = 6; ///< below this a bar is invisible +constexpr int32_t MINI_BAR_MAX_WIDTH_PX = 14; +constexpr int32_t MINI_BAR_HEIGHT_PX = 40; ///< decorative; no responsive scaling +constexpr int32_t MINI_BAR_RADIUS_PX = 4; + /** Create slot column: bar_bg (with bar_fill child) + status_line in a column flex container */ SlotColumn create_slot_column(lv_obj_t* parent, int32_t bar_width, int32_t bar_height, int32_t bar_radius); @@ -218,6 +238,15 @@ struct UnitToolLayout { int min_virtual_tool = -1; ///< Minimum mapped_tool value (for labeling) int hub_tool_label = -1; ///< Override label for HUB units (from extruder index, -1 = use min_virtual_tool) + /// Bit t = this unit actually SUPPLIES physical nozzle first_physical_tool + t. + /// A unit can own a nozzle it does not feed: a toolchanger head fed by an + /// MMU (an ACE-fed U1 head) belongs to the head's unit, but its slot's + /// identity is owned by the MMU -- AmsBackend::slot_identity_owner_unit(). + /// Cleared for those, so the canvas draws no supply line from the owner to + /// filament it is not supplying. Computed HERE, once, from the same walk + /// that placed the nozzles: the overview used to re-derive it inline from + /// the same data with a per-(tool, slot) backend query. + uint32_t feeds_mask = ~0u; /// Extruder this unit's single nozzle belongs to, as an opaque name. Set /// only for one-nozzle units whose lanes all agree; empty otherwise. Two /// units naming the same extruder feed one nozzle — that is string diff --git a/include/ui_ams_context_menu.h b/include/ui_ams_context_menu.h index 0410891f2a..ae46f6f641 100644 --- a/include/ui_ams_context_menu.h +++ b/include/ui_ams_context_menu.h @@ -57,7 +57,14 @@ class AmsContextMenu : public ContextMenu { /// nothing else. using BackupEligibleFn = std::function; - /// Init and publish the two XML subjects this menu's layout binds. Idempotent, + /// The spool number a slot's badge shows, which is not slot+1 once a unit + /// re-uses another's identity: on multiACE an ACE-fed head and the ACE bay + /// behind it share one number. Injected for the same reason as + /// BackupEligibleFn — always AmsBackend::spool_display_number() in + /// production, a parameter only so the builder stays testable. + using SlotDisplayNumberFn = std::function; + + /// Init and publish the XML subjects this menu's layout binds. Idempotent, /// and called from the constructor, so production never needs it. Public for /// tests that build ams_context_menu.xml without a menu instance: the names /// must resolve before lv_xml_create(), or the state bindings are silently @@ -78,6 +85,7 @@ class AmsContextMenu : public ContextMenu { EDIT, ///< Edit slot properties CLEAR_SPOOL, ///< Clear assigned spool from empty slot SPOOLMAN, ///< Assign Spoolman spool + OPEN_SOURCE_UNIT, ///< Jump to the unit that owns this slot's spool identity SCAN_QR ///< Scan QR code to assign spool }; @@ -90,9 +98,12 @@ class AmsContextMenu : public ContextMenu { AmsContextMenu(const AmsContextMenu&) = delete; AmsContextMenu& operator=(const AmsContextMenu&) = delete; - // Movable - AmsContextMenu(AmsContextMenu&& other) noexcept; - AmsContextMenu& operator=(AmsContextMenu&& other) noexcept; + // Non-movable. Every owner (AmsPanel, AmsOverviewPanel, the external-spool + // menu) holds this in a unique_ptr and nothing moves it; the move bodies + // existed only to hand per-instance subjects from one object to another, + // and the subjects are the class's now. + AmsContextMenu(AmsContextMenu&&) = delete; + AmsContextMenu& operator=(AmsContextMenu&&) = delete; /** * @brief Show context menu near a slot widget @@ -150,22 +161,30 @@ class AmsContextMenu : public ContextMenu { // === Subjects for button enable/disable states === // - // Static, like BufferStatusModal's, and for the same two reasons. The XML - // registry is keyed by name for the whole process, so per-instance storage - // cannot work here: three owners construct an AmsContextMenu (AmsPanel, - // AmsOverviewPanel, ExternalSpoolMenu) and all three publish the same two - // names, so the last registration wins and the first owner to be destroyed - // withdraws — or worse, silently outlives — a name the others still serve. - // Before this was static, a destroyed menu left "ams_slot_can_load" pointing - // into freed storage, and the next lv_xml_create() binding it wrote an - // observer through a reused allocation (nightly ASan, 2026-08-16). + // ONE set for the class, not one per instance. The XML binds these by fixed + // name, and three owners each construct their own menu (AmsPanel, the + // overview, the external-spool menu): with per-instance subjects registered + // under the same names, whichever instance registered LAST owned the names, + // and every other instance's writes landed on subjects no card was bound to + // any more -- Load/Unload greyed by another menu's stale answer, and, once + // an owner was destroyed, the registry pointing at reclaimed storage (that + // last one caught by nightly ASan, 2026-08-16). One menu is on screen at a + // time, so one set is exactly what the XML needs. // - // Sharing the values across the three owners is correct rather than merely - // tolerable: only one context menu is on screen at a time, and both values - // are set in on_created() immediately before the menu is shown. - static lv_subject_t slot_is_loaded_subject_; ///< 1 = loaded (Unload enabled), 0 = not loaded - static lv_subject_t slot_can_load_subject_; ///< 1 = has filament (Load enabled), 0 = empty - static bool subjects_initialized_; + // Torn down through StaticSubjectRegistry at shutdown, never in the + // destructor -- a per-instance deinit is what left the registry resolving + // "ams_slot_can_load" to dead storage the moment any one owner went away. + static lv_subject_t s_slot_is_loaded_subject_; ///< 1 = loaded (Unload enabled), 0 = not loaded + static lv_subject_t s_slot_can_load_subject_; ///< 1 = has filament (Load enabled), 0 = empty + /// 1 = another unit owns this slot's filament identity (multiACE: an + /// ACE-fed U1 head). Bound in XML so the edit actions hide and the + /// "open the owner" action appears, without adding imperative visibility. + static lv_subject_t s_slot_source_external_subject_; + static bool s_subjects_initialized_; + static void deinit_subjects(); + /// Unit that owns this slot's identity, or -1. Held so OPEN_SOURCE_UNIT can + /// name it without re-querying a backend that may have changed. + int source_owner_unit_ = -1; // === Backend reference for dropdown operations === AmsBackend* backend_ = nullptr; @@ -202,6 +221,8 @@ class AmsContextMenu : public ContextMenu { void handle_edit(); void handle_clear_spool(); void handle_spoolman(); + /// "Open in " — the slot's spool is described by another unit. + void handle_open_source(); void handle_scan_qr(); void handle_tool_changed(); void handle_backup_changed(); @@ -216,6 +237,8 @@ class AmsContextMenu : public ContextMenu { /// always-eligible stub when there is no backend (matching the old code, /// which skipped every compatibility check in that case). BackupEligibleFn backend_eligible_fn() const; + /// backend_->spool_display_number() as a callable, or slot+1 with no backend. + SlotDisplayNumberFn backend_display_number_fn() const; int get_current_tool_for_slot() const; int get_current_backup_for_slot() const; @@ -260,7 +283,8 @@ class AmsContextMenu : public ContextMenu { // @param eligible The backend's rule. // @return Newline-separated dropdown options, starting with "None". static std::string build_backup_options_for(int total_slots, int item_index, - const BackupEligibleFn& eligible); + const BackupEligibleFn& eligible, + const SlotDisplayNumberFn& display_number = {}); // Pure: should the change-handler refuse this selection? // @@ -308,8 +332,15 @@ class AmsContextMenu : public ContextMenu { // directions — offering what will be refused strands a runout-paused user // (bundle JX2FVRB9), and refusing what the backend accepts hides the // pause-then-swap recovery Klipper just told them to perform. + // `source_external` is slot_identity_owner_unit().has_value(): the position + // is fed from ANOTHER unit rather than holding a spool of its own. Load is + // meaningless there and is withdrawn outright — an ACE-fed U1 head is + // loaded with `ACE_LOAD_HEAD HEAD=n ACE=a SLOT=s`, which names a specific + // bay, so the choice belongs to the bay's own menu. Unload is unaffected: + // `ACE_UNLOAD_HEAD HEAD=n` needs no bay and is exactly what the head can do. static bool decide_can_load(bool system_busy, bool toolhead_unload, - std::optional slot_has_filament, bool print_blocks_op); + std::optional slot_has_filament, bool print_blocks_op, + bool source_external); // Pure: whether the Unload button is offered for the open slot. // @@ -345,6 +376,7 @@ class AmsContextMenu : public ContextMenu { static void on_edit_cb(lv_event_t* e); static void on_clear_spool_cb(lv_event_t* e); static void on_spoolman_cb(lv_event_t* e); + static void on_open_source_cb(lv_event_t* e); static void on_scan_qr_cb(lv_event_t* e); static void on_tool_changed_cb(lv_event_t* e); static void on_backup_changed_cb(lv_event_t* e); diff --git a/include/ui_ams_environment_overlay.h b/include/ui_ams_environment_overlay.h index c660d69b10..0da50d7f70 100644 --- a/include/ui_ams_environment_overlay.h +++ b/include/ui_ams_environment_overlay.h @@ -106,6 +106,7 @@ class AmsEnvironmentOverlay : public OverlayBase { static void on_start_stop_clicked(lv_event_t* e); static void on_preset_changed(lv_event_t* e); + static void on_auto_dry_toggled(lv_event_t* e); // === State === @@ -176,6 +177,14 @@ class AmsEnvironmentOverlay : public OverlayBase { lv_subject_t preset_text_subject_; char preset_text_buf_[64] = {}; + + /// Humidity-controlled drying. A separate capability from the dryer itself — + /// a backend can have one without the other — so it carries its own + /// visibility rather than riding on dryer_visible_subject_. + lv_subject_t auto_dry_visible_subject_; + lv_subject_t auto_dry_on_subject_; + lv_subject_t auto_dry_text_subject_; + char auto_dry_text_buf_[96] = {}; }; /** diff --git a/include/ui_ams_sidebar.h b/include/ui_ams_sidebar.h index d845a51257..3c6d0b1b44 100644 --- a/include/ui_ams_sidebar.h +++ b/include/ui_ams_sidebar.h @@ -220,7 +220,11 @@ class AmsOperationSidebar { // Step progress state StepOperationType current_operation_type_ = StepOperationType::LOAD_FRESH; int current_step_count_ = 4; - int target_load_slot_ = -1; + /// Whether the operation on screen is one we started — see + /// OperationOwnership. Replaces target_load_slot_, which carried this + /// answer as a side effect of holding a slot number nothing ever read (the + /// slot itself goes to AmsState::set_pending_target_slot for the pulse). + OperationOwnership ownership_; bool heat_label_showing_temp_ = false; // Index of the step whose label shows a live "