Skip to content

feat(ams): Snapmaker U1 + multiACE support (#6) - #7

Draft
physicsG wants to merge 20 commits into
mainfrom
develop/snapmaker-multiace
Draft

physicsG wants to merge 20 commits into
mainfrom
develop/snapmaker-multiace

Conversation

@physicsG

Copy link
Copy Markdown
Owner

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_bindingspools 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 dispatchACE_LOAD_HEAD / ACE_UNLOAD_HEAD for heads the ACE owns, the native U1 path for stock-feeder heads.

Follow-up

#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.

physicsG and others added 20 commits August 18, 2026 17:27
## 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

#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.
Loading an ACE bay onto a head that already holds a different one emits
ACE_UNLOAD_HEAD then ACE_LOAD_HEAD -- a real swap. The sidebar built a
fresh-load bar for it and drove that bar with the unload half's phase numbers,
so "Feed filament" was highlighted for the entire retract (51 s of a ~2.5 min
operation, measured on hardware), and the operation resolved to IDLE between the
halves, hiding the bar mid-swap.

plan_load() now answers "is this a swap?" in its own field rather than the
sidebar inferring it from which command is dispatched -- on an ACE bay those
disagree. Load and unload get disjoint phase-id spaces and the sidebar resolves
an id through the model instead of using it as an array index, which is what
lets one bar carry both halves; an id the bar does not claim holds it rather
than moving it somewhere plausible and wrong. preload_finish stops resolving the
operation while a swap is in flight, and the mock finally reproduces the U1's
step bar so any of it is visible under --test.

Verified on a live U1 (firmware 20260722, one ACE 2 Pro on T3): two real bay
swaps, correlating filament_feed.channel_state against the rendered step.
236 commits of main onto develop/snapmaker-multiace. Five conflicts, every one
of them "both sides added something", so all five resolve as unions rather than
by picking a side:

  - ams_backend.h: filament_ops_may_home() (ours) and delegates_homing_to_printer()
    (main's) ask different questions and both survive, alongside
    change_tool_completes_load().
  - ui_ams_sidebar / ui_panel_filament: the two homing-prompt gates AND together.
    They suppress the prompt for different reasons -- U1/ACE never emit that G28,
    AFC emits its own -- so either alone re-introduces the other's bug.
  - ui_ams_context_menu: our display_number() label with main's three-state
    BackupEligibility switch.
  - check_doc_refs.py: ours (gitignore + per-submodule attribution, strict
    --devel), with the exempt list unioned.

One break the textual merge could not see: ui_ams_toolhead_menu.cpp still called
print_blocks_filament_op() with the (bool, bool, bool) signature main replaced
with a typed PrintState. Ported to get_print_lifecycle(), which is also what
clears main's new raw-print-state gate -- that gate is what caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…crets

release.yml already knows how to cross-compile this platform, but it fires only
on a `v*` tag and its later jobs need secrets a fork does not have (R2_*,
Android signing, WEBSITE_DISPATCH_TOKEN), so a fork could not produce a U1
build at all. snapmaker-u1.yml is the same toolchain + build + package steps
with nothing secret in them: branch pushes, PRs and the Run-workflow button
upload an installable artifact, and a `u1-v*` tag additionally publishes a
GitHub Release using the automatic GITHUB_TOKEN.

The U1 is the one platform this works for unaided — its Dockerfile is Debian
Trixie plus apt crossbuild-essential-arm64, with no tarball fetch and no private
registry. Tag prefix is `u1-v` rather than `v` so a fork release does not also
start release.yml and fail. The job additionally extracts the toolchain image's
CA bundle into build/snapmaker-u1/certs/, which release-snapmaker-u1 packages
but release.yml never produces — upstream CI tarballs ship without certs/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 15fa08d)
GITHUB_REPO was documented as overridable and was not: the generated bundles
hard-assigned it in their header, which runs before the modules' own
`: "${GITHUB_REPO:=...}"` default, so `GITHUB_REPO=me/fork sh install.sh` was
silently ignored. The generators now emit the soft form.

That alone is not enough, and the missing half is the dangerous one: the
installer tries the upstream CDN and HTTP mirror BEFORE GitHub at every step,
so a fork install would resolve upstream's version from
releases.helixscreen.org and then download upstream's binary under it.
HELIX_GITHUB_ONLY=1 skips both tiers.

scripts/install-fork.sh is a thin front-end that sets those two and hands over
— platform detection, service setup, backup/rollback, SHA256 verification and
--update/--uninstall/--local stay in the one installer rather than being
forked. It prefers an install.sh sitting beside it, so an unpacked archive
installs with no network; release-snapmaker-u1 now ships it there. The
Moonraker [update_manager] block and release_info.json follow GITHUB_REPO too,
or the printer would be offered upstream's releases as updates to a fork build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1c23c9d)
…k releases resolve as latest

Two things made the documented one-liner not work, one merely wrong and one
dangerous. Wrong: install-fork.sh only exists on this branch, so the
raw/main URL 404s until the branch reaches main. Dangerous: the script then
fetched install.sh from HELIX_FORK_REF (default main), and main's install.sh
is the OLD bundle — hard GITHUB_REPO=, no HELIX_GITHUB_ONLY — which would take
the environment this script set, ignore it, and quietly install upstream's
binary from upstream's CDN. Exactly the outcome the script exists to prevent.

So: the installer is now taken from the fork's LATEST RELEASE first (the
installer that built the binary it installs; the publish job attaches
install.sh next to install-fork.sh), then from HELIX_FORK_REF — and whichever
is found is refused unless it carries HELIX_GITHUB_ONLY. Fork releases are no
longer prereleases: /releases/latest, which the installer discovers versions
through and the download URLs go through, resolves only to non-prereleases,
so a prerelease-only repo had no "latest" at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 98ef402)
The U1 support doc cites release_info.json, which the installer writes into
INSTALL_DIR on the printer and which therefore exists in no checkout. Main's
ref gate is strict about that, so the citation went red the moment the two
branches met. Exempted rather than reworded, per the list's own note: the
citation is correct, the file simply is not there at check time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	docs/devel/specs/2026-07-25-helixctl-ui-test-harness-design.md
#	include/filament_op_dispatch.h
#	scripts/check_doc_refs.py
#	scripts/install-dev.sh
#	translations/de.yml
#	translations/en.yml
#	translations/es.yml
#	translations/fr.yml
#	translations/it.yml
#	translations/ja.yml
#	translations/pt.yml
#	translations/ru.yml
#	translations/zh.yml
#	ui_xml/translations/de.xml
#	ui_xml/translations/en.xml
#	ui_xml/translations/es.xml
#	ui_xml/translations/fr.xml
#	ui_xml/translations/it.xml
#	ui_xml/translations/ja.xml
#	ui_xml/translations/pt.xml
#	ui_xml/translations/ru.xml
#	ui_xml/translations/translations.xml
#	ui_xml/translations/zh.xml
PathTopology and PathSegment moved in ams_types.h, and AmsState::on_backend_event
moved by ~180 lines in ams_state.cpp, when the merge landed upstream's changes on
top of the multiACE ones. Caught by the symbol-cite gate main added in that same
merge, which had never run against these docs before.
## What breaks

Snapmaker's firmware brokers LAN pairing itself and delegates one step to the printer's touchscreen: the approval tap. The stock screen app answered it. HelixScreen replaces that binary, and never answered — so the request goes to nobody. From a U1's `moonraker.log`:

```
07:37:03,695 [client_manager.py:_handle_request_lan_auth()] - LAN auth request from clientid: app-eb2b366a-…
07:37:03,695 [client_manager.py:_handle_request_lan_auth()] - notify screen new client access request
                                          ← nothing follows. No approve ever arrives.
```

Snapmaker Orca and the Snapmaker App hang at "requesting connection" until they time out. An already-paired client still reconnects fine — the component short-circuits on a known `clientid` and never asks the screen — so the breakage reads as intermittent.

## What this adds

<img width="800" height="480" alt="image" src="https://github.com/user-attachments/assets/5f1b5dbc-c180-4f84-8368-831bd03400c7" />

The screen's half: receive the notification, prompt, send the answer.

- `lan_client_authorization.{h,cpp}` — provider table, **the only place a vendor is named**. Pure, no LVGL.
- `lan_client_auth_router.{h,cpp}` — subscribes, prompts, answers. Names no firmware.
- 15 unit tests (`[lanauth]`), plus `docs/devel/LAN_CLIENT_AUTHORIZATION.md` for the protocol and its traps.

**No capability gate, deliberately** — no `if (U1)` anywhere. The notification *is* the probe: a firmware without a broker never sends one. Cost on every other printer is one entry in `method_callbacks_` — no UI, no RPC, no subscription or startup changes. Excluded from the ESP32 build like its sibling routers. A second firmware is one table row.

Product names carry `// i18n: do not translate`; the sentence around them is translated. 7 strings, 9 locales.

## Testing

Verified against a U1 on PAXX 1.5.2: the firmware accepts these decision params byte for byte (with and without the optional `id`); a real `request_lan_auth` raised the prompt and **Deny** reached the firmware, leaving the client registry untouched. **Allow** confirmed — Orca and the App pair again.

Only an *unbound* client triggers the prompt; the doc has a recipe for re-triggering.

## Out of scope

The stock screen's access-code display (a separate settings surface; the code is pinned to `12345678` on current firmware) and the cloud-account PIN path. Neither is needed to pair either app while the printer is in LAN mode — both come in over the LAN path. Documented.

`quality-checks.sh` is green except `validate_icon_fonts.sh`, which fails identically on `main` and is untouched here.
format-xml.py --check has failed on ams_context_menu.xml and
ams_environment_overlay.xml since before u1-v0.99.115, which shipped with them
red. Upstream's copies are clean, so this is fork-side drift from the multiACE
work rather than anything inherited. Attribute re-wrapping only — both parse
trees are byte-identical before and after. quality-checks.sh treats XML
formatting as advisory, which is why only the bats gate caught it.
Replaces the fork's landing page — which was still upstream's README and said nothing about the U1 work — with one about what this fork actually adds.

## What's in it

Three sections, one per thing the fork adds: **multiACE** (ACE units alongside the U1's own heads, plus per-unit drying), **LAN pairing** (Orca / the Snapmaker App can bind again), and **installable U1 builds** (`install-fork.sh`, `u1-v*` tags). Then a branch-model table and a hand-off to upstream.

**Hand off rather than fork the content.** Everything upstream — features, supported printers, config, troubleshooting — links to upstream's README instead of being copied. Upstream touches `README.md` ~68 times per 6 months; a forked copy would be stale within days and conflict on every sync. Net: −259 lines, +65.
Upstream's own Snapmaker U1 work is the substance here: the reprint routing
snapshot (last_print_tool_mapping), the two-route remap predicate, per-tool
seeding by default_head_for_tool, the four-head dashboard preset, and the
tool-changer step-bar fixes. Eight files conflicted; the two that were not
mechanical are the AMS reverse-sync gate and the context-menu subjects.

The reverse-sync gate takes BOTH sides as a conjunction. Main replaced the
firmware-persistence test with supports_per_tool_spool_assignment() to stop
CFS/AD5X IFS resurrecting cleared lanes, but that predicate defaults to
is_tool_changer(), which this branch made true for SNAPMAKER and MULTIACE --
so main's gate alone would have re-run the sync on the U1 and reintroduced
the PLA-head-displays-PETG bug this branch fixed. has_firmware_filament_identity()
still excludes it.

AmsContextMenu made its subjects static on both sides independently; this
takes main's names (the smaller edit, and it keeps future merges quiet) with
this branch's ams_slot_source_external subject and its StaticSubjectRegistry
teardown, which main lacks. AmsBackendMock had can_unload_from_toolhead added
by both sides in different places and merged clean into a duplicate override;
the two rules are for disjoint modes and are now one function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflicts were four kinds, none semantic to multiACE:

- Vendor gating: main put each vendor backend behind HELIX_HAS_*, so the
  multiACE include and its factory case move inside HELIX_HAS_SNAPMAKER.
- Shared executors: main consolidated FilamentRunoutHandler onto
  helix::ui::execute_filament_load(). The branch's lone addition there,
  caps.change_tool_completes_load, is carried into the shared helper, so all
  three surfaces now answer it (an ACE bay needs the false).
- LAN pairing landed upstream and was then refactored onto Modal plus
  per-client denial suppression. Main's version supersedes the branch's
  imperative one wholesale; nothing branch-only was lost.
- Docs and translations are generated. Doc conflicts were purely the retired
  content-hash citation pipeline against main's named anchors, so main wins and
  scripts/doc_cite_anchors.tsv goes with it; the YAMLs are unioned and the XML
  regenerated.

AmsSystemInfo::supports_tool_mapping is gone (d87addb). set_multiace_mode()
carried that flag to say "the U1 owns no tool table", which now lives in
get_remap_strategy() and owns_tool_mapping_table() -- both keyed on
snapmaker_mode_, which multiACE mode deliberately clears. Both now also accept
multiace_mode_, or the mock would have claimed a remap table the U1 has never had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge resolution split inside a `// =====...` comment divider: the regex
matching the conflict separator was not anchored to line start, and a divider's
trailing run of `=` ends in exactly `=======\n`. That consumed the wrong
boundary, left the real separator in the file and unbalanced the braces, so the
translation unit did not compile.

Redone from the three merge stages with git merge-file and a line-anchored
resolver. Both sides appended a distinct TEST_CASE and both were closed by the
same trailing braces, so ours is closed explicitly and the shared tail closes
theirs. 16 TEST_CASEs, braces balanced, compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two rules made a runout-paused U1 unrecoverable from the only dialog standing.

prepare_for_resume() sent `AUTO_FEEDING ... LOAD=1 PRINTING=1`. FEED_AUTO
silent-returns ("LOAD skipped: channel[N] is_printing=1") when PRINTING is set
and the port sensor reads no filament -- which is the state a runout leaves
behind, and the only state this function runs in. Every Resume was a no-op that
re-raised CHECK_FILAMENT_RUNOUT; the same load succeeded the moment the flag was
dropped. do_load_filament() has always omitted it, and says why.

manual_action_row hid Load/Unload/Purge whenever the backend answers
recovers_filament_on_resume(), which the U1 does. Combined with the above, a
paused user had no Load button and a Resume that could not work. PAUSED now
always shows the row; PRINTING still always hides it, and an idle autofeed
runout is unchanged. refuse_if_printing() permits the op while paused, so the
buttons are not offered into a refusal.

`print_state_enum` is PrintJobState (PRINTING=1, PAUSED=2), not the PrintState
lifecycle -- the two do not share numbering.

Mutation: restoring `PRINTING=1` fails the AUTO_FEEDING assertion in
test_ams_backend_snapmaker.cpp; restoring the old cond fails the paused-autofeed
section of the new test_runout_manual_actions_paused.cpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lv_obj_bind_state_if_eq()'s observer is unconditional in both directions
(lv_observer.c obj_state_observer_cb: add_state on true, remove_state on false)
and knows nothing of its siblings. Two of them on the same widget and state do
not compose into an OR -- each fires only when ITS subject moves and asserts
both polarities when it does, so the last subject to change decides, and a false
condition clears what another binding set.

The filament panel showed it: with a print owning the toolhead the panel
computed and published filament_load_disabled=1, then any filament op finishing
dropped filament_operation_in_progress 1->0 and that observer removed
LV_STATE_DISABLED outright. Load came back live, the click reached
run_filament_op(), and the backend refused it with nothing on screen to say why
-- the dead end bundle JX2FVRB9 was supposed to have closed.

21 sites collapse to a single <bind_state_if cond="a or b or c">, which the
expression evaluator re-runs whenever ANY referenced subject changes. The two
header_bar action_buttons keep their pair and carry STATE_BINDING_OK: a caller
passes either the subject or the cond form, never both, and the unused one
defaults to a constant that fires once at bind.

check_state_binding_conflicts.py holds the line at zero (quality-checks.sh, and
--staged-only for the hook), with that annotation as the only escape.

Mutation: restoring btn_load's three bindings fails the "print gate outlives an
unrelated operation finishing" section of the new
test_state_binding_composition.cpp, which drives the real filament_panel.xml.
Confirmed live too -- before, `ctl state btn_load` read disabled=false mid-print
with filament_load_disabled=1; now it stays true across that same toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
None of the three existed at 84d29d0, so the branch's own pre-gate code met
them for the first time on merge. Each is fixed rather than baselined around.

Namespace: `ams_draw` was a root namespace, which put all 73 of its declarations
outside helix:: -- six of them added by the multiACE work. Nesting it as
helix::ams_draw retires the lot, so the count falls 2350 -> 2277 and the branch's
six are absorbed rather than exempted. A global `namespace ams_draw =
helix::ams_draw;` alias keeps the ~230 short call sites spelling it the same way;
only the two files that own the namespace changed.

Comment archaeology: four comments cited a commit SHA. Each is rewritten as the
present-tense constraint it was reaching for, and the baseline ratchets down.

Icon validation: validate_icon_fonts.sh feeds `comm` two lists built by `sort`
without pinning a collation, so a developer locale that orders differently makes
comm reject its own inputs and report present icons as missing -- a red gate on a
clean tree. LC_ALL=C for the script, and the one explicit en_EN sort in the
ICON_MAP check goes with it, since that check exists to mirror a byte-ordered
binary search.

quality-checks.sh now passes end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
format-xml.py wraps attributes at ~120 chars; the collapsed bind_state_if and
bind_flag_if conditions ran past it. Pure line-wrapping, no expression changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nish the multiACE locales

Two upstream checks arrived with the v0.99.118 merge that this fork's own code
and workflow predate. Both failed the u1-v0.99.118 build.

release-snapmaker-u1 now asserts the packaged binary carries no remote-control
server, reading the remote_control= stamp mk/rules.mk writes at link time. Cross
builds default that server ON (3e82e8d gave every developer cross build the
helixctl server), and snapmaker-u1.yml never passed HELIX_PACKAGING=1 because
before the assert it did not have to. The flag has to be set at the BUILD, not
at packaging, since the stamp records what was linked. Set it only for a u1-v*
tag: branch builds keep the server, which is what makes a dev cross build
drivable on the device.

test_explicit_tag_coverage requires every label_tag / translation_tag to be
non-empty in every locale. The multiACE work left nine strings untranslated -
`Auto-dry` and `Open in ACE` carry explicit tags and so failed the gate, and the
other seven were empty beside them. All nine are now filled in all eight
locales, keeping the wording consistent with the `Drying` and `Open` entries
already there. Those were the only empty values in the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant