diff --git a/.gitattributes b/.gitattributes index d54ff9f6e4..72e57d211c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,11 @@ assets/test_gcodes/** linguist-vendored # Needs the driver from `make setup`; unregistered, git falls back to a text # merge and these files conflict whenever two branches both cited a lesson. .claude-recall/*.json merge=recall-stats + +# This fork's README is its own document, not a patched copy of upstream's. +# Upstream touches README.md every couple of days, so without this every merge +# of main into develop conflicts on a file whose two sides were never meant to +# be reconciled. `ours` keeps this fork's version untouched and drops upstream's +# change. Needs the driver from `make setup`; unregistered, git falls back to a +# text merge and the conflicts come back. +README.md merge=ours diff --git a/.github/workflows/snapmaker-u1.yml b/.github/workflows/snapmaker-u1.yml new file mode 100644 index 0000000000..2c08489dd9 --- /dev/null +++ b/.github/workflows/snapmaker-u1.yml @@ -0,0 +1,306 @@ +# Snapmaker U1 — cross-compile, package, and (on a u1-v* tag) publish. +# +# Why this exists next to release.yml: release.yml already knows how to build +# this platform, but it fires only on a `v*` tag and its downstream jobs need +# secrets a fork does not have (R2_*, Android signing, WEBSITE_DISPATCH_TOKEN). +# This workflow is the same toolchain + build + package steps with nothing that +# can fail for lack of a secret, so a fork can produce an installable U1 build +# from a branch push or a button press. +# +# Deliberately NOT triggered by `v*`: that is upstream's release tag, and a fork +# pushing one would start release.yml too, which then fails on the missing +# secrets. Fork releases use `u1-v` (e.g. u1-v0.99.114). +name: Snapmaker U1 + +on: + push: + branches: + - main + - 'feat/**' + - 'fix/**' + # A `u1-v*` tag additionally publishes a GitHub Release on this repo (see + # the publish job). + tags: + - 'u1-v*' + # No paths-ignore here on purpose: GitHub applies path filters to TAG pushes + # too, so a release tag landing on a docs-only commit would silently not + # build. Skipping a few docs-push builds is not worth a release that + # quietly does nothing. The PR trigger below keeps the filter. + pull_request: + branches: [ main ] + paths-ignore: + - '**.md' + - 'docs/**' + workflow_dispatch: + +concurrency: + group: snapmaker-u1-${{ github.ref }} + # A tag build ends in a published release — never cancel one of those. + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +jobs: + build: + name: Build (Snapmaker U1) + runs-on: ubuntu-22.04 + timeout-minutes: 120 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: false + + - name: Initialize submodules + uses: ./.github/actions/init-submodules + + # The runner ships nearly full of preinstalled SDKs we never use, and the + # cross-compile writes assembler temps + ccache to the root partition. + - name: Free up disk space + run: | + df -h / + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL + df -h / + + # The U1 links with -flto, so the final ld is one large process; release.yml + # learned the hard way (v0.99.110, exit 137) that the runner's RAM is not + # reliably enough for a peak like that. Swap makes it page instead of dying. + - name: Add swap for the link peak + run: | + free -h + sudo fallocate -l 16G /swapfile-helix || sudo dd if=/dev/zero of=/swapfile-helix bs=1M count=16384 + sudo chmod 600 /swapfile-helix + sudo mkswap /swapfile-helix + sudo swapon /swapfile-helix + free -h + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + # Unlike release.yml (which runs on a tag ref, whose cache scope is always + # empty and must not save), this runs on a branch — so it can both restore + # and save, and the second run on a branch is substantially faster. + - name: ccache + uses: actions/cache@v5 + with: + path: /tmp/.ccache-snapmaker-u1 + key: ccache-snapmaker-u1-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ccache-snapmaker-u1-${{ github.ref_name }}- + ccache-snapmaker-u1- + + - name: Configure ccache + run: | + mkdir -p /tmp/.ccache-snapmaker-u1 + printf 'max_size = 2G\ncompression = true\n' > /tmp/.ccache-snapmaker-u1/ccache.conf + + # Debian Trixie + apt crossbuild-essential-arm64. No tarball fetch, no + # private registry, no secret — which is why this platform is the one a + # fork can build unaided. + - name: Build toolchain image + run: | + docker buildx build \ + --cache-from type=gha,scope=toolchain-snapmaker-u1 \ + --cache-to type=gha,mode=max,scope=toolchain-snapmaker-u1 \ + --load \ + -t helixscreen/toolchain-snapmaker-u1 \ + -f docker/Dockerfile.snapmaker-u1 \ + docker/ + + - name: Cross-compile for Snapmaker U1 + env: + # A cross build carries the helixctl server by default, and + # release-snapmaker-u1 refuses to package a binary that does -- it + # reads the remote_control= stamp mk/rules.mk writes at link time, so + # the flag has to be set HERE, at the build, not at the packaging + # step. HELIX_PACKAGING=1 is what turns the server off. + # + # Only on a u1-v* tag. Branch builds keep the server on purpose: that + # is what makes a dev cross build drivable on the device. + PACKAGING: ${{ startsWith(github.ref, 'refs/tags/u1-v') && 'HELIX_PACKAGING=1' || '' }} + run: | + docker run --rm \ + -v "${{ github.workspace }}":/src \ + -v /tmp/.ccache-snapmaker-u1:/root/.cache/ccache \ + -w /src \ + helixscreen/toolchain-snapmaker-u1 \ + make PLATFORM_TARGET=snapmaker-u1 SKIP_OPTIONAL_DEPS=1 $PACKAGING -j$(nproc) + + - name: Show ccache stats + run: | + docker run --rm \ + -v /tmp/.ccache-snapmaker-u1:/root/.cache/ccache \ + helixscreen/toolchain-snapmaker-u1 \ + ccache -s + + # The container runs as root; everything after this is host-side. + - name: Fix build directory ownership + run: sudo chown -R $(id -u):$(id -g) build/ + + # The device's own CA bundle is not guaranteed to be current, so the local + # `make snapmaker-u1-docker` target extracts the image's. release.yml has + # no equivalent, so CI tarballs have been shipping without certs/ — and + # release-snapmaker-u1 only includes the file if it is already there. + - name: Extract CA certificates for HTTPS on device + run: | + mkdir -p build/snapmaker-u1/certs + docker run --rm helixscreen/toolchain-snapmaker-u1 \ + cat /etc/ssl/certs/ca-certificates.crt > build/snapmaker-u1/certs/ca-certificates.crt + test -s build/snapmaker-u1/certs/ca-certificates.crt + + - name: Verify the binary is a U1 binary + run: | + set -e + bin=build/snapmaker-u1/bin/helix-screen + test -f "$bin" + file "$bin" + # aarch64, and dynamically linked against glibc (the U1 target is + # hybrid: static libstdc++/libgcc, dynamic libc/libdrm). + file "$bin" | grep -q 'ARM aarch64' + ls -lh "$bin" + + - name: Generate pre-rendered images + run: make venv-setup && make gen-all-images + + - name: Package release + run: make release-snapmaker-u1 + + # The zip is the important one: its name is version-less by design + # (helixscreen-snapmaker-u1.zip), which is what the installer looks for + # first at every transport and what Moonraker's update manager needs. + - name: Show packaged contents + run: | + ls -lh releases/ + unzip -l releases/helixscreen-snapmaker-u1.zip | head -30 + + - name: Upload U1 build + uses: actions/upload-artifact@v7 + with: + name: helixscreen-snapmaker-u1 + path: | + releases/helixscreen-snapmaker-u1.zip + releases/helixscreen-snapmaker-u1-*.tar.gz + if-no-files-found: error + retention-days: 30 + + - name: Upload symbol map + uses: actions/upload-artifact@v7 + with: + name: symbols-snapmaker-u1 + path: | + build/snapmaker-u1/bin/helix-screen.sym + build/snapmaker-u1/bin/helix-screen.debug + if-no-files-found: warn + retention-days: 30 + + # ========================================================================== + # Publish a GitHub Release on THIS repo, so scripts/install-fork.sh (and the + # printer's Moonraker update manager) have something to download. + # + # Only on a `u1-v*` tag. Uses the automatic GITHUB_TOKEN — no configured + # secret — so it works on a fork out of the box. + # ========================================================================== + publish: + name: Publish release + needs: build + if: startsWith(github.ref, 'refs/tags/u1-v') + runs-on: ubuntu-22.04 + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: false + + - name: Download build + uses: actions/download-artifact@v8 + with: + name: helixscreen-snapmaker-u1 + path: release-files + + # The installer resolves a version to + # .../releases/download//helixscreen-snapmaker-u1-.tar.gz + # (and the version-less .zip, which it tries FIRST and which therefore + # works whatever the tag is called). `make release-snapmaker-u1` names the + # tarball from VERSION.txt, so unless the tag matches that exactly, the + # tar URL 404s and only the zip resolves. Add a correctly-named copy so + # both do — belt and braces, and it costs one hardlink. + - name: Name the tarball after the tag + run: | + set -eu + cd release-files + tag="${GITHUB_REF_NAME}" + want="helixscreen-snapmaker-u1-${tag}.tar.gz" + have=$(ls helixscreen-snapmaker-u1-*.tar.gz | head -1) + if [ "$have" != "$want" ]; then + cp "$have" "$want" + echo "Added $want (built as $have)" + fi + ls -lh + + # Ship BOTH installers with the build, exactly as upstream ships install.sh + # with its releases. install-fork.sh fetches install.sh from + # releases/latest/download/ first, so the installer that runs is the one + # that built the binary it installs — and it exists exactly when there is + # something to install, unlike a branch's raw file. + - name: Stage the installers + run: cp scripts/install-fork.sh scripts/install.sh release-files/ + + - name: Checksums + run: | + cd release-files + sha256sum * > SHA256SUMS + cat SHA256SUMS + + - name: Write release notes + run: | + set -eu + tag="${GITHUB_REF_NAME}" + cat > release-notes.md < 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 @@ -619,6 +640,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),) @@ -796,6 +819,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 := @@ -952,6 +981,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/README.md b/README.md index a92defb01a..52ae9c5e67 100644 --- a/README.md +++ b/README.md @@ -1,300 +1,106 @@

HelixScreen
-

HelixScreen

-

A modern touch interface for Klipper 3D printers

-

helixscreen.org

+

HelixScreen for the Snapmaker U1

+

A fork of HelixScreen that supports the U1's toolchanger and multiple ACE units

- Build - Code Quality + Snapmaker U1 build + Latest U1 build License: GPL v3 - LVGL - Platform - Version - Discord

-Your printer can do way more than your current touchscreen lets you. Bed mesh visualization, input shaper graphs, multi-material management, print history — it's all trapped in a browser tab. HelixScreen puts it at your fingertips. - -Fast, beautiful, and frugal enough to run on hardware you already own — your printer's onboard SoC, a Raspberry Pi from a drawer, or anything newer. - -Run it right on your printer, or on a separate device — a spare Pi, a mini PC, even your desktop — as a remote screen pointed at your printer's Moonraker over the network. Great for a floor-standing printer with a screen up on your desk. - ---- - -**Quick Links:** [Website](https://helixscreen.org) · [Features](#features) · [Screenshots](#screenshots) · [Installation](#installation) · [User Guide](docs/user/USER_GUIDE.md) · [FAQ](#faq) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/RZCT2StKhr) +> **This is a fork of [prestonbrown/helixscreen](https://github.com/prestonbrown/helixscreen).** +> Everything upstream HelixScreen does, this does — see [upstream's README](https://github.com/prestonbrown/helixscreen#readme) +> for the full feature tour. What follows is only what this fork adds on top, for +> the Snapmaker U1. +> +> Upstream is merged in regularly, and U1 work goes back upstream where it fits. --- -## Why HelixScreen? - -- **Customizable dashboard** — Multi-page grid with drag-to-reposition, edge resize, and 30+ widgets including temperature graphs, fan arcs, and power toggles -- **Every feature at your fingertips** — 30+ panels, 20+ overlays, 20+ modals, 300+ XML layouts -- **~15MB RAM on embedded targets, ~75MB disk** — sips memory on a Creality K1 or Flashforge AD5M; a few times more on 64-bit Pi, still well under what other touchscreen UIs need. Your printer's onboard SoC or an older Pi is plenty — no need to buy new hardware. -- **80+ printers in the database** — Auto-detects your hardware and configures itself -- **Multi-material ready** — AFC, Happy Hare, ACE, AD5X IFS, CFS, Snapmaker U1, tool changers (incl. MedusaHC hotend changers), Spoolman -- **Exclude objects** — Tap-to-exclude overhead map view with object outlines during prints -- **Looks great** — Light/dark themes with 17 presets, responsive layouts, GPU-accelerated blur -- **First-run wizard** — Guided setup discovers your printer's capabilities -- **9 languages** — English, German, Spanish, French, Italian, Japanese, Portuguese, Russian, and Chinese - -
-Technical comparison - -| Feature | HelixScreen | GuppyScreen | KlipperScreen | -|---------|-------------|-------------|---------------| -| UI Framework | LVGL 9 XML | LVGL 8 C | GTK 3 (Python) | -| Declarative UI | Full XML with reactive bindings | C only | Python only | -| RAM Usage | ~15MB (32-bit) | ~15-20MB | ~50MB | -| Disk Size | ~75-115MB | ~60-80MB | ~50MB | -| Multi-Material | 7 backends | Limited | Basic | -| Printer Database | 80+ models | — | Manual config | -| Display Layouts | Auto-detecting (480x320 to 1024x600, plus ultrawide and portrait) | Fixed | Configurable | -| Internationalization | 9 languages | — | 40+ languages | -| Status | Pre-1.0, actively developed | Inactive | Mature (maintenance) | -| Language | C++17 | C | Python 3 | - -
- -## Screenshots - -### Home Panel -Home Panel - -### Print File Browser -Print Select - -### Bed Mesh Visualization -Bed Mesh - -
-More screenshots - -### Controls Panel -Controls Panel - -### Motion Controls -Motion Controls - -### AMS / Filament Management -AMS Panel +## What this fork adds -### Input Shaper Results -Input Shaper Results +### Multiple ACE units alongside the U1's own toolheads -### PID Tuning -PID Tuning +Stock HelixScreen models the U1 as a four-head parallel toolchanger. This fork adds +**multiACE**: one or more Snapmaker ACE units feeding the same machine, shown as +separate units with their own slot counts, and a filament path that traces which +spool actually feeds which tool. -### Settings -Settings +Multi-filament overview: SnapSwap and ACE 2 Pro -### First-Run Wizard -Setup Wizard +Per-unit drying comes with it — live temperature and humidity, material presets, and +auto-dry with its own thresholds: -
+ACE 2 Pro drying controls -See [docs/devel/GALLERY.md](docs/devel/GALLERY.md) for the full gallery. +### LAN pairing for Snapmaker Orca and the Snapmaker App -## Features +The U1's firmware brokers pairing itself and delegates one step to the printer's +screen: the approval tap. Replacing the stock screen means nothing answers, so +Orca and the phone app hang at *"requesting connection"* until they time out. -**Dashboard** — Customizable multi-page grid with drag-to-reposition, edge resize, and a catalog of 30+ widgets. Temperature graphs, fan arcs, power toggles, camera feeds, active spool, favorite macros — add what matters, hide what doesn't. Per-breakpoint layout persistence. +This fork answers it: -**Printer Control** — Print management with G-code preview, motion controls, temperature presets with per-material overrides, multi-fan control, Z-offset, speed/flow tuning, live filament consumption tracking, power device management. +Connection Request prompt -**Multi-Material** — 7 filament system backends: AFC (Box Turtle, ViViD), Happy Hare (ERCF, 3MS, Tradrack, Night Owl), ACE (Anycubic ACE Pro), AD5X IFS, Creality CFS, Snapmaker U1 (with RFID spool recognition), and tool changers — including MedusaHC hotend changers, whose dock sensors and filament feeder are driven on top of klipper-toolchanger. Multi-unit and multi-backend support. Full Spoolman integration with spool creation wizard. +Deny goes on the wire too, so a refused client fails immediately instead of waiting. +Details in [`docs/devel/LAN_CLIENT_AUTHORIZATION.md`](docs/devel/LAN_CLIENT_AUTHORIZATION.md). -**Visualization** — 3D G-code layer preview with memory-aware geometry budgets, 3D bed mesh with async rendering, print thumbnails, frequency response charts, unified temperature graph. +### Installable U1 builds -**Calibration** — Input shaper with frequency response charts, PID tuning with live graph, MPC calibration (Kalico), belt tension tuning, bed mesh, screws tilt adjust, Z-offset, firmware retraction, probe management. +Upstream's release pipeline needs secrets a fork does not have. This fork ships its +own U1 workflow that cross-compiles, packages and publishes a working build from a +`u1-v*` tag — plus a fork-aware installer, so the printer is offered *this* repo's +releases as updates rather than upstream's. -**Integrations** — HelixPrint plugin, power devices with quick-toggle, print history, timelapse (Moonraker plugin), exclude objects with tap-to-exclude map view, LED control (5 backends), sound alerts (SDL/PWM/M300), Bluetooth label printing (Brother QL/PT, Niimbot, MakeID). - -**Display** — Auto-detecting layout system (480x320 through 1024x600, plus ultrawide and portrait — see below), display rotation (0/90/180/270) with auto-detection, light/dark themes with 17 presets and live theme editor, GPU-accelerated backdrop blur, screensavers. - -**System** — First-run wizard with guided hardware discovery, 80+ printer models with auto-detection, 9 languages, opt-in crash reporting with debug bundles, KIAUH installer, versioned config migration. - -## Supported Platforms - -| Platform | Architecture | Status | -|----------|-------------|--------| -| Raspberry Pi 3/4/5, CM4, Zero 2 W (64-bit) | aarch64 | Tested | -| Raspberry Pi 3/4 (32-bit) | armhf | Tested | -| BTT Pad / CB1 / CB2 / Manta | aarch64 | Tested | -| Creality K1 / K1C / K1 Max | MIPS32 | Tested | -| Creality K2 Pro / K2 Plus / K2 SE | ARM (musl) | Tested | -| Creality Sonic Pad | armhf | Tested | -| Flashforge AD5M / AD5M Pro | armv7-a | Tested | -| Flashforge AD5X | MIPS32 | Tested | -| Snapmaker U1 (SnapSwap toolchanger) | aarch64 | Tested³ | -| QIDI Q2, Max 4 | aarch64 | Supported¹ | -| Sovol SV06 / SV08 | Pi build | Tested | -| Elegoo Centauri Carbon | armv7-a | Tested² | -| x86 Mini PC (Debian) | x86_64 | Tested | -| macOS / Linux desktop | x86_64 / ARM64 | Development / CI | -| Android phone / tablet | arm64 / x86_64 | Experimental⁴ | - -¹ QIDI models with Linux framebuffer displays (Q2, Max 4) only. Stock firmware runs standard Moonraker and works directly; community firmware like [FreeDi](https://github.com/Phil1988/FreeDi), [53Aries/Q2-Firmware](https://github.com/53Aries/Q2-Firmware), or [FreeQIDI](https://github.com/Phil1988/FreeQIDI) is optional. Older models (X-Smart 3, X-Plus 3, X-Max 3, Q1 Pro, Plus 4) ship with QIDI's MKS PI smart-panel (a TJC serial HMI that *is* the UI; TJC is the Chinese OEM that Nextion licenses globally) and are **not compatible for on-device install** without a screen replacement — see [QIDI_SUPPORT.md → Display Compatibility](docs/devel/printers/QIDI_SUPPORT.md#display-compatibility) for why. Remote-control mode works on all six QIDI models regardless. - -² Elegoo Centauri Carbon requires the community [OpenCentauri COSMOS](https://github.com/OpenCentauri/cosmos) firmware ([docs](https://docs.opencentauri.cc/klipper-conversion/cosmos/cosmos/); stock Elegoo firmware has no SSH, Klipper, or Moonraker). Ships with factory white-balance calibration for the 4.3" panel. - -³ Snapmaker U1 needs SSH access. Stock firmware (1.2+) provides it via the **Root access** option in printer settings; the community [PAXX Extended Firmware](https://github.com/paxx12-snapmaker-u1/SnapmakerU1-Extended-Firmware) enables SSH by default and is the easiest path. Tested on PAXX 1.3.x/1.4.x; the stock-firmware path is unverified on a real stock device. Reinstall HelixScreen after any firmware update — it resets system files and the stock screen returns until you reinstall. - -⁴ Android is a **remote** client only. It monitors and controls a printer over your network and does not replace a printer's own panel. Needs Android 9.0 or newer, and runs in landscape. Not on Google Play yet, so you install the APK yourself from a [GitHub release](https://github.com/prestonbrown/helixscreen/releases/latest). See [Android app](docs/user/INSTALL.md#android-app-experimental). +--- -## Installation +## Install -> **Run these commands on whatever machine will drive the display.** -> For an on-printer screen, that's your printer's host — SSH into your Raspberry Pi, BTT board, or (for all-in-one printers like Creality K1/K2, Flashforge AD5M/Pro) directly into the printer. -> For a **remote screen** on a separate device (a spare Pi, mini PC, etc.), run them there instead, then point it at your printer's Moonraker (IP + port `7125`) in the setup wizard. See [Remote screen setup](docs/user/INSTALL.md#remote-screen-setup-run-on-a-separate-device). +On the printer, over SSH: -**One-line install:** -```bash -curl -sSL https://raw.githubusercontent.com/prestonbrown/helixscreen/main/scripts/install.sh | sh +```sh +curl -fsSL https://github.com/physicsG/helixscreen/releases/latest/download/install-fork.sh | sh ``` -The installer auto-detects your platform, downloads the correct binary, sets up the service, and launches the first-run wizard. To update: -```bash -curl -sSL https://raw.githubusercontent.com/prestonbrown/helixscreen/main/scripts/install.sh | sh -s -- --update -``` +That is a thin front-end for upstream's installer — platform detection, service +setup, backup/rollback and SHA256 verification are all the same code path. It only +pins the repo to this fork and skips upstream's CDN, which would otherwise serve +upstream's binary. -To install or roll back to a specific release (e.g. a last-known-good version), pass `--version` with the tag: -```bash -curl -sSL https://raw.githubusercontent.com/prestonbrown/helixscreen/main/scripts/install.sh | sh -s -- --version v0.99.111 +```sh +sh install-fork.sh --update # update in place +sh install-fork.sh --version u1-v0.99.116 # pin a specific build +sh install-fork.sh --local helixscreen-snapmaker-u1.zip # from a downloaded archive ``` -Add `--clean` to wipe HelixScreen's settings and start fresh (it asks for confirmation first; your Klipper/Moonraker config and G-code are untouched). Combine the two to reinstall a specific version with default settings: `--clean --version v0.99.111`. - -Also available through [KIAUH](https://github.com/dw-0/kiauh) as an extension. - -**Flashforge AD5M/Pro:** We provide a [ready-made firmware image](https://github.com/prestonbrown/ff5m) (Forge-X fork with HelixScreen pre-configured) — just flash from a USB drive. Or install manually on an existing Forge-X/Klipper Mod setup. - -**Android (experimental):** There is an Android build for watching and controlling a printer from a phone or tablet. It is not on Google Play yet, so download the APK from the [latest release](https://github.com/prestonbrown/helixscreen/releases/latest) and install it. `helixscreen-android-arm64-v.apk` covers essentially any modern phone or tablet. Nothing gets installed on the printer; the app just needs to reach Moonraker on your network. See [Android app](docs/user/INSTALL.md#android-app-experimental). - -See the [Installation Guide](docs/user/INSTALL.md) for detailed instructions, display configuration, and troubleshooting. - -## Development - -**Want to contribute? Start at [CONTRIBUTING.md](CONTRIBUTING.md)** — it routes you by what you want to do. New contributors follow a marked path: onboarding (environment + build + a 15-minute mental model) → an annotated first contribution → the per-subsystem architecture guide. +SSH on the U1: `Settings > Maintenance > Root Access`, or the `firmware-config` web +page. Credentials are `root` / `snapmaker`. -The short version, if you just want to see it run: - -```bash -# Check/install dependencies -make check-deps && make install-deps - -# Build -make -j - -# Run with mock printer (no hardware needed) — 'S' takes a screenshot; -# -v (INFO), -vv (DEBUG), -vvv (TRACE) for logging -./build/bin/helix-screen --test -vv - -# Run tests -make test-run -``` - -XML layouts hot-reload by default on native builds — edit `ui_xml/*.xml`, save, watch the running UI update live. - -**Test suite:** 5,000+ test cases across 600+ test files covering printer state, UI components, XML parsing, multi-material, and more. - -For the daily-workflow reference (run flags, logging, config, IDE setup), see [docs/devel/DEVELOPMENT.md](docs/devel/DEVELOPMENT.md). - -## FAQ - -**How is this different from GuppyScreen/KlipperScreen?** -More features, far lower RAM use (~15MB on embedded targets vs ~50MB for KlipperScreen), and actively developed. The lighter footprint means the printer you have or a Pi you've owned for years is plenty — no need to chase new SBC hardware. See the [comparison table](#why-helixscreen). - -**Can I run HelixScreen on a separate device instead of on my printer?** -Yes. Install it on any supported Linux device — a spare Pi, a mini PC, even your desktop — and enter your printer's IP in the wizard's Moonraker step. This is ideal when the printer is on the floor and you want the screen at your desk. Point it at Moonraker (port `7125`), not Mainsail/Fluidd. See [Remote screen setup](docs/user/INSTALL.md#remote-screen-setup-run-on-a-separate-device). - -**Which printers are supported?** -Any Klipper + Moonraker printer. 80+ models in the auto-detection database spanning Voron, Creality, QIDI, Anycubic, Flashforge, Sovol, RatRig, FLSUN, Elegoo, Prusa, Snapmaker, and more. The wizard auto-discovers your printer's capabilities even if it's not in the database. - -**What screen sizes are supported?** -800x480 and 1024x600 are the well-tested landscape sizes; 480x320 runs but is tight in places. Display rotation (0/90/180/270) with auto-detection. - -**Ultrawide (e.g. 1920x440) and portrait (e.g. 480x800) both work.** The layout engine detects either orientation, sizes the grid from the screen, and gives each its own home dashboard layout. Portrait also has dedicated layouts for the app shell, navigation bar, Print Status, Print Tune, Motion, Bed Mesh and the temperature graph; ultrawide has nothing beyond the dashboard. Panels without a dedicated layout use the adaptive fallback, which is why landscape is still the most polished of the three. Both keep gaining per-panel work, and both are open for contributions that need only XML, not C++ — see the [UI Contributor Guide](docs/devel/UI_CONTRIBUTOR_GUIDE.md). - -**What multi-material systems work?** -AFC (Box Turtle, ViViD), Happy Hare (ERCF, 3MS, Tradrack, Night Owl), ACE (Anycubic ACE Pro), AD5X IFS, Creality CFS, Snapmaker U1 (with RFID spool recognition), and tool changers (viesturz/klipper-toolchanger, including MedusaHC hotend changers). Full Spoolman integration for spool management. - -See [docs/user/FAQ.md](docs/user/FAQ.md) for the full FAQ. - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| SDL2 or build tools missing | `make install-deps` | -| Submodule empty | `git submodule update --init --recursive` | -| Can't connect to Moonraker | Check IP/port in settings.json | -| Wizard not showing | Delete settings.json to trigger it | -| Display upside down | Set rotation in settings or check `panel_orientation` in `/proc/cmdline` | - -See [docs/user/TROUBLESHOOTING.md](docs/user/TROUBLESHOOTING.md) for more solutions, or open a [GitHub issue](https://github.com/prestonbrown/helixscreen/issues). - -## Documentation - -### User Guides -| Guide | Description | -|-------|-------------| -| [Installation](docs/user/INSTALL.md) | Setup for Pi, Sonic Pad, K1, K2, AD5M, AD5X, QIDI | -| [User Guide](docs/user/USER_GUIDE.md) | Using HelixScreen — panels, overlays, settings | -| [Configuration](docs/user/CONFIGURATION.md) | All settings with examples | -| [Upgrading](docs/user/UPGRADING.md) | Version upgrade instructions | -| [FAQ](docs/user/FAQ.md) | Common questions | -| [Troubleshooting](docs/user/TROUBLESHOOTING.md) | Problem solutions | -| [Telemetry & Privacy](docs/user/TELEMETRY.md) | What data is collected (opt-in) | - -### Developer Guides -| Guide | Description | -|-------|-------------| -| [Development](docs/devel/DEVELOPMENT.md) | Daily workflow: run flags, logging, config, IDE setup | -| [Architecture](docs/devel/ARCHITECTURE.md) | Whole-app model + guide to the 15 architecture chapters | -| [LVGL9 XML Guide](docs/devel/LVGL9_XML_GUIDE.md) | XML syntax reference | -| [UI Contributor Guide](docs/devel/UI_CONTRIBUTOR_GUIDE.md) | Breakpoints, tokens, colors, widgets | -| [Changelog](CHANGELOG.md) | Release history | -| [Roadmap](https://github.com/prestonbrown/helixscreen/issues) | Feature timeline (labeled issues) | - -## Community - -**[Join the HelixScreen Discord](https://discord.gg/RZCT2StKhr)** — Get help, share your setup, request features, and follow development. - -**Also discussed in:** -- [FuriousForging Discord](https://discord.gg/Cg4yas4V) — #mods-and-projects ([jump to HelixScreen topic](https://discord.com/channels/1323351124069191691/1444485365376352276)) -- [VORONDesign Discord](https://discord.gg/voron) — #voc_works ([jump to HelixScreen topic](https://discord.com/channels/460117602945990666/1468467369407156346)) - -### Co-Maintainers Wanted - -We're looking for co-maintainers to help grow HelixScreen! You can contribute broadly across the project or own a specific area that interests you: - -- **Printer support** — Maintain builds and testing for specific platforms (Creality, QIDI, Flashforge, etc.) -- **Multi-material backends** — Own a filament system integration (AFC, Happy Hare, ACE, CFS, etc.) -- **UI/UX** — Help design and implement panels, overlays, and responsive layouts -- **Localization** — Maintain translations for your language -- **Documentation** — Keep guides accurate and help new users get started -- **Testing & CI** — Expand the test suite and maintain build infrastructure - -If you're interested, join the [Discord](https://discord.gg/RZCT2StKhr) and introduce yourself, or open a [GitHub Discussion](https://github.com/prestonbrown/helixscreen/discussions). - -**Bug Reports & Feature Requests:** [GitHub Issues](https://github.com/prestonbrown/helixscreen/issues) — please include your printer model and logs (`helix-screen -vv`) when reporting bugs. +--- -## License +## Branches -GPL v3 — See [LICENSE](LICENSE) for details. Third-party components and their licenses are listed -in [COPYRIGHT](COPYRIGHT). +| Branch | What it is | +|---|---| +| `develop/snapmaker-multiace` | **Default.** The U1 + multiACE work. Releases are cut from here | +| `main` | A clean mirror of upstream `main` — never committed to directly, so upstream merges stay conflict-free | -One exception: **[`lib/helix-xml/`](lib/helix-xml/) is MIT**, not GPL. It is a permanent fork of the -declarative XML UI engine that shipped inside LVGL core until v9.5 removed it and moved it to the -commercial LVGL Pro. We forked from the last MIT commit (`a15dcbeb5`, 2026-01-26) and keep our own -contributions to it under MIT so the engine stays usable as a standalone library. See -[`lib/helix-xml/README.md`](lib/helix-xml/README.md). +Release tags are `u1-v` (e.g. `u1-v0.99.116`), deliberately not `v*`: +upstream's tag would start a release pipeline this fork cannot complete. -## Acknowledgments +--- -**Inspired by:** [GuppyScreen](https://github.com/ballaswag/guppyscreen) (general architecture, LVGL-based approach), [KlipperScreen](https://github.com/KlipperScreen/KlipperScreen) (feature inspiration) +## Everything else -**Built with:** [LVGL 9.5](https://lvgl.io/), [Klipper](https://www.klipper3d.org/), [Moonraker](https://github.com/Arksine/moonraker), [libhv](https://github.com/ithewei/libhv), [spdlog](https://github.com/gabime/spdlog), [SDL2](https://www.libsdl.org/), and `helix-xml` (our MIT fork of LVGL's XML engine) +Features, supported printers, configuration, the user guide and troubleshooting are +all upstream's and unchanged — start at +**[upstream's README](https://github.com/prestonbrown/helixscreen#readme)** and the +[User Guide](docs/user/USER_GUIDE.md). -**AI-Assisted Development:** Built with [Claude Code](https://github.com/anthropics/claude-code) by [Anthropic](https://www.anthropic.com/) +Bugs in U1 or multiACE behaviour belong [here](https://github.com/physicsG/helixscreen/issues). +Anything that reproduces without a U1 is better reported +[upstream](https://github.com/prestonbrown/helixscreen/issues). diff --git a/docs/devel/HELIXCTL.md b/docs/devel/HELIXCTL.md index f8ecfc484f..8694761a34 100644 --- a/docs/devel/HELIXCTL.md +++ b/docs/devel/HELIXCTL.md @@ -754,7 +754,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/printers/SNAPMAKER_U1_SUPPORT.md b/docs/devel/printers/SNAPMAKER_U1_SUPPORT.md index d3e00fb0b6..5d9179918d 100644 --- a/docs/devel/printers/SNAPMAKER_U1_SUPPORT.md +++ b/docs/devel/printers/SNAPMAKER_U1_SUPPORT.md @@ -106,6 +106,36 @@ Manual packaging is also available: make package-snapmaker-u1 ``` +### Building the U1 on a fork (`.github/workflows/snapmaker-u1.yml`) + +`release.yml` can build this platform, but only on a `v*` tag, and its later jobs +need secrets a fork does not have (`R2_*`, Android signing, `WEBSITE_DISPATCH_TOKEN`). +`snapmaker-u1.yml` is the same toolchain + build + package steps with nothing +secret in them, so a fork can produce an installable build on its own: + +| Trigger | Result | +|---|---| +| push to `main`, `feat/**`, `fix/**` | builds, uploads `helixscreen-snapmaker-u1` artifact (30 days) | +| **Run workflow** button (`workflow_dispatch`) | same | +| pull request to `main` | same | +| push a **`u1-v*`** tag | the above, plus a GitHub Release on that repo with the `.zip`, `.tar.gz`, `install-fork.sh` and `SHA256SUMS` | + +The U1 is the one platform a fork can build unaided: `docker/Dockerfile.snapmaker-u1` +is Debian Trixie plus `crossbuild-essential-arm64` from apt, with no tarball fetch +and no private registry (`ad5x`, by contrast, pulls its toolchain from upstream's +own release assets). + +Tag prefix is `u1-v`, **not** `v` — a `v*` tag would also start `release.yml`, +which then fails on the missing secrets: + +```bash +git tag u1-v0.99.114 && git push origin u1-v0.99.114 +``` + +This workflow also extracts the toolchain image's CA bundle into +`build/snapmaker-u1/certs/`, which `release-snapmaker-u1` then packages — a step +`release.yml` does not perform, so upstream CI tarballs ship without `certs/`. + ## Installation ### Prerequisites @@ -130,6 +160,59 @@ curl -sSL https://releases.helixscreen.org/install.sh | sh The installer auto-detects the U1 platform, downloads the correct aarch64 binary from the release CDN, deploys platform hooks, and starts HelixScreen. Re-run to upgrade. +### Installing a fork's build + +`scripts/install-fork.sh` is a thin front-end for the same installer — it sets +the two things a fork install needs and hands over, so the install path itself +(platform detection, service setup, backup/rollback, SHA256 verification, +`--update` / `--uninstall` / `--local`) is not duplicated: + +```sh +GITHUB_REPO=/helixscreen # which repo to install from +HELIX_GITHUB_ONLY=1 # that repo's GitHub releases ONLY +``` + +The second is the load-bearing one. The installer normally tries the upstream +CDN (`releases.helixscreen.org`) and HTTP mirror *before* GitHub, and those +serve upstream's artifacts — so setting `GITHUB_REPO` alone would resolve +upstream's version number and install upstream's binary under it. + +```sh +# From the fork's latest u1-v* release (install-fork.sh is attached to each) +curl -fsSL https://github.com//helixscreen/releases/latest/download/install-fork.sh | sh + +# From a branch that carries fork support, before any release exists +HELIX_FORK_REF= sh -c "$(curl -fsSL https://raw.githubusercontent.com//helixscreen//scripts/install-fork.sh)" + +# Offline: scp the archive over, unpack, and run the copy shipped inside it +sh install-fork.sh --local helixscreen-snapmaker-u1.zip +``` + +Flags pass straight through to the installer. `GITHUB_REPO` overrides the +default fork; `HELIX_FORK_REF` picks the branch/tag the installer is fetched +from when no release provides one. + +Where the installer itself comes from, in order: `install.sh` beside the +script (an unpacked archive), then the fork's **latest release** +(`releases/latest/download/install.sh` — the installer that built the binary it +will install), then `HELIX_FORK_REF`. Whichever it finds is **refused unless it +knows about fork installs** (`HELIX_GITHUB_ONLY`): an older `install.sh` +hard-assigns `GITHUB_REPO` and would take the environment this script set, +ignore it, and quietly install upstream's binary from upstream's CDN. That is +why the release is preferred over a branch, and why `HELIX_FORK_REF=main` fails +loudly until the fork's `main` carries the new installer. + +Two consequences for the fork's releases: they are published as full releases, +not prereleases (`/releases/latest` — the API the installer discovers versions +through, and the download URL above — resolves to the newest *non*-prerelease, +so a prerelease-only repo has no "latest"), and a `u1-v*` tag passes the +installer's version normalisation untouched (only bare `0.99.x` gets a `v` +prefixed), so `--version u1-v0.99.114` and the release URLs agree. + +Both the Moonraker `[update_manager helixscreen]` block and `release_info.json` +are written with whichever repo the install came from, so the printer offers +*that* repo's releases as updates rather than upstream's. + ### Build ```bash diff --git a/docs/images/u1/ams-overview.png b/docs/images/u1/ams-overview.png new file mode 100644 index 0000000000..a86dda2e65 Binary files /dev/null and b/docs/images/u1/ams-overview.png differ diff --git a/docs/images/u1/filament-environment.png b/docs/images/u1/filament-environment.png new file mode 100644 index 0000000000..ead27d73c7 Binary files /dev/null and b/docs/images/u1/filament-environment.png differ diff --git a/docs/images/u1/pairing-prompt.png b/docs/images/u1/pairing-prompt.png new file mode 100644 index 0000000000..fbd900e87a Binary files /dev/null and b/docs/images/u1/pairing-prompt.png differ diff --git a/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt b/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt index 4a75ab05e2..7725c7a9fd 100644 --- a/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt +++ b/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt @@ -125,6 +125,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 @@ -330,6 +331,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 325711d6e6..d8e9a18745 100644 --- a/include/ams_backend.h +++ b/include/ams_backend.h @@ -776,6 +776,37 @@ 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 the printer-side system arrange its own homing for filament * load/unload ops, so HelixScreen should neither prompt nor send G28? @@ -795,6 +826,31 @@ class AmsBackend { 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 @@ -1285,6 +1341,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). @@ -1677,6 +1963,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 // ======================================================================== @@ -2330,6 +2651,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 064fdaad94..b872bec5ea 100644 --- a/include/ams_backend_ace.h +++ b/include/ams_backend_ace.h @@ -116,6 +116,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 16e8a04995..f9da84f1f9 100644 --- a/include/ams_backend_mock.h +++ b/include/ams_backend_mock.h @@ -84,6 +84,15 @@ class AmsBackendMock : public AmsBackend { * * Scoped to tool-changer mode: the mock also stands in for lane-based systems, * where the inherited LOADED-status rule is the correct one. + * + * Snapmaker/multiACE answer separately, and are not tool_changer_mode_: only + * a HEAD can be unloaded from the toolhead, exactly as AmsBackendSnapmaker + * answers (false for every index >= NUM_TOOLS). Multiace mode is PARALLEL, so + * under the base rule every filled ACE bay read "already at the toolhead" — + * the slot menu greyed Load on all four bays of an ACE and offered Unload on + * all four. Real hardware does neither, so the one scenario the mock exists + * to exercise — swapping bays on an ACE-fed head — could not be reached in + * `--test` at all. */ [[nodiscard]] bool can_unload_from_toolhead(int slot_index) const override; @@ -598,6 +607,41 @@ 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; + + /// Mounting the head an ACE bay feeds moves the carriage and feeds nothing, + /// so `T{n}` is not that bay's load — exactly as AmsBackendMultiAce answers. + /// + /// Without this the mock kept the base's blanket `true`, and plan_load()'s + /// swap arm substituted `change_tool(mapped_tool)` for the bay's load. Every + /// bay of ACE 1 maps to tool 0, so a bay swap dispatched a tool change the + /// mock then refused as out of range — reachable only once the bay gating + /// above was corrected, which is how it stayed hidden. + [[nodiscard]] bool change_tool_completes_load(int slot_index) const override; + + /// Mirror the real backends' step models so the step bar under test is the + /// one that ships. + /// + /// The mock never overrode this, so every mocked backend fell through to the + /// sidebar's legacy coarse bar (Heat → [tip] → Feed/Retract). An unload on a + /// mocked U1 rendered two steps where the real one renders four, and the + /// firmware-phase bar — including the multiACE swap model this exists to + /// show — had no mock representation whatsoever. + [[nodiscard]] OperationStepModel get_operation_step_model(StepOperationType op) const override; + [[nodiscard]] lv_subject_t* get_operation_step_index_subject(StepOperationType op) override; + /** * @brief Configure (or clear) the simulated Snapmaker print task. * @@ -778,6 +822,15 @@ class AmsBackendMock : public AmsBackend { */ void set_action(AmsAction action, const std::string& detail); + /// Publish a U1 firmware phase id (AmsBackendSnapmaker::*_PHASE_BASE + n) + /// while a mocked Snapmaker/multiACE operation runs. No-op in every other + /// mock mode, which has no phase model and uses the coarse AmsAction bar. + void set_operation_phase(int phase); + + /// Walk the unload half + the ACE-side fetch of a multiACE bay swap. + /// Returns false if the operation was cancelled partway. + bool run_multiace_swap_prologue(InterruptibleSleep interruptible_sleep); + /** * @brief Execute load operation with optional multi-phase sequence * @param slot_index Slot being loaded from @@ -890,6 +943,20 @@ 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}}; + + /// The head the last multiace load/unload targeted, or -1. + /// + /// Mirrors AmsBackendMultiAce::op_target_head_ and exists for the same + /// reason: get_operation_step_model() takes no slot, and `current_slot` is + /// not the head — after a bay load the mock sets it to the BAY's global + /// index, so keying the swap model off it picked the plain load model for + /// every swap after the first. + int multiace_op_head_ = -1; /// Declared remap route outside Snapmaker mode. Native by default: every /// non-Snapmaker mode stands in for a table-owning backend. RemapStrategy remap_strategy_ = RemapStrategy::Native; diff --git a/include/ams_backend_multiace.h b/include/ams_backend_multiace.h new file mode 100644 index 0000000000..6602cbd516 --- /dev/null +++ b/include/ams_backend_multiace.h @@ -0,0 +1,491 @@ +// 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. + /// + /// Except mid-SWAP, where the same preload_finish is a boundary rather than + /// an end: do_load_filament() emitted `ACE_UNLOAD_HEAD` and `ACE_LOAD_HEAD` + /// back to back, so resolving the operation here dropped the action to IDLE + /// between the halves -- the step bar hid itself, the action buttons came + /// back, ownership was released (so the load half was then re-detected as a + /// foreign operation) and a post-op nozzle cooldown was armed while the load + /// half still needed the heat. swap_in_flight_head_ suppresses exactly that + /// one resolution and nothing else. + [[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. + if (head < 0 || head >= NUM_TOOLS || head_kind_[head] != HeadSource::ACE) { + return false; + } + return swap_in_flight_head_ != head; + } + + /// 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, plus the two things an ACE adds to it. + /// + /// UNLOAD: the last step names 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 it (unload phase 3). + /// + /// LOAD_SWAP: a whole model of its own, because on an ACE-fed head a swap + /// really is one operation with two halves and an ACE-side gap in the middle + /// (see the .cpp). The base has no swap of its own -- a plain U1 lane is + /// PARALLEL and feeds its own nozzle -- so it answers LOAD_SWAP with the + /// load model, which is what put "Feed filament" under a retract. + [[nodiscard]] OperationStepModel get_operation_step_model(StepOperationType op) const override; + + /// Does loading @p slot_index have to retract what is at its head first? + /// + /// Answered per BAY, which is the only place the question has a real answer: + /// the base rule reads the SYSTEM's `filament_loaded` / `current_slot`, which + /// on a U1 describe the mounted carriage and say nothing about which bay is + /// seated at the head this bay feeds. Heads and feeder lanes keep the base + /// answer. + /// + /// Shares one implementation with do_load_filament()'s pre-unload, so the + /// command that goes out and the bar that is drawn cannot disagree. + [[nodiscard]] bool needs_unload_before_load(const AmsSystemInfo& info, + int target_slot) 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; + + /// THE swap rule, and the only copy of it: loading a bay onto a head that + /// already holds a DIFFERENT bay has to retract that one first. Caller must + /// hold mutex_. False for a head, an unbound bay, or a bay already seated. + [[nodiscard]] bool bay_load_needs_unload_locked(int slot_index) const; + + /// The head the last dispatched load/unload NAMED, or -1 for none yet. + /// + /// get_operation_step_model() needs to know which head the operation is + /// about, and it takes no slot argument -- the sidebar asks it for a whole + /// bar, not per slot. It used to read `current_slot`, the head on the + /// CARRIAGE, which is the same head only in `mode="head"`. In `mode="multi"` + /// bay *s* feeds head *s*, so acting on a bay whose head is not mounted + /// consulted the wrong head's source kind and mislabelled the bar. + int op_target_head_ = -1; + + /// Has the firmware confirmed the in-flight swap is actually running? + /// Guards the latch against the pre-start lag; see apply_swap_phase_locked(). + bool swap_progress_seen_ = false; + + /// The head a UI-initiated swap is running on, or -1. + /// + /// Set by do_load_filament() when it emits the pre-unload, cleared when the + /// operation reaches a terminal channel_state. Two jobs, both of which need + /// to know that the unload and the load are one operation: + /// preload_finish_ends_unload() must not resolve at the boundary, and + /// apply_swap_phase_locked() fills the ACE-side gap after it. + int swap_in_flight_head_ = -1; + + /// Retire the swap latch once the swap it guards is over. Caller must hold + /// mutex_; runs after AmsBackendSnapmaker::handle_status_update(), whose + /// channel_state parse is the input. + /// + /// The latch exists so preload_finish cannot resolve the operation at the + /// boundary BETWEEN the two halves; this is the other end of it. It does not + /// synthesize a phase: the gap between the halves measured ~4s of `inited` + /// on a live U1, not the long blind window the design assumed, so the bar + /// holds on the retract step across it instead of gaining a row nobody sees. + void apply_swap_phase_locked(bool& changed); + + /// 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; + + /// multiACE's own swap telemetry, off the `ace` object. Used ONLY to say + /// "the ACE is doing something the U1 cannot see" -- it drives the + /// indeterminate busy label, never a step index and never a gate. The + /// `swap_phase` string is captured for the log and for classify-time error + /// text; its enumeration is unknown (every capture so far is "idle"), so + /// nothing branches on a specific value. + bool ace_swap_in_progress_ = false; + std::string ace_swap_phase_ = "idle"; + /// `ace.status`, system-wide. "ready" is the known idle value. + std::string ace_status_ = "ready"; + /// `last_swap_result.ts`, or < 0 before the first frame carrying one. The + /// block PERSISTS across sessions, so only a change of timestamp is an + /// event; the first value seen is history and is recorded, not surfaced. + double last_swap_ts_ = -1.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 1f0e22a49f..e6a1fcbff6 100644 --- a/include/ams_backend_snapmaker.h +++ b/include/ams_backend_snapmaker.h @@ -72,6 +72,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; } @@ -109,6 +125,21 @@ class AmsBackendSnapmaker : public AmsSubscriptionBackend { [[nodiscard]] AmsSystemInfo get_system_info() const override; [[nodiscard]] SlotInfo get_slot_info(int slot_index) const override; + /// Phase-id space for the UNLOAD direction: 0=Home 1=Select 2=Heat 3=Retract. + static constexpr int UNLOAD_PHASE_BASE = 0; + /// Phase-id space for the LOAD direction (load / preload / manual_sta): + /// 10=Home 11=Select 12=Heat 13=Feed 14=Purge. + /// + /// Disjoint from the unload space ON PURPOSE. The two used to share 0..4, + /// justified by "only one operation is live at a time" -- true for a plain + /// load or a plain unload, and false for a SWAP, which runs both directions + /// inside one user-initiated operation. An unload phase then indexed + /// whatever bar happened to be on screen: on a multiACE bay swap, + /// `unload_doing` (3) highlighted the load model's "Feed filament" for the + /// length of the retract. Separate spaces make an index unambiguous, so one + /// bar can carry both halves and a mismatched phase is *recognisably* + /// foreign rather than silently plausible. + static constexpr int LOAD_PHASE_BASE = 10; // Operation step bar. The U1 firmware reports a granular channel_state that // classify_channel_state maps to a per-direction step index published via the // ams_operation_phase subject, so the step model and its driving index live in @@ -179,19 +210,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; @@ -309,7 +372,18 @@ class AmsBackendSnapmaker : public AmsSubscriptionBackend { [[nodiscard]] std::vector last_print_tool_mapping() 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: @@ -319,13 +393,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; - /// Firmware routing: logical tool index -> physical head index, mirrored from /// print_task_config.extruder_map_table (32 logical entries, 4 heads). /// @@ -383,6 +508,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. @@ -416,10 +554,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 751fa5b7c8..61c81b6f01 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,47 @@ inline StepOperationResult detect_step_operation(AmsAction action, AmsAction pre bool filament_loaded, bool is_active_action) { StepOperationResult result; + // 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 + } + + // The same rule in the LOAD direction: a swap bar that is loading is never + // demoted to a fresh-load one. + // + // The guard above fixed only the unload direction, and the identical + // transient reaches this one. A UI-initiated swap that preheats first spends + // the whole wait at HEATING and then IDLE, which reads as "finished" — so at + // dispatch the operation looked foreign, the arm below saw LOADING out of + // IDLE, and the 7-step multiACE swap bar was rebuilt as the 5-step load one + // with the retract half already discarded. + // + // Ownership is re-armed at dispatch now, which is the root fix; this is the + // matching structural guard, and it also covers a swap the printer started + // on its own. A genuine fresh load never reaches here with a swap bar up: + // recreate_step_progress_for_operation() only builds LOAD_SWAP when the + // caller planned one. + if (current_op == StepOperationType::LOAD_SWAP && action == AmsAction::LOADING) { + return result; // no change — keep the swap 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 8a41d320d9..4b2bf70a22 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; } /** @@ -1701,6 +1712,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 0f4155e7e1..47b2cc63d0 100644 --- a/include/filament_op_dispatch.h +++ b/include/filament_op_dispatch.h @@ -47,6 +47,18 @@ struct FilamentOpPlan { FilamentRefusal refusal = FilamentRefusal::None; AmsCall ams_call = AmsCall::None; int ams_arg = -1; ///< Slot index, or tool number when ams_call == ChangeTool + /// The operation includes retracting what is already at the toolhead, so the + /// UI should show a SWAP rather than a fresh load. + /// + /// Separate from `ams_call` because they answer different questions. The + /// sidebar used to derive the display from the dispatch — `ams_call == Load` + /// meant fresh, anything else meant swap — which holds only while "needs an + /// unload first" and "is dispatched as a tool change" agree. On a multiACE + /// ACE bay they do not: `change_tool_completes_load()` is false there (`T3` + /// mounts the head and feeds nothing), so the plan is a plain Load, while the + /// backend still emits `ACE_UNLOAD_HEAD` before `ACE_LOAD_HEAD`. Every swap + /// onto an ACE-fed head therefore rendered a fresh-load bar. + bool is_swap = false; }; /** @@ -65,6 +77,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; /// AmsBackend::is_bypass_active(). Distinct from /// !requires_slot_selection_for_load(): that answer is also false for a /// backend that simply does not need a slot, and the two want opposite @@ -167,14 +182,22 @@ 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. + // Whether an unload is part of this operation — asked once, and NOT + // conflated with which command carries it (see FilamentOpPlan::is_swap). + const bool swap = caps.needs_unload_before_load && sys.current_slot != target_slot; + if (swap && caps.change_tool_completes_load) { 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, - slot_info->mapped_tool}; + slot_info->mapped_tool, swap}; } } - return {FilamentTier::AmsBackend, FilamentRefusal::None, AmsCall::Load, target_slot}; + return {FilamentTier::AmsBackend, FilamentRefusal::None, AmsCall::Load, target_slot, swap}; } if (macro_available) { diff --git a/include/filament_slot_override_store.h b/include/filament_slot_override_store.h index e0af7f712a..f6102b967f 100644 --- a/include/filament_slot_override_store.h +++ b/include/filament_slot_override_store.h @@ -306,6 +306,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 b6993832a5..c8a9d28e46 100644 --- a/include/printer_discovery.h +++ b/include/printer_discovery.h @@ -333,13 +333,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 @@ -636,6 +655,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 @@ -655,6 +705,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) @@ -1720,6 +1778,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 e24bc035df..1b4cb7aace 100644 --- a/include/ui/ams_drawing_utils.h +++ b/include/ui/ams_drawing_utils.h @@ -17,7 +17,7 @@ class AmsBackend; // Forward declaration for compute_system_tool_layout() * Consolidates duplicated drawing code used by ui_ams_mini_status, * ui_panel_ams_overview, ui_ams_slot, and ui_spool_canvas. */ -namespace ams_draw { +namespace helix::ams_draw { // ============================================================================ // Color Utilities @@ -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. @@ -198,6 +209,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); @@ -236,6 +256,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 @@ -327,4 +356,9 @@ struct ToolBadgeLabels { */ SystemToolLayout compute_system_tool_layout(const AmsSystemInfo& info, const AmsBackend* backend); -} // namespace ams_draw +} // namespace helix::ams_draw + +/// Unqualified `ams_draw::` is the spelling at ~230 call sites across the AMS +/// drawing code. The namespace itself lives under helix:: (every HelixScreen +/// declaration does); this alias keeps the short spelling working. +namespace ams_draw = helix::ams_draw; diff --git a/include/ui_ams_context_menu.h b/include/ui_ams_context_menu.h index 7a8d9067eb..43c80eb1ce 100644 --- a/include/ui_ams_context_menu.h +++ b/include/ui_ams_context_menu.h @@ -58,7 +58,14 @@ class AmsContextMenu : public ContextMenu { 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 @@ -79,6 +86,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 }; @@ -91,9 +99,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 @@ -151,19 +162,21 @@ 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 them is correct rather than merely tolerable: the values are set + // in on_created() immediately before the menu is shown. // - // 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. + // 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 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 /// 1 = this backend's load/unload mount and unmount a tool, so the menu @@ -174,7 +187,15 @@ class AmsContextMenu : public ContextMenu { static lv_subject_t slot_unload_hint_subject_; static lv_subject_t slot_unload_hint_visible_subject_; static char slot_unload_hint_buf_[128]; + /// 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 slot_source_external_subject_; static bool 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; @@ -211,6 +232,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(); @@ -225,6 +248,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; @@ -269,7 +294,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? // @@ -317,8 +343,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. // @@ -354,6 +387,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 30cfef7e28..83881d1d44 100644 --- a/include/ui_ams_sidebar.h +++ b/include/ui_ams_sidebar.h @@ -221,7 +221,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 "