diff --git a/.gitignore b/.gitignore index 695cb8f3b33..7bb24ce4951 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ resources/profiles/user/default deps_src/build/ .claude/ .hermes/ -CLAUDE.md \ No newline at end of file +CLAUDE.md +# UI reference snapshots — third-party imagery, design reference only (see the folder's README) +ui-snapshots-inspiration/ diff --git a/CLAUDE.md b/CLAUDE.md index d7fa733073c..408df1c0b15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,292 @@ Run individual test suites: ./tests/sla_print/sla_print_tests ``` +### The U1 Device page (`resources/web/device_page/`) + +The reconstructed U1 web UI needs **no rebuild** — `build/resources` is a symlink to +`resources/`, the HTTP server reads files per request and sends no cache headers, so +edit and reload. Only C++ changes need `ninja`. + +**It is the only Device page.** Nothing loads `flutter_web?path=2` any more and the +Original / Rebuilt switcher is gone. That was not a matter of deleting a button: the +shipped page was the **only** caller of `sw_UpdateMachineFilamentInfo`, which despite its +name never reaches the printer — it writes Orca's `machine_filaments`, the one source the +Prepare sidebar's filament combos have. `core/orcasync.js` does that now, on every state +change, guarded by a comparison and re-forced on reconnect (Orca clears the record when a +machine disconnects). **What writes the PRINTER is a macro**, not that command: +`SET_PRINT_FILAMENT_CONFIG` (single-quoted `KEY='value'`, `SAVE='1'`) and +`SET_PRINT_PREFERENCES` (bare `KEY=value`, and Auto Leveling goes out as **`BED_LEVEL`** +though the machine reports `auto_bed_leveling`). Two panels sent the flat patch to the +Orca command instead and did nothing at all on a real machine, silently, because neither +awaits its own request. The **login is untouched and stays Orca's**: `sw_UserLogin` opens +the native `SMUserLogin` on `id.snapmaker.com`, no reconstructed page has a login form, +and the rail asks for that dialog rather than offering one. The whole account is +[docs/u1-webui/02-device-page/12-orca-integration.md](docs/u1-webui/02-device-page/12-orca-integration.md). + +**Where things are.** The page has two destinations in one webview — Device control and +Storage — switched by the left rail with no reload. +[`js/registry.js`](resources/web/device_page/js/registry.js) lists them and their panels; +everything inside `.content` is built from it. **One panel is one directory**, holding all +three of its files: + +``` +js/views/device-control/camera/ + camera-panel.js what it reads, and its mount/update + camera-view.js its DOM: built once, then patched + camera-commands.js everything it can ask the machine to do +``` + +The four control panels sit in **two unequal columns**, not a 2x2 grid: a main column +that takes what is left, and a side column pinned at `--col-w` (830) holding Control and +Filament. A panel names its own column in its declaration (`column`, `grow`); the +destination names them in `registry.js`. Below a 1600px window it is one centred column +and none of that applies. + +**The Filament panel integrates with multiACE, a plugin — not with "the ACE".** +[decay71/multiACE](https://github.com/decay71/multiACE) is a third-party Klipper plugin +deployed onto a U1; the `ace` object, the macros and the override store are all its, and a +stock U1 has none of them. **Everything about it lives in one module, +[`shared/js/multiACE.js`](resources/web/shared/js/multiACE.js)** — macros and the line +builder, constants, the state model, the bay merge, **and the three forms an ACE is +drawn in** (`ACE_ART` and its builders, from `docs/ace-mmu/16-ace-visuals.md` — the same +standard the C++ Prepare page draws from) — named for the plugin rather than the +hardware. `MachineState.ace()` calls into it. It was spread over three files, and that +is how the panel came to read bay identity from a source that does not carry it. + +**Whether a filament may be edited takes the machine's permission AND the absence of a +tag.** `print_task_config.filament_edit[i]` is the printer's own per-slot permission — +literally `allowed_edit` in its `print_task_config.py`, enforced by +`SET_PRINT_FILAMENT_CONFIG` refusing an `official` slot without `FORCE=1`. Read it, never +re-derive it. But it is a **latch**: the same firmware function sets +`filament_official[ch] = False` on every write, so one edit unlocks a tagged spool until +the tag is read again on its next load. So the page requires **both** — `allowedEdit` (the +machine's, verbatim) *and* no tag; `editable` in `filaments()` is that pair, and the two +are separate fields so it is visible that the panel is stricter than the machine rather +than disagreeing with it. Stricter is allowed; looser never is. The mark, the label and what +the click opens all come from that one bit, and read-only means **no inputs and a single +Close** — not disabled fields. **Whether the spool carries a tag is a separate fact and +gets its own mark**: the green `RFID` word is the four-slot form's, and a printer with an +`ace` object never draws that form — its stock-feeder heads are feeder boxes inside cards, +so a tagged feeder spool had no tag drawn at all. `.ace-tag` is that mark, mirrored about +the roll from `.ace-prov`. Both are in `cardSig()`, because **a card whose signature omits +something it draws never repaints for it** — and a tag arrives seconds after the filament. An ACE **bay** is a different subsystem: bays carry no tags +here (`rfid: 0`, `head_tag_seen: {}`), their identity is multiACE's override store, and +writing one from the panel is unbuilt — so **every bay wears the eye**, whatever named it, +and only the PROV *word* still says which source it was. A pencil goes back on a bay when +`ACE_SPOOL_ASSIGN` does. + +**UI copy says what a control is or what state it is in — and stops there.** No +explaining what multiACE is in a tooltip, no reasoning in a dialog. The explanations +belong in `docs/u1-webui/`, and a reader of a hover has not asked for one. + +**A colour with alpha zero is an absence, not black.** `cssColor` reads the alpha in both +forms the wire uses. multiACE wipes every stock-feeder head's identity when the machine +enters head mode — `_clear_filament_display()` sends `FILAMENT_TYPE="" VENDOR="" +FILAMENT_COLOR_RGBA=00000000` — and the filament stays physically in the head while it +happens. It does **not** touch `filament_detect`, so on a tagged head the identity is +still there in the other object: `filaments()` falls back to the spool's own record when +the working copy has none (`fromTag` says which was used), which is multiACE's +`rfid → override → derived` applied one level up. The feeder box then has a **bay's three +states**: named, occupied-and-unnamed (`#B7BDC6` and `?`, for a wiped head with no tag), +empty (the checkerboard). Occupancy comes from `filament_exist` and `headLoaded()`, never +from the identity that was just wiped. Opaque black is `000000FF` and still draws black. + +**And it says it in words, not in the machine's schema.** A *field* name (`channel_state`, +`print_task_config`, `hum_level2`) says where the page read something, which is the page's +business; `channelWord()` and `activity.js` turn those into words the way a label should +be written. **A macro name is not an exception** — it was one, on the ground that it says +what will be sent, and it read as a G-code console once it was under every verb on the +toolhead sheet. The trace pane is where the wire belongs, and it already has it. Nor does +a dialog quote a reply: an `ok` is not a yes here, so `JSON.stringify(reply)` on screen is +both noise and misleading. + +**The Filament panel has two shapes and the machine picks.** A printer that reports no +`ace` Klipper object gets the four slots the page always drew; one that reports it gets +four toolhead cards, two by two, each with its own header choosing what feeds that head - +stock feeder, one of up to four ACE units, or hand-fed - the source drawn under it as a +cabinet or a feeder module, and a tube into the head's inlet. `ace` is **not** on the +subscription (that list is pinned to the shipped bundle's), so it is read on its own by +`session.refreshAce()` and re-read after anything that changes it. **What is in each BAY is not machine state.** The `ace` object carries no per-bay +identity — the raw slots read `{material:"", brand:"", rfid:0}` — and multiACE keeps the +names in an override store. Orca's Prepare page polls the merged `/multiace/api/state` +from C++ and saw filament the panel drew as `?`. nginx serves `/multiace/` with no CORS +header, but Moonraker on :7125 reflects the Origin and the store is a file under its +`config` root, so the page reads it directly: `syncBays()` → `store.aceBays`, merged with +multiACE's own precedence **rfid → override → derived**. Five things that are in +the code because the machine or the plugin said so: **a swap is `ACE_UNLOAD_HEAD` then +`ACE_LOAD_HEAD`, never `ACE_SWAP_HEAD`** — that one is the *print's* swap, it opens with a +`G1 Z2` hop off the part and Klipper refuses it on an unhomed Z, which is why a swap from +the panel said "Must home Z axis first" when a load and an unload never do; multiACE's own +dashboard and HelixScreen both send the pair, and neither half moves Z. (The background +family is not the same case and is unchanged: there is no `ACE_BG_LOAD`, and +`ace_bg_swap.py` emits no motion G-code at all — it drives the parked head's extruder +through a private trapq.) **`head_ace` does not answer "what feeds this head"** +(resolve `head_manual` → `head_feeder` → `head_ace`, in that order); **no bay has a +level** — `spool_binding` is empty, so a disc is a colour and not a gauge; and **an `ok` is +not a yes** — `ACE_SET_AUTO_DRY THRESHOLD=` returns `ok` and changes nothing, and +`ACE_DRY DURATION=` is in **minutes**, so a dialog offering hours would have dried for +four. Every macro argument was settled by sending it and reading the object back. The macro +surface is evidence too: `tools/ace_macros.py` reads `printer.gcode.help` into +`data/ace-macros.json` and `check_coverage.py` holds its table to it. The reasoning is +[docs/u1-webui/02-device-page/10-multiace-filament.md](docs/u1-webui/02-device-page/10-multiace-filament.md); +what to build next is +[11-multiace-handover.md](docs/u1-webui/02-device-page/11-multiace-handover.md). + +with `js/core/` for what every view needs (`dom`, `render`, `pending`, `store`, +`session`, `connection`, `overlay`, `diag`, `mock`, `thumbs`) and `js/widgets/` for the +rail, the trace pane and shared art. A panel is handed **its own commands and nothing +else**. Full rationale: [docs/u1-webui/02-device-page/09-restructure.md](docs/u1-webui/02-device-page/09-restructure.md). + +### The print-processing popup (`resources/web/print_processing/`) + +The other embedded surface, **rebuilt from the shipped one** rather than from a design. +The widget tree is all in `main.dart.js` and the constants resolve, so +[the specification](docs/u1-webui/03-print-processing/original-dialog-mockup.html) is +measurement: `B.ch = EdgeInsets.all(12)`, `B.m4 = BoxConstraints(120,180,0,inf)`, the +80 x 100 filament card, the three dropdowns' item heights and offsets. `check_mockup.py` +holds the mockup to those numbers and `drive/print-dialog.js` holds the page to the same +ones after a real layout. + +**Three things the shipped dialog does that no screenshot showed**, because both captures +were taken with the filament section empty: Edit Filament is a **Wrap of 80 x 100 cards**, +not rows; there is a **fifth section**, a nozzle-mismatch banner (`A.R5`); and **a +toolhead whose type or nozzle does not match is passed `enabled: false` and cannot be +picked at all** - half opacity, a warning icon and one of two tooltips. That last one is +behaviour, and the drive script checks it by clicking a refused item and asserting +nothing moved. + +**The two sources are never derived from each other.** The FILE's filaments come from +`sw_GetFileFilamentMapping` - **parallel arrays**, and the plate thumbnail - and the +MACHINE's from `print_task_config` plus each extruder's `nozzle_diameter`. The bundle +names `sw_GetMachineFilamentMapping` for the machine side and Orca does not implement it. +A file filament's nozzle is the one **inferred** thing here: `nozzle_diameters` is +per-extruder and `filament_type` per-filament, and a real plate reports seven of one and +four of the other. + +**One mock for both surfaces**, in `shared/js/mockhost.js`: the U1 *and* Orca's half of +this dialog, with the file's filaments and the machine's on one `printer` object. A +surface-local mock that invented both lists could never report a mismatch - it would be +testing its own fixture. It also enforces the host's preconditions, so +`sw_StartLocalPrint` refuses `{}` there exactly as the C++ does. + +**Testing it against a real U1** needs a real plate, because outside Orca there is none: + +```bash +R=resources/web/shared/tests +python3 $R/run_webkit.py --real --size 714x750 --watch \ + --gcode ~/models/plate_1.gcode --page web/print_processing/index.html +``` + +`u1_bridge.py` parses the file's own trailing metadata to answer Orca's half. +`sw_StartLocalPrint` is refused unless `--allow-print` is passed: it starts a print, and a +suite that can start a print is a suite that will. The whole account is +[resources/web/print_processing/README.md](resources/web/print_processing/README.md). + +Iterate on it with `run_webkit.py`, which drives the real page in **WebKitGTK — the +engine Orca's own webview uses**. It needs a display (WSLg provides one); playwright and +the vendored chromium do not work here. + +```bash +# checks against the simulated printer, then exits +python3 resources/web/shared/tests/run_webkit.py --shots /tmp/shots + +# --size matters: the two-column layout only engages at 1600 and above, so the +# default 1500 checks the single-column one instead +python3 resources/web/shared/tests/run_webkit.py --size 1920x1080 + +# the same page against the REAL printer, with no Orca at all +python3 resources/web/shared/tests/run_webkit.py --real --watch # stays open +python3 resources/web/shared/tests/run_webkit.py --real --drive script.js +python3 resources/web/shared/tests/run_webkit.py --real --device-ip 192.0.2.1 + +# the SHIPPED Flutter bundle instead of the reconstruction (implies --real) +python3 resources/web/shared/tests/run_webkit.py --original --sn --watch +``` + +- `--watch` keeps the window open until it is closed, and the terminal becomes a live + trace of what each click sends. +- `--size WxH` sets the window, which the Device page's layout depends on - see above. +- Committed drive scripts live in `resources/web/shared/tests/drive/`: the DOM walker, + the camera panel against the simulator and against a printer, the multiACE filament + card, the no-printer branch, and the print-dialog mockups. See its README - they were + re-written from scratch every time before. +- `--page PATH` loads any other surface under `resources/` instead of the Device page, + which is how the print-dialog mockups are driven: + `--page 'web/print_processing/mockups/option-b.html?scenario=mismatch'`. +- `--drive FILE` runs JavaScript in the live page; the script reports by setting + `window.__report`. This is how hardware behaviour gets measured rather than assumed. +- `--device-ip` points the saved device somewhere unroutable, to exercise the page with + no printer there. +- `--original` loads the real Snapmaker bundle instead of the reconstruction, and + reaches a connected page with live telemetry. Add `--sn `: Orca's config can hold + stale device records and the bundle tries to connect every one it is handed. What it + took is in `docs/u1-webui/tools/harness/README.md` - chiefly that Orca posts replies + as a JSON **string**, which the reconstruction's client parses either way and the + bundle does not. +- **`--real` needs Orca closed** — it authenticates with the same saved `clientId`, and + a broker evicts the older holder. It is a second host speaking Orca's contract + (`docs/u1-webui/tools/u1_bridge.py`), so it proves the page and the printer agree, not + that Orca agrees. + +Use it to check engine behaviour that source-text checks cannot see: focus and +selection, whether a committed value survives the next state push, layout that must not +shift, and anything about a real machine's timing. + +**Two things that only starting Orca can show.** Both are about a message that never +arrives, and neither has any on-screen symptom on this page: + +- **A side effect must not ride on the repaint.** `render()` defers to a + `requestAnimationFrame`, and WebKit fires none into a view that is not being composited + — which the Device tab is not at startup. Anything whose consequence is on *another* + tab hangs off `state.onChange`, never off `render()`. `core/orcasync.js` was on the + repaint, passed every suite, and ran zero times in the app. +- **A push-only channel needs an initial value.** `sw_SubscribeUserLoginState` registered + a subscriber and replied with nothing, so it reported only changes — and the account is + restored 4 s before the Flutter bundle finishes parsing and subscribes, so the Home tab + showed signed-out over a live session. A subscription answers with the current state. + +**Finishing a half-connected path lights up code that has never run.** Telling Orca what +filament is loaded makes `m_connect_machine_info_list` non-empty for the first time on +this firmware, and the Prepare page gates several things on exactly that. The first one to +run segfaulted at address 0: `SyncMarkOverlay` is a child of the filament combo, +`filament_sync_marks` keeps a raw pointer, and both removal sites `Destroy()` the combo +without dropping it — so `SetSynced()` reached the virtual `IsShown()` through a freed +vtable. The invariant was a comment on the member and nothing else. The same switch-on is +why the sidebar's "Machine Filament" section had never been drawn: it is filtered on a +nozzle diameter the page never used to send. + +**Every suite has been green while the page was visibly broken.** A `ReferenceError` left +the page with no motion column and all 17 browser checks passed; a rename broke `boot()` +and the simulator stayed green because the broken line was on the *not-connected* branch, +which the mock never takes. Two habits follow: + +- **Dump the DOM and diff it** before and after any move — a `--drive` script that walks + `.app` printing every tag, id, class and `data-*` answers *what is on the page* rather + than *is this one thing right*. It caught both failures above. +- **`--real --device-ip 192.0.2.1`** forces the not-connected branch with no printer + involved. Run it on anything touching the device record. + +**`--shots` works again, and is real evidence.** It used to write blank PNGs — EGL finds +no driver under WSL, so WebKit's accelerated compositor never put anything in the window +`GdkPixbuf` was reading back, and every file was byte-identical whatever changed. An +unattended `--shots` run now renders into a `Gtk.OffscreenWindow` with +`WEBKIT_DISABLE_COMPOSITING_MODE=1`, which goes through cairo on the CPU. A `--watch` run +still gets a real window, and still gets blank PNGs with it. Pictures are worth having, +but they are still the weaker half: **the seam through a spool was 3 px out to the eye and +exactly right when measured.** Subtract two numbers. + +The other suites: + +```bash +python3 resources/web/shared/tests/conformance_test.py # constants vs evidence +python3 resources/web/shared/tests/unit_jsc.py # pure logic, in JavaScriptCore +python3 docs/u1-webui/tools/run_all.py # regenerate every data file +python3 docs/u1-webui/tools/check_coverage.py # nothing unimplemented in silence +``` + +Start at [docs/u1-webui/STATUS.md](docs/u1-webui/STATUS.md): what is proven against +hardware, what is not, and what to pick up next. + ## Architecture ### Core Libraries diff --git a/docs/ace-mmu/15-printer-panel.md b/docs/ace-mmu/15-printer-panel.md new file mode 100644 index 00000000000..decad355dc5 --- /dev/null +++ b/docs/ace-mmu/15-printer-panel.md @@ -0,0 +1,127 @@ +# The U1 printer panel (Prepare / Preview) + +Interactive mockup: [printer-panel-mockup.html](printer-panel-mockup.html) · +Visual standard: [16-ace-visuals.md](16-ace-visuals.md) + +> **Status: design only. No code has landed.** This branch carries the docs and mockups; +> every implementation step below is still to do. The ACE machinery referenced here — +> `ace_head_capacity` / `ace_head_unit`, `AceMmuProvider`, `sync_ace_topology`, +> `AceMmuPlan` — lives on `feat/ace-mmu-slicing` and does not exist on +> `develop/add-multiace-support` yet. Start from [NEXT.md](NEXT.md). + +**Prepare and Preview share one sidebar.** The Printer section at the top of it is a +single widget seen in two tabs, so this is one change in two places — and anything +added to it is also on screen while reading a sliced preview, where vertical space is +contested. + +## What is wrong today + +Measured by running the app, not by reading it (`.claude/tools/start.sh headless`). + +| # | Defect | Where | +|---|--------|-------| +| 1 | **Four tabs hold one control each.** `Nozzle 1..4`, each with a `Diameter` combo; changing any one writes all four and switches the whole preset, because the U1 refuses mixed diameters. Three tabs exist to be clicked and show nothing new. | `Sidebar::update_nozzle_settings`, Plater.cpp:8682 | +| 2 | **Three names for one thing.** Sidebar says *Nozzle 1–4*, Printer Settings says *Toolhead 1–4*, the assignment dialog says *T1–T4*. | Plater.cpp:8814, Tab.cpp:4808, `resources/web/aceplan` | +| 3 | **No topology.** The preset knows head 4 is fed by ACE 1 with four slots (`ace_head_capacity`, `ace_head_unit`); the panel never says so. | — | +| 4 | **No contents, and no way to ask.** The spool colours at each head are known when the printer is on the LAN — they already drive the filament sync — but never reach this panel. | `append_ace_filament_list`, Plater.cpp:773 | +| 5 | **Two half-syncs.** The sidebar glyph syncs nozzle diameters only; multiACE topology sync is a separate button buried in Printer Settings › Multimaterial. | Plater.cpp:2214, Tab.cpp:4652 | + +## The shape: Bambu's panel, for four heads + +Taken from `ui-snapshots-inspiration/Nozzle_and_filament_info/`, piece by piece. + +- **Three cards across the top** — printer (thumbnail over preset combo), plate + (texture swatch + ⓘ, absorbing today's `Bed type` row), and **Sync info**. +- **A green corner tick** on any card that agrees with the connected machine. On a head + box it means the ACE wiring matches what the printer reports — a claim we can make, + because `sync_ace_topology` already computes exactly that diff. +- **A bordered box per head**, Bambu's Left/Right Nozzle panes wrapped **2×2**: an + `ACE` row (badge when a unit feeds it, `Stock feeder` otherwise, adjust button always) + and a `Diameter` row. No `Flow` row — this fork deleted the control and there is no + setting behind it. +- **One `Nozzle` row**, not four tabs. Per-head diameters return only if the U1 ever + allows mixed sets; a machine reporting differing diameters still routes to the + existing `NozzleDiameterSelectDialog`. +- **Sync in two steps** — *Successfully synchronized nozzle, ACE mode and ACE unit + information* → **[Continue to sync filaments] [Cancel]**, anchored over the viewport. + Step two opens the existing `Sidebar::show_sync_filament_dialog`. + +## ACE mode — the printer's own switch + +Verified against firmware (`/printer/gcode/help` on 192.168.2.242): + +``` +SET_ACE_MODE MODE=normal|multi|head [HEAD=n] +ACE_SET_HEAD_ACE HEAD=0..3 ACE=0..3 "each ACE head is wired to exactly one ACE" +ACE_SET_HEAD_FEEDER HEAD=0..3 ENABLE=0|1 "(head mode only)" +``` + +The panel mirrors it as a labelled dropdown — **Normal** / **Per toolhead** / +**Combined** — with the raw `SET_ACE_MODE MODE=…` in the tooltip. Only in **head** mode +does per-toolhead wiring mean anything, so the head boxes grey their ACE rows in Normal. +Sync info reads the mode back along with the wiring. + +## The assign popover + +A **choice**, not a count: the firmware offers exactly two macros, so the list has +exactly two kinds of row and a tick rather than a spinner. + +``` +Which ACE feeds Toolhead 4? + ⬡ Stock feeder One spool, loaded at the head ( ) + ▤ ACE 1 · ACE 2 Pro 4 slots · connected · 39% RH (✓) +``` + +Units are named as the printer names them — `protocol: "v2"` → **ACE 2 Pro**, `"v1"` → +**ACE Pro** — the same mapping `resources/web/multiace/index.html` already uses. Between +them the rows write exactly `ace_head_capacity` and `ace_head_unit`. + +**One unit may feed several heads.** `ACE_SET_HEAD_ACE` binds a head to one ACE; it says +nothing about an ACE feeding one head, and `head_ace` is a map from head to unit. Ticking +the same unit on a second head is therefore legal, each row then reads *also feeds +Toolhead N*, and a warning states the real capacity. + +## Two defects this uncovered + +1. **A shared unit double-counts capacity.** `AceMmuPlan.hpp` sums capacity per head + (`total_cap += cap[h]`) and enforces it per head (`if (++load[h] > cap[h])`). Two + heads on one 4-slot unit read as **8 places** when there are 4, so the + infeasible-plate refusal — whose whole purpose is catching this — would pass a plate + that cannot be laid out. Needs a per-unit pool constraint beside the per-head one. +2. **Combined mode cannot be emitted.** `ace_head_capacity` already offers *6 slots* and + *8 slots*, but `GCode.cpp`'s `unit_of_head()` returns the single `ace_head_unit[h]` + and the plan's slot is an index *within the head*. Slot 5 of an 8-slot head emits + `ACE= SLOT=5` where the machine needs `ACE= SLOT=1` — wrong + unit, wrong slot, wrong colour. Those enum values are unsafe until the emitter maps + slot → (unit, slot). + +## Touch points + +| Piece | Where | State | +|-------|-------|-------| +| The panel | `Plater.cpp:2199–2565` | Title bar, preset card, Bed type row, nozzle notebook — built once in the Sidebar constructor | +| The head boxes | `Sidebar::update_nozzle_settings`, `Plater.cpp:8682` | Rebuilds one page per `nozzle_diameter` entry; becomes a 2×2 grid | +| Sync, nozzles | `Plater.cpp:2214–2350` | Queries the machine, `NozzleDiameterSelectDialog` on mixed diameters. Keep; becomes half the press | +| Sync, topology | `TabPrinter::sync_ace_topology`, `Tab.cpp:4652` | Reads `/multiace/api/state`, diffs per head, reports. Lift out of `TabPrinter` so the sidebar can call it | +| Sync, filaments | `Sidebar::show_sync_filament_dialog`, `Plater.cpp:8467` | Already lists U1 toolheads *and* ACE slots. What *Continue to sync filaments* opens | +| Topology | `ace_head_capacity`, `ace_head_unit` | Per-head `coInts` in the printer preset — the panel works with the printer off | +| Live contents | `AceMmuProvider`, `AceSnapshot` | Units, slots, colours, humidity, per-head bindings. One `fetch_once()` | + +**Before building:** the U1 connects as a `PrintHost` through the webview, not as a +`MachineObject`, so this panel cannot lean on `MachineObject::poll_ace_ams()`. It +resolves a host with `AceMmuProvider::resolve_connected_host()` and reads on demand — +the other reason press-to-sync fits the U1 better than a background poll. + +## Shipping order + +Each is one branch off `develop/add-multiace-support`, one squashed PR. + +1. **Panel structure** — three cards, 2×2 head boxes, one Nozzle row, `Toolhead N` + naming, plate card absorbing Bed type. No ACE; no new config. Self-contained. +2. **Topology in the preset** — `ace_head_capacity` / `ace_head_unit`, the Multimaterial + settings page, `sync_ace_topology`. +3. **The panel's ACE row** — badge, assign popover, ACE mode dropdown, on top of 1 + 2. +4. **Sync info, two-step** — nozzle + topology in one press, chaining to the filament sync. +5. **Per-unit capacity pool** — the shared-unit fix. Before any user can share a unit. +6. **Combined mode** — the emitter's slot → (unit, slot) map. Until then the mode is + listed and disabled, with the reason on it. diff --git a/docs/ace-mmu/16-ace-visuals.md b/docs/ace-mmu/16-ace-visuals.md new file mode 100644 index 00000000000..99d8449c31a --- /dev/null +++ b/docs/ace-mmu/16-ace-visuals.md @@ -0,0 +1,204 @@ +# The ACE visual standard + +Interactive sheet: [ace-visual-standard.html](ace-visual-standard.html) + +An ACE was drawn four different ways. The device page gave it 60 px circular spools; the +assignment dialog 96 px cards in a teal-banded box; the printer panel 7×15 px bars; and +the filament-mapping popup — which reads the ACE through `amsList` — **Orca's real AMS +widgets**. Four languages for one object. + +That last one is the way out. The ACE is already projected onto `Ams`/`AmsTray`, so the +AMS widgets are the standard, and **the geometry below is lifted from +`src/slic3r/GUI/Widgets/AMSItem.hpp` rather than invented** — which is what stops it +drifting again. + +## The three forms + +One treatment — **outlined chassis, solid bays, one stroke weight** — in three +proportions. Four bays is not a variable: `/api/state` returns `"slots": [/* exactly 4 */]` +and `SLOT_COUNT = 4` is a constant. There is no single-bay form; an AMS Lite has one +spool, an ACE never does. + +| Form | Size | Where | Function | +|------|------|-------|----------| +| **Badge** | 44×26 fill | a head box | `ace_badge()` · JS `aceBadge()` | +| **Glyph** | 44×26 line, stroke 1.6 | a popover row, a label | `ace_glyph()` · JS `aceGlyph()` | +| *the two above are **G** and **O5**: one rounded body, 4 px of margin all round, spools 6×16 on a pitch of 10 at x 4/14/24/34* |||| +| **Glyph, square** | 24×24 line, stroke 1.6 | a tab, a menu, a `ScalableButton` | `ace_glyph_square()` · JS `aceGlyphSquare()` | + +The JS half is [`resources/web/shared/js/multiACE.js`](../../resources/web/shared/js/multiACE.js), +where the geometry is `ACE_ART` and the three builders sit beside it. The Device page's +Filament panel draws all three: the badge in each unit row, the square in a menu row, the +wide glyph beside a label. + +**The sizes are nominal, and the builders take a zoom** — the wide forms are 44×26 *at +z = 1*. That matters where a slot is smaller than the drawing: the Device page's unit row +is 17 px, because that panel's body is 456 and measured, so its badge is the same drawing +at `17/26`. A zoom is not a second set of proportions; redrawing it would have been. + +**The badge is G and the outline is O5.** A and O2 stay on the sheet as what they are +corrections of; the sheet opens on the new pair and every specimen on it follows. + +**F** is the same badge with its two halves the *same* width — one rounded rectangle split +by a colour stop, with no inset hood. That is the silhouette the Device page's own cabinet +actually has (`.ace-cab-top`, radius 9, `#EEEEEE` over `#CECECE`, sides parallel), so a +badge meant to be a portrait of it arguably should not narrow its top. + +**G** finishes the thought. A and F both draw the base *over* the bays, which crops their +lower 2.5 px; G draws the spools **last**, so a spool is a whole spool. That exposes an +unequal margin — 6 px at the sides against 3.5 at the top — so it is made **one number on +all four sides: 4 px**, which is the gap's own number and the proportion A already states +(*padding 4 == gap 4*). The badge stays 44×26 and its body stays 24 tall, so the margin +comes out of the spools: **5×13 becomes 6×16**, on a pitch of 10 at x 4/14/24/34. + +**O5 · Flush, solid bays** is G's outlined twin — O2's treatment on G's body. The other +four outlines all wear `CAB`, which is A's stepped silhouette; if the badge stops stepping +then its twin has to stop too, or the pair say two different things about one object. + +`check_mockup.py` holds each one's geometry — G's margin as `4,4,4,4` computed from the +rendered rects, its spools as 6×16, its body height as unchanged at 24, its draw order by +document position, and O5's bays as byte-identical to G's. + +**The implementation has followed.** `ACE_ART.badge` and `ACE_ART.glyph` in +[`shared/js/multiACE.js`](../../resources/web/shared/js/multiACE.js) are G and O5 now, so +the Device page's Filament panel draws the new pair — the unit-row badge at 17 px, the wide +glyph in the help dialog. `aceGlyphSquare()` is untouched: the square was S4 before and is +S4 still. It was one edit in one place, because the drawing has one home. + +`run_webkit.py` holds the panel to it: the badge must be one body the full width, split by +a colour stop rather than a second box, with the spools drawn **over both** and 6×16 on a +4 px margin. The old assertion — *its base is drawn wider than its hood* — was the thing +that had to change, and it is the only line that did. + +**Badge (G)** — one rounded body, 44×24 inside the 44×26 box, split by a hard colour stop +at y 16, with the four spools drawn **last** so none is cropped. Bays are 6×16 capsules at +x 4/14/24/34 — **margin 4 = gap 4** on all four sides, which is Bambu's own proportion and +the thing A states. Colour and emptiness are all that survive at this size, so the badge +carries colour only — *not* a level, even where Spoolman has bound one; an empty bay is +white against the grey body, with no outline. Trust and staleness live wherever the badge +is a control. + +*A, the starting point, put a narrower hood over a wider base and drew that base over the +bays, cropping their lower 2.5 px. G is that drawing corrected against the cabinet it is a +portrait of: the cabinet has parallel sides and whole spools, so its badge should too.* + +**Glyph (O5)** — the badge's own silhouette in line: G's body as one rounded rectangle +inset by half a stroke, with G's own bays filled. *O2 drew A's stepped path; when the badge +stopped stepping the glyph had to stop too, or the pair say two different things about one +object.* + +**Glyph, square** — body and four bays, no hood or base step. It is deliberately *not* +the same silhouette: the family is carried by the bay treatment and the stroke, because +the square has a third of the width to say the same thing in. If the two ever sit side by +side and the mismatch shows, the fallback is the wide drawing letterboxed into the square. + +## The third box, and the one in use + +The three forms are icons; they stand for an ACE where there is no room to draw one. +**Section 3 of the sheet carries the drawings themselves** — beside the AMS strip and the +AMS unit box, because they are three boxes for the same four spools and the third is the +one the Device page draws today. At its own size, with `device.css`'s own numbers, so the +two cannot drift without something failing: + +| | | | +|---|---|---| +| **The ACE cabinet** | 310×71 | `#EEEEEE` over `#CECECE`, seam through the roll at `5px + --disc/2`, 16 px of shoulder either side | +| **The stock feeder** | 94×71 | the same drawing in `#FFFFFF` over `#1F1F1F`, one bay instead of four | +| **The feeder badge** | 17×17 | 24×24 nominal — the frame at badge size, square, because a wide badge for it read as a squashed ACE | +| **The toolhead** | 64×140, and 32×70 | `extruderBackground.svg`, with the sensor marker on the artwork's **body** at (32, 72.5) rather than on its box | + +**When Spoolman is bound, a bay can say how much is left.** An ACE slot has no +`material_remain`, which is why a disc is a colour and not a gauge — but that holds only +while nothing is bound. With `spool_mode: "spoolman"` and `spool_binding` mapping the slot +to a Spoolman id there *is* a weight, and then the disc follows the rule the AMS column +already follows: **the filament colour from the bottom up to what is left**, with the grams +on the bay. One drawing with two states rather than two drawings. **Unmeasured is hatched +rather than empty**, because *unmeasured* and *used up* are different things and a bar at +zero says the second one. The badge does not follow: at 6×16 a level is not readable, and +colour and emptiness are all it claims to carry. + +**310 is not a choice, it is arithmetic.** A bay is `.slot`'s own — a 36 px disc over a +58×19 name pill, 6 px apart — in a 62 px column at `flex: 0 0`, with 10 px gaps; four of +those plus the shoulders is 310 px at every panel width there is. What that costs is +measured in +[the actions study](../u1-webui/02-device-page/multiace-actions.html): the card needs a +330 px cell, and below a 655 px panel a bay is cut. + +```bash +python3 docs/u1-webui/tools/check_mockup.py docs/ace-mmu/ace-visual-standard.html # 55 +``` + +## The box the spools sit in + +Orca's AMS already owns the neutrals, and uses them for exactly these roles: + +- `AMS_CONTROL_DEF_BLOCK_BK_COLOUR` **#EEEEEE** — the band, and an empty tube +- `AMS_CONTROL_DEF_LIB_BK_COLOUR` **#F8F8F8** — the box the tubes stand in +- `AMS_CONTROL_BRAND_COLOUR` **#009688** — hover, 2 px +- `AMS_CONTROL_DISABLE_COLOUR` **#CECECE** — a unit configured but not answering + +All go through `StateColor::darkModeColorFor`, so dark mode is not a second palette. + +**The spool object is `AMSLib`** — 58×80 (`AMS_CAN_LIB_SIZE`), a well inset by 4, the +filament colour drawn **from the bottom up to how much is left**. Not a swatch on a card: +a level in a tube, so a row reads as an inventory. Selection is 2 px in *the filament's +own colour* (`AMSLib`'s rule); hover is the brand teal. Label ink follows the fill's +luminance, the same `< 0.6` test `AMSLib` uses for its badge. + +## Moisture and temperature + +`AMSHumidity`, unchanged: a pill (radius = half the height) on `#EEEEEE`, the +`hum_level1..5` droplet at 16 px, a 1 px `#C2C2C2` divider, then the dryer glyph +(`ams_drying` / `ams_is_drying`). `AMS_HUMIDITY_SIZE` 93×26 with a percentage, +`AMS_HUMIDITY_NO_PERCENT_SIZE` 60×26 without. + +`AMSinfo` already handles the ACE's exact case: `humidity_raw = -1` selects the numbered +droplet, anything else the plain droplet plus the number. Bucket the raw percentage +1 = ≤20, 2 = ≤35, 3 = ≤50, 4 = ≤65, 5 = >65 to pick the glyph. + +**Temperature is the one addition.** The AMS carries `current_temperature` but never +draws it here; the ACE reports `temp` per unit and it matters while drying. It goes in +the same pill behind a second divider — one chip, not three. Absent when unreported, +never zeroed. + +## Where a level comes from + +An ACE slot has **no remain field**: `/api/state`'s `slots[]` carries material, brand, +colour and source, and nothing about quantity. But a Spoolman-backed printer binds them: + +```jsonc +"spool_mode": "spoolman", +"spool_binding": { "0_0": "15", "0_1": "10", "0_3": "16" }, +"spools": { "15": { "weight_g": 500.1, "used_mm": 0.0, "density": 1.27, ... } } +``` + +So bound slots can drive the column honestly, and an unbound one is drawn full but +hatched and labelled *amount unknown* rather than pretending to be full. + +**What is not available is a percentage.** Spoolman knows the initial weight; this +payload does not, so a column scaled to 1 kg would call an 843 g spool 84% when it may be +a full 850 g one. Show the grams and treat the column as a gauge — or fetch +`remaining_weight` from Spoolman directly and scale it properly, which is its own piece +of work. `AceSlot` parses none of the binding today; wiring it through is the +prerequisite for the column meaning anything. + +## Adoption + +| Surface | Draws now | Becomes | +|---------|-----------|---------| +| Filament mapping popup (`AmsMappingPopup.cpp`) | Orca's AMS widgets, via the `amsList` projection | **Nothing** — it is already the standard, and the reference | +| Device / AMS tab (`AMSControl`, `AmsItem`) | Orca's AMS widgets, fed by the projection | **Nothing**, beyond naming the unit *ACE 2 Pro* rather than *AMS* | +| Printer panel (`Plater.cpp` sidebar) | 7×15 px bars in an ad-hoc strip | **Badge** in the head box | +| Assignment dialog (`resources/web/aceplan`) | 96 px `.pos` cards in a teal `.acebox` | **Spool box** + `AMSLib` columns; keep the drag targets | +| U1 + multiACE page (`resources/web/multiace`) | 60 px circular spools; 36 px circular swatches | **Spool box** + `AMSLib` columns. The circles are the biggest departure and the one worth losing — nothing else in Orca draws filament round | +| Rebuilt Device page (`resources/web/device_page`) | **Done, 2026-08-26.** All three forms, from `shared/js/multiACE.js` | — | + +**Note on `AMSPreview`.** The 82×27 strip of 14×14 cubes is real, shipping code, but it +has only two call sites — `AMSControl.cpp:1173` (the unit selector) and +`CalibrationWizardPresetPage.cpp:617`. `AMS_ITEM_CUBE_SIZE` appears nowhere else: the +cube is internal to that widget and is never drawn alone. It is documented here so the +two native surfaces are not diverged from, not as a form to build with. + +**One arithmetic snag** if `AMSPreview` is ever reused: padding 7 plus four 14 px cubes +plus three 5 px gaps is 85, not the 82 the constant states. Callers size the preview +themselves today. Fix it once rather than per surface. diff --git a/docs/ace-mmu/17-plate-template.md b/docs/ace-mmu/17-plate-template.md new file mode 100644 index 00000000000..20e38cd882a --- /dev/null +++ b/docs/ace-mmu/17-plate-template.md @@ -0,0 +1,119 @@ +# The U1 plate template + +Interactive sheet: [plate-thumbnails-options.html](plate-thumbnails-options.html) · +Used by: [15-printer-panel.md](15-printer-panel.md) (the plate card) + +One silhouette, four plates, two ways of filling it. The same relationship +[16-ace-visuals.md](16-ace-visuals.md) has to the ACE: a shape that is fixed, so every +surface that draws a build plate draws the same object. + +## The silhouette + +**Measured, not traced.** A first attempt was drawn by eye from the product photos and was +wrong in every dimension — deep crenellations where the plate has small nicks. So the +product PNG was decoded to raw pixels (pure Python: `zlib` + manual unfiltering, no imaging +library needed), thresholded against the background, and the top and bottom edges sampled +column by column. + +| Property | Measured | +|----------|----------| +| Plate | 391 × 413 px → **aspect 0.947**, slightly taller than wide | +| Top notches | three, centred at **x 26 / 50 / 74** | +| Notch size | ~**6 wide**, **2.9 deep** | +| Bottom tongue | **x 23 → 77.5**, protruding **2.9** below the side edges | +| Corner radius | **~2** | + +All as percentages of the plate's own box: width 100, height 105.6. **Do not re-trace by +eye.** + +## Drawing it: emphasised, deliberately + +At 44 × 40 — the plate card's thumbnail — a 2.9 % notch is **just over one pixel**. Drawn +truthfully the U1 plate is a rounded square with three nicks nobody can resolve, and the +silhouette contributes nothing. Exaggerating is a normal icon convention, but it is a +decision, so it was taken explicitly rather than arrived at by sloppy tracing. + +Four points along that axis were drawn and compared (*As measured*, *Emphasised*, +*Exaggerated*, *Plain*). **Emphasised** was chosen: the measured notches roughly doubled, +enough to register as a notched plate at a glance without reading as a different object. + +``` +platePath(notchDepth, notchHalfWidth, tongueDepth, cornerRadius) + measured (3.06, 3.0, 3.06, 2) + emphasised (5.5, 3.8, 4.2, 2) ← chosen +``` + +The chosen path, in a `0 0 100 105.6` viewBox: + +``` +M2 0 H22.2 L26 5.50 L29.8 0 H46.2 L50 5.50 L53.8 0 H70.2 L74 5.50 L77.8 0 +H98 A2 2 0 0 1 100 2 V99.40 A2 2 0 0 1 98 101.40 +H79.5 L77 105.60 H23 L20.5 101.40 H2 A2 2 0 0 1 0 99.40 V2 A2 2 0 0 1 2 0 Z +``` + +**Aspect is preserved, never stretched.** The plate is taller than wide and the card slot +is 44 × 40, so the drawing letterboxes. Squashing it would change the notch proportions, +which is the one thing the silhouette exists to carry. + +## Filling it + +**Photograph Snapmaker's own plates. Draw everything else generically.** + +The three plates Snapmaker sells for the U1 are first-party products for their bed types, +so a photograph is simply what the plate looks like. Anything else is drawn: the nearest +real product is somebody else's, and using it would brand a **bed type** — an abstract +setting — with a vendor the app never otherwise mentions. Whoever selects *Cool Steel +Plate* may own any plate, or none. + +| Bed type | Enum | Fill | Source | +|----------|------|------|--------| +| Textured PEI Plate | `btPTE` | photo | Snapmaker product shot | +| Smooth PEI Plate | `btPEI` | photo | Snapmaker product shot | +| Graphic Effect Plate | `btGESP` | photo | Snapmaker product shot | +| Cool Steel Plate | `btSuperTack` | **drawn** | cool blue, soft sheen, no maker's marks | + +That is the U1's whole default set. The advanced-mode plates +(`support_multi_bed_types`: Cool Plate, Engineering, Textured Cool) are out of scope; when +they arrive they are **drawn**, by the same rule. + +**Sampling a photograph.** Take the middle of the flat product shot, zoomed ~150–195 % so +the crop lands inside the plate's surface, then clip to the silhouette. Never fit the whole +photo — the background and the plate's own edge would come with it, and the silhouette is +already doing that job. *Smooth PEI is matte black*, not amber; only Textured PEI is the +bronze people picture when they hear "PEI". + +**Drawing a plate.** Paths and gradients only — icon SVGs go through **nanosvg** +(`BitmapCache.cpp:18`), which has no filters, no `` and no CSS. A base gradient +plus one or two soft highlight sweeps is enough; do not invent surface detail that claims +to be a particular product. + +## Sizes and formats + +| Where | Size | +|-------|------| +| Plate card thumbnail | **44 × 40** | +| Plate picker cell | **40 × 36** | + +Photographs ship as **PNG at 1× / 2×** — the sources are `.webp` and `.jpg`, which Orca's +icon path cannot read. Drawn plates ship as **SVG**. + +## Two traps + +**Key off the enum, never the label.** The same `BedType` is named differently per printer: +`btPEI` is *Smooth High Temp Plate* generically and *Smooth PEI Plate* on the U1; +`btSuperTack` is *Cool Plate (SuperTack)* generically and *Cool Steel Plate* on the U1. +Match on the label and the U1 shows a Bambu plate under a Snapmaker name. + +**The card cannot be narrow without a picture.** Bambu's plate card is 92 px, so the label +clips to *Textur…* and the picture carries the identification. Without art, a 92 px card is +just clipped text — worse than the full-width `Bed type` row it replaces. Which is why the +row stays as it is until these exist. + +## Open + +**Licensing.** The three photographs are Snapmaker's, used here as design reference the way +`ui-snapshots-inspiration/` holds Bambu's screenshots. Shipping them in an **AGPL-3.0** +repository needs permission. What can ship: ask Snapmaker, photograph the plates yourself, +or draw all four. The drawn cool plate has no such problem and is the model for anything +that has to be replaced — the silhouette and the sampling rules are unaffected either way, +so it is a swap of the fill, not a redesign. diff --git a/docs/ace-mmu/NEXT.md b/docs/ace-mmu/NEXT.md new file mode 100644 index 00000000000..d2417ea9633 --- /dev/null +++ b/docs/ace-mmu/NEXT.md @@ -0,0 +1,135 @@ +# Where this stopped, and what to do next + +**Branch:** `feat/u1-printer-panel`, cut from `origin/develop/add-multiace-support`. +**Contents:** design docs and mockups only. **No code.** Read this before touching anything. + +## What was produced + +| File | What it is | +|------|------------| +| [15-printer-panel.md](15-printer-panel.md) | The panel design: five measured defects, Bambu's shape for four heads, the ACE mode switch, two defects it uncovered, touch points, shipping order | +| [16-ace-visuals.md](16-ace-visuals.md) | The ACE visual standard: three forms under one rule, the spool box, the moisture pill, where a fill level comes from | +| [17-plate-template.md](17-plate-template.md) | The U1 plate template: the measured silhouette, the path the app draws, and when a plate is photographed rather than drawn | +| [printer-panel-mockup.html](printer-panel-mockup.html) | The panel, interactive: six machine states, the sync flow, the assign popover, the mode dropdown | +| [ace-visual-standard.html](ace-visual-standard.html) | The badge and its twins, the spool box, the pill — with the rejected alternates kept as the record | +| [plate-thumbnails-options.html](plate-thumbnails-options.html) | The four plates in the card, the silhouette variants that were weighed, and the advanced-mode ones noted | + +## Decisions taken — do not reopen without a reason + +- **Panel shape:** Bambu Studio's, from `ui-snapshots-inspiration/`. Three cards across the + top; a bordered box per head wrapped 2×2; one `Nozzle` diameter row, not four tabs. +- **Naming:** **Toolhead N**, everywhere. Printer Settings already says it; the sidebar + said *Nozzle N* and the assignment dialog *T1–T4*. +- **No `Flow` row.** This fork deleted the control; there is no setting behind it. +- **ACE mode** is the printer's own three-way switch, mirrored as a labelled dropdown. +- **The assign popover is a choice, not a count** — two macros, so two kinds of row and a + tick. Units named as the printer names them (*ACE 2 Pro* / *ACE Pro*). +- **ACE visuals:** badge **A · Cabinet** (44×26 fill), glyph **O2 · Solid bays** (44×26 + line), square **S4 · Front face** (24×24 line). One rule: outlined chassis, solid bays. +- **Plate visuals:** one measured silhouette, drawn **Emphasised**; photograph Snapmaker's + own plates, draw everything else generically. Full spec in + [17-plate-template.md](17-plate-template.md). +- **No single-bay glyph.** Every ACE unit has exactly four slots. + +## Open — the only things still undecided + +**1. Licensing of the plate photographs.** Three of the four plate thumbnails are built +from Snapmaker's product photography, used here as design reference the way +`ui-snapshots-inspiration/` holds Bambu's screenshots. Shipping them inside an +**AGPL-3.0** repository needs permission. What can ship: ask Snapmaker, photograph the +plates yourself, or draw all four the way Cool Steel already is. **This blocks the plate +card, nothing else** — and it is a swap of the fill, not a redesign: the silhouette, +sampling and sizes in [17-plate-template.md](17-plate-template.md) hold either way. + +**2. Whether the dev tooling comes over.** `.claude/tools/` lives on +`feat/ace-mmu-slicing` and is absent here. Every verification step below assumes it. See +*Traps*. + +Everything else about the panel, the ACE visuals and the plate template is decided and +written down. Where a decision was close, the alternates are kept in the mockups as the +record rather than deleted. + +## Shipping order + +Each step is one branch off `develop/add-multiace-support`, one squashed PR. + +| # | PR | Depends on | +|---|-----|-----------| +| 0 | **These docs** (this branch) | — | +| 1 | **Panel structure** — collapse the four identical nozzle tabs to one `Nozzle` row; move sync out of the title bar onto a labelled card | — | +| 2 | **Topology in the preset** — `ace_head_capacity` / `ace_head_unit`, the Multimaterial settings page, `sync_ace_topology` | — | +| 3 | **The panel's ACE row** — badge, assign popover, ACE mode dropdown | 1, 2 | +| 4 | **Sync info, two-step** — nozzle + topology in one press, chaining to the filament sync | 3 | +| 5 | **Per-unit capacity pool** — the shared-unit fix. Must land before a user can share a unit | 2 | +| 6 | **Combined mode** — the emitter's slot → (unit, slot) map. Until then the mode is listed and disabled | 2 | +| — | **Plate card** — assets plus a card. Independent of everything above | a decision | + +### PR 1, concretely + +Both changes are in `src/slic3r/GUI/Plater.cpp` and need no new config. + +1. `Sidebar::update_nozzle_settings` — check whether every `nozzle_diameter` value is + equal; build **one** page named `Nozzle` when they are, one per head named + **Toolhead N** when they are not. Safe: every existing use of + `m_nozzle_diameter_lists` iterates the whole list and writes the same value, so a + single entry is fine. +2. Move `m_printerinfo_syncbtn` off `m_panel_printer_title` onto a `StaticBox` card beside + the printer card, with a `Sync info` label and the whole card as the click target. Keep + the handler and the U1-only show/hide — retarget the show/hide to the card. + +This was written once and reverted; it is straightforward, but it was **never compiled or +run**, so treat it as a sketch, not a patch. + +## Facts established here — measured, not inferred + +- **The live printer** at `192.168.2.242` reports `mode: "head"`, `device_count: 1`, one + connected **ACE 2 Pro** (`protocol: "v2"`) at 39% RH feeding **Toolhead 4**, holding four + Kingroon/Generic PETG spools (`#83AFFF`, `#8FA7C8`, `#632C2C`, `#C47053`). +- **The mode switch is real firmware:** `SET_ACE_MODE MODE=normal|multi|head [HEAD=n]`, + with `ACE_SET_HEAD_ACE` / `ACE_SET_HEAD_FEEDER` documented "(head mode only)". Read from + `/printer/gcode/help`. +- **One unit may feed several heads.** `ACE_SET_HEAD_ACE` binds a head to one ACE and says + nothing about the reverse; `head_ace` is a map from head to unit. +- **A slot has no remain field.** Quantity comes from Spoolman via `spool_binding` + (`{"0_0":"15","0_1":"10","0_3":"16"}` → `weight_g` 500.1 / 997.7 / 843.1). One of the + four slots is unbound. A *percentage* is not derivable — the initial weight is not in + the payload. +- **Icon SVGs go through nanosvg** (`BitmapCache.cpp:18`): paths and gradients only, no + filters, no ``, no CSS. Anything photographic must ship as PNG. +- **The repo is AGPL-3.0**, so shipped assets must be licence-compatible. Snapmaker's + product photography is not. +- **The U1 connects as a `PrintHost`** through the webview, not as a `MachineObject`, so + the panel cannot use `MachineObject::poll_ace_ams()`. Resolve a host with + `AceMmuProvider::resolve_connected_host()` and read on demand. +- **Two defects found in existing code**, both in `15-printer-panel.md`: a shared unit + double-counts capacity in `AceMmuPlan.hpp`; Combined mode emits the wrong `ACE=`/`SLOT=` + in `GCode.cpp`. + +## Traps on this branch + +- **`develop/add-multiace-support` has no ACE code and no `docs/ace-mmu/` tree.** It is + upstream v2.3.6. Everything from `feat/ace-mmu-slicing` — the provider, planner, + dialogs, docs 01–14 — is absent. +- **`.claude/tools/` is absent too.** The headless-X harness, crash catcher and page + checker live on `feat/ace-mmu-slicing`. Bringing them over early is worth its own PR: + every step below is verified with them, and GUI claims made without them have been wrong + three times in this feature. +- **Switching branches invalidates the build** — the first build after a switch is a full + 622-target rebuild, not an incremental one. Budget for it. +- **Profile edits need a version bump**, or the app keeps its cached copy in + `~/.config/Snapmaker_Orca/system/` and the change silently does nothing. + +## How to verify anything in the GUI + +Reproduce, don't theorise. Three crashes in this feature were misdiagnosed from reading +code; each was settled in one run with the headless harness. Once `.claude/tools/` is on +this branch: + +``` +./.claude/tools/start.sh headless # Xvfb :99 + Orca, crash catcher armed +./.claude/tools/start.sh shot x.png # screenshot the virtual display +./.claude/tools/start.sh click X Y # click on it +./.claude/tools/start.sh trace # resolve the last crash to file:line +``` + +It runs against a copy of `~/.config/Snapmaker_Orca`, so it cannot disturb real presets. diff --git a/docs/ace-mmu/README.md b/docs/ace-mmu/README.md new file mode 100644 index 00000000000..07ae78c85da --- /dev/null +++ b/docs/ace-mmu/README.md @@ -0,0 +1,59 @@ +# multiACE on the Snapmaker U1 — design docs + +Design and research notes for running one or more Anycubic **ACE Pro / ACE 2 Pro** +filament changers on a **Snapmaker U1** from this slicer, via the printer-side +[multiACE](https://github.com/decay71/multiACE) service. + +Each document is written against **verified reality** — read from the running app, the +live printer at its REST endpoint, or the firmware's own macro help — rather than from +the code alone. Where something is unverified, it says so. + +## Start here + +**[NEXT.md](NEXT.md)** — what has been decided, what is still open, the shipping order, +and the traps on this branch. Read it before touching anything. + +## Documents + +| # | Document | Contents | +|---|----------|----------| +| 15 | [15-printer-panel.md](15-printer-panel.md) | **The U1 printer panel** in Prepare/Preview: the five defects, Bambu Studio's shape applied to four heads, the ACE mode switch, two defects it uncovered, and the shipping order | +| 16 | [16-ace-visuals.md](16-ace-visuals.md) | **The ACE visual standard**: one way to draw an ACE, taken from Orca's own AMS widget geometry — badge, glyph, spool box, moisture pill | +| 17 | [17-plate-template.md](17-plate-template.md) | **The U1 plate template**: the measured silhouette, the emphasised path the app draws, and when a plate is photographed rather than drawn | + +## Mockups + +Self-contained HTML, interactive. Open directly, or all at once with +`./.claude/tools/start.sh mockups`. + +| Mockup | For | +|--------|-----| +| [printer-panel-mockup.html](printer-panel-mockup.html) | [15](15-printer-panel.md) — the panel, every machine state, the sync flow and the assign popover | +| [ace-visual-standard.html](ace-visual-standard.html) | [16](16-ace-visuals.md) — the badge, its outlined and square twins, the spool box, the moisture pill | +| [plate-thumbnails-options.html](plate-thumbnails-options.html) | [17](17-plate-template.md) — the four plates in the card, the silhouette options that were weighed, and the advanced-mode ones noted | + +Each mockup is the specification the code is built against: when the two disagree, the +mockup is updated in the same commit, not left to drift. + +## Status + +**Design only. No code has landed on this branch.** Every implementation step is listed in +[NEXT.md](NEXT.md). + +## Numbering + +Documents are numbered in the order they were written, not in reading order — the number +is a stable handle for cross-references. **15 and 16 are the first of this set to land on +`develop/add-multiace-support`**; documents 01–14 cover the provider, data model, +slicing, planner and dialogs, and arrive with the PRs that implement them. + +## Conventions + +- **Measure, don't infer.** Three crashes in this feature were misdiagnosed from reading + code; each was settled in one run with `.claude/tools/start.sh` (headless X, crash + catcher, `THROW_LOG=1`). GUI claims are made from screenshots, printer claims from the + printer. +- **Status vocabulary:** *done* = observed working end to end · *built* = written and + compiles, not yet observed · *gap* = not written. +- **Say what is not known.** A doc that hides an unverified assumption costs more than + one that names it. diff --git a/docs/ace-mmu/ace-visual-standard.html b/docs/ace-mmu/ace-visual-standard.html new file mode 100644 index 00000000000..9239b818d30 --- /dev/null +++ b/docs/ace-mmu/ace-visual-standard.html @@ -0,0 +1,1337 @@ + + +The ACE visual standard + + + + +
+ +
+

The ACE visual standard

+

An ACE is drawn four different ways today. The device page gives it 60 px circular + spools; the assignment dialog gives it 96 px cards in a teal-banded box; the new + printer panel gives it 7×15 px bars in a rounded strip; and the filament-mapping + popup — which reads the ACE through amsList — already gives it + Orca's real AMS widgets. Four languages for one object.

+

That last one is the way out. The ACE is projected onto Ams/AmsTray + already, so the AMS widgets are the standard — the geometry below is lifted from + src/slic3r/GUI/Widgets/AMSItem.hpp rather than invented, which is what stops it + drifting again. Everything is drawn here at true size: 1 px = 1 DIP.

+

Four states are the ACE's own and have no AMS equivalent — an inferred spool + identity, a configured but absent unit, the toolhead a unit feeds, and a + temperature reading. Each is marked ACE where it appears.

+
+ +
+
+ Badge + + + + + + + + + +
+
+ Outline + + + + + + + +
+
+ Square + + + + + + +
+
+ Slot 3 + + + + + + + +
+
+ Unit + + + + + +
+
+ Remaining + + + + + +
+
+ Moisture + + + + + + +
+
+ + +
+
1The badge — four spools in a box
+
+

Five ways to draw it — all 44×26

+

The icon Bambu puts beside AMS in a nozzle box is not + AMSPreview — not a row of cubes but a little cabinet seen head-on, and + Orca ships no equivalent (ams_icon.svg is a 155×128 illustration). So here are + seven candidates, from the faithful copy to the ones that lean on what this fork already draws. + Pick one and the whole page follows.

+
+

Each is drawn to the same 44×26 box and the same + four bays, so they are interchangeable in code — one function, one argument. Pick with + the Badge row in the deck and the rest of this page follows, including the states below. + Every one is shown twice: enlarged, and at true size in the row it actually lives in.

+

A is the faithful distillation of Bambu's icon — hood, bays, base + drawn over them. B keeps that silhouette but draws the chassis in line rather than fill, + so the colours carry and the grey mass goes away. C drops the chassis entirely and stands + the bays on AMSPreview's own ground, which is the least new invention of the five. + D is the ACE's front face: four bays cut into one body. E uses spool ends, which is + what the U1 + multiACE page already draws — picking it would make that page the + standard rather than the exception. F is A with parallel sides: one rounded + rectangle split by a colour stop rather than a narrower hood over a wider base. That is the + silhouette the Device page's cabinet actually has (section 4), so a badge meant + to be its portrait arguably should not narrow its top — which is the whole of the case + for it, and the whole of the case against is that A is Bambu's own proportion. + G finishes that thought: the spools are drawn last rather than under the + lower half, so none is cropped, and the margin round them is one number on all four + sides — 4 px, which is the gap's own number and the proportion A + states. The badge is still 44×26 and its body still 24 tall, so that margin comes + out of the spools: they grow from 5×13 to 6×16 and the drawing gets + its air back at the edges instead of at the corners.

+
+

The outlined twin — the same cabinet, in line. + Where the badge says what is in it, the outline says what it is: it names an + ACE in a popover row, a settings label, a menu. So it is not a separate drawing — it is + A's own silhouette, one stepped path with the hood's shoulders on top and the base + stepping out at the bottom. Five ways to treat the bays inside it — and the last is + not that silhouette at all: O5 wears G's, because if the badge stops + stepping then its outlined twin has to stop too, or the pair say two different things + about one object:

+
+

And a square twin, for icon slots. The cabinet is + 44×26 — it cannot go where the app wants a square: a tab, a menu row, a + ScalableButton. Squaring it is a real trade, because the ACE's four bays sit in a + row, and a row is wide. Four ways to spend the square, each shown at 48, at 24 and 16 + true size, and in the tab it would most likely appear in:

+
+

The trade, plainly. S1 changes nothing and + pays for it in size — at 16 px the bays are under a pixel. S2 fills the square + but the bays thin out. S3 adds an outline that competes with the cabinet's own. + S4 is the most legible small, at the cost of the hood-and-base step.

+ +
+

Settled — three forms, one rule

+

The picks turned out to share a rule, + which is a better outcome than three unrelated shapes: + outlined chassis, solid bays, at one stroke weight.

+
+

What the square costs, stated. S4 drops the + hood and the base step, so the square is not the same silhouette as the wide glyph + — the family is carried by the bay treatment and the stroke, not by the outline. That is + a deliberate trade: the two live in different places (a tab or menu icon versus a popover row + or label) and the square has a third of the width to say the same thing in. If they ever end + up side by side and the mismatch shows, S1 is the fallback, because it is literally the + wide drawing scaled down.

+

Three definitions, not eight. The alternates stay on this page as the + record of what was weighed; the code ships + ace_badge(), ace_glyph() and ace_glyph_square() + and nothing else.

+
+

There is no single-bay variant. Bambu needs one + because an AMS Lite holds one spool; every ACE unit has exactly four slots — + /api/state returns "slots": [/* exactly 4 */] and + SLOT_COUNT = 4 is a constant, not a configuration. A one-bay glyph would depict a + machine that does not exist, so the family has four bays or none (O4), and nothing + between.

+
+
+ + +
+
2Moisture and temperature
+
+

The pill — 93×26 with a percentage, 60×26 without

+

AMSHumidity, unchanged: a pill (radius = half the height) on + AMS_CONTROL_DEF_BLOCK_BK_COLOUR, the hum_level1..5 droplet at 16 px, + then a 1 px #C2C2C2 divider and the dryer glyph when the unit can dry. + All of it already exists — icons, sizes, sentinel values.

+
+

The ACE reports a raw percentage where the AMS often reports only a 1–5 + bucket, and AMSinfo already handles exactly that split: humidity_raw = -1 + selects the numbered droplet, anything else selects the plain droplet plus the number. Your unit + reports humidity: 39, so it takes the second form. Bucket it as + 1 = ≤20, 2 = ≤35, 3 = ≤50, 4 = ≤65, + 5 = >65 to pick the glyph.

+

Temperature ACE is the one addition. The AMS carries + current_temperature in its info struct but never draws it in this widget; the ACE + reports temp per unit and it matters while drying. It goes in the same pill behind a + second divider, so there is still one chip to look at rather than three. When the unit reports no + temperature the segment is absent, not zeroed.

+

Drying swaps the sun (ams_drying) for the animated glyph + (ams_is_drying) exactly as support_drying() does, and the remaining time + belongs in the tooltip, not the pill — the pill is a glance, not a readout.

+
+
+ + +
+
3The box the spools sit in
+
+

Four spool objects in a light grey box

+

This is the AMS shape: a light grey box with four colour columns + standing in it. The column is AMSLib — 58×80 + (AMS_CAN_LIB_SIZE), a well inset by 4, and the filament colour drawn + from the bottom up to how much is left. Not a swatch on a card: a level in a tube, so a + row of them reads as an inventory at a glance.

+

Three greys, all from the AMS palette, each doing one job: #EEEEEE is + the band and also the empty tube, #F8F8F8 is the box the tubes + stand in. Selection is 2 px in the filament's own colour, which is + AMSLib's rule rather than the brand teal — hover is the teal.

+
+
+ A · Strip — 82×27 +
+ AMSPreview · inline: a head box, a mapping row, a list item +
+
+ B · Unit box — band + ground +
+ the ACE as an object: identity, the head it feeds, moisture, four slots +
+
+ C · Spool objects — 58×80 each +
+ AMS_CAN_LIB_SIZE · the column is how much is left +
+
+ D · The cabinet — 310×71 · what is + used today +
+ #EEEEEE over #CECECE · seam through the roll at + 5+18 · 16 px of shoulder either side, and it does not resize +
+
+ D′ · The stock feeder — the same drawing, + 94×71 +
+ #FFFFFF over #1F1F1F · the Automatic Filament Feeder + Module is a different device and one glance should say so +
+
+ Their badges — 44×26 and 17×17 +
+ the ACE badge is wide because a cabinet is; the feeder's is + square, because a wide badge for it only ever read as a squashed ACE +
+
+ The toolhead — 64×140, and at half +
+ extruderBackground.svg · the marker is centred on the + artwork's BODY at (32, 72.5), not on its box +
+
+

D is the third box, and it is the one in use. A and B are the + AMS language at two scales; D is what the Device page's Filament panel draws + today, with device.css's own numbers, and the badge in section 1 + is its portrait — which is the whole reason G exists, since the cabinet + has parallel sides and uncropped spools and a badge of it should have both too. + Change a number here and resources/web/device_page/css/device.css has to + move with it, or check_mockup.py says so.

+

And when Spoolman is bound, the bay can say how much is + left. The cabinet draws a flat colour today for the reason below — an ACE + slot has no material_remain, so a disc is a colour and not a gauge. + That holds only while nothing is bound. With spool_mode: "spoolman" and + spool_binding mapping the slot to a Spoolman id there is a + weight, and then the rule the AMS column already follows applies to the disc: the + filament colour from the bottom up to how much is left. One drawing with two + states, not two drawings — switch it with the Remaining row in the deck + and watch D change with C. Unmeasured is hatched rather than empty, because + unmeasured and used up are different things and a bar at zero says the + second one. The badge does not follow: at 6×16 a level is not readable, + and colour and emptiness are all it claims to carry.

+

A bay is .slot's own, and that is what fixes the + width. A 36 px disc over a 58×19 name pill, 6 px apart, in a + 62 px column with 10 px gaps — four of those plus 16 px of + shoulder either side is 310 px, at every panel width there is. The column + is flex: 0 0 on purpose: shrinking it would be a second set of + proportions for the same object, which is the thing this sheet exists to prevent. + What that costs is measured in + the actions study + — the card needs a 330 px cell, and below a 655 px panel a bay is + cut.

+

The seam is disc-relative, not a percentage. + 5px + var(--disc)/2 puts the hard colour stop through the middle of + every roll, so half of each is in each half of the box — and widening the gap + under a roll moves the chip without moving the seam. Splitting the bay across the two + halves is what made .slot's own 36/58/19 affordable: with both in + the upper half the box was 71 px tall and a bay had to shrink to 26/52.

+

The feeder is the cabinet's drawing, not a drawing of its + own — same padding, same radius, same seam rule, one bay instead of four. A + frame may change horizontal padding and nothing else, or a feeder's spool stops + sitting at an ACE bay's exact height. Its badge follows the same idea: the frame + at badge size, white over black on a rounded square.

+

The band carries identity, the ground carries contents. Left to right the band + is: unit number in a brand-coloured square, the model name the printer gives it + (protocol: "v2"ACE 2 Pro, "v1"ACE Pro), the + toolhead it feeds ACE, and the moisture pill hard right — which is + where Bambu puts it on the AMS.

+

Where the level comes from — and the one thing to settle. The AMS has + material_remain 0–100 and draws it directly. An ACE slot has no such + field: /api/state's slots[] carries material, brand, colour and + source, and nothing about quantity. But your printer runs spool_mode: "spoolman", + and spool_binding maps slots to Spoolman ids — + {"0_0":"15", "0_1":"10", "0_3":"16"} — each carrying a real + weight_g: 500 g, 998 g and 843 g. So three of + your four slots can drive the column honestly; S3 is not bound, which is why it is drawn + full but hatched and labelled amount unknown rather than pretending to be full.

+

What is not available is a percentage: Spoolman knows the initial weight, + this payload does not, so a column scaled to 1 kg would call an 843 g spool 84% when it + may be a full 850 g one. Show the grams, and treat the column as a gauge rather than a + measurement — or fetch remaining_weight from Spoolman directly and scale + it properly, which is its own small piece of work. + AceSlot parses none of this today ACE, so wiring the + binding through is the prerequisite for the column meaning anything.

+

A unit that is configured but not answering ACE has no + AMS equivalent: an AMS is only reported when present, whereas an ACE lives in the preset and the + printer may be off, on cloud, or short of a unit. It takes + AMS_CONTROL_DISABLE_COLOUR through the same Enable(false) path the AMS + widgets already have — grey cubes, grey badge, grey text — so it is legibly there + but unreadable, rather than absent or, worse, empty.

+
+
+ + +
+
4The numbers, and where they come from
+
+

One source, two implementations

+

The ACE is drawn in native wx (the sidebar, the mapping popup) and in webviews + (the assignment dialog, the U1 + multiACE page). So the standard is one spec with two + implementations, and the numbers are pinned to the existing macros so the two cannot drift.

+
+ + + + + + + + + + + + + + + + + + + + +
Thingwx macroValueCSS token
ACE badge— (new)44×26 fillace_badge()
ACE glyph— (new)44×26 line, stroke 1.6ace_glyph()
ACE glyph, square— (new)24×24 line, stroke 1.6ace_glyph_square()
Slot cubeAMS_ITEM_CUBE_SIZE14×14, r2internal to AMSPreview — never drawn alone
StripAMS_ITEM_SIZE82×27, r3, pad 7, gap 5.strip
Strip + moistureAMS_ITEM_HUMIDITY_SIZE120×27.strip + .pill
Slot cardAMS_CAN_LIB_SIZE58×80.lib
Moisture pillAMS_HUMIDITY_SIZE93×26, r13.pill
… level onlyAMS_HUMIDITY_NO_PERCENT_SIZE60×26.pill.short
Band groundAMS_CONTROL_DEF_BLOCK_BK_COLOUR#EEEEEE--ams-block-bk
Spool groundAMS_CONTROL_DEF_LIB_BK_COLOUR#F8F8F8--ams-lib-bk
SelectionAMS_CONTROL_BRAND_COLOUR#009688, 2px--ams-brand
UnreadableAMS_CONTROL_DISABLE_COLOUR#CECECE--ams-disable
Pill divider— (literal in AMSHumidity)#C2C2C2, 20px--ams-rule
Dropletshum_level1..5_{light,dark}16px SVGsame files
Dryerams_drying / ams_is_drying16px SVGsame files
+
+

One arithmetic snag to settle in code: padding 7 plus four 14 px cubes + plus three 5 px gaps is 85, not the 82 the constant states. The AMS gets away with it because + callers size the preview themselves. For the ACE, fix it once — gap 4 lands exactly on + 82 — rather than letting each surface pick.

+
+
+ + +
+
5What changes where
+
+

Every surface that draws an ACE

+
+ + + + + + + + + + + + + + + + + + + +
SurfaceDraws nowBecomes
Filament mapping popup
AmsMappingPopup.cpp
Orca's AMS widgets, via the amsList projectionNothing. It is already the standard — this is the reference.
Printer panel
Plater.cpp sidebar
7×15 px bars in an ad-hoc rounded stripA — Strip (82×27), four 14 px cubes. Same width as the AMS row it mirrors.
U1 + multiACE page
resources/web/multiace
60 px circular spools with a hub; 36 px circular swatchesB — Unit box with C — slot cards. The circles are the biggest + departure and the one worth losing: nothing else in Orca draws filament round.
Assignment dialog
resources/web/aceplan
96 px .pos cards in a teal-banded .aceboxB + C, keeping the drag targets. Closest already; mostly re-tokening.
Device / AMS tab
AMSControl, AmsItem
Orca's AMS widgets, fed by the projectionNothing, beyond naming the unit ACE 2 Pro rather than AMS.
+
+

Order I would take it: the strip ships with the printer panel, since that PR + is drawing one anyway and it is the cheapest place to prove the tokens. The unit box and slot cards + then land as one restyle PR across the two web pages, which is where the four languages actually + collapse into one. The two native surfaces need no work at all — which is the argument for + this standard rather than a new one.

+
+
+ +
+ + diff --git a/docs/ace-mmu/plate-thumbnails-options.html b/docs/ace-mmu/plate-thumbnails-options.html new file mode 100644 index 00000000000..106c6856bd2 --- /dev/null +++ b/docs/ace-mmu/plate-thumbnails-options.html @@ -0,0 +1,421 @@ + + +U1 build plates in the UI + + +
+
+

U1 build plates in the UI

+

Snapmaker sells three plates for the U1 — Textured PEI, Smooth PEI and Graphic + Effect — and both the EU and US stores list exactly those, one variant each. Their product + photography is below, sampled at the plate's centre so what you see is the surface + rather than the silhouette.

+

And a fourth, from elsewhere. Cool Steel Plate is in the U1's default list but + Snapmaker does not sell it separately, so there was no photo for it. BIQU's + CryoGrip + Pro for the Snapmaker U1 fills that slot: a double-sided 7-layer composite in two + finishes — Frostbite (coarse) and Glacier (fine) — and, for Glacier, five + colours. Same U1 mount, so the measured silhouette carries over unchanged.

+

Scope: these three, plus Cool Steel. The app can offer more — turning on + support_multi_bed_types swaps the U1's list for a longer one that includes Cool + Plate, Engineering Plate and the rest of the Bambu-lineage set. Those are + deliberately out of scope here; §3 records what they are and what they would need, so + the decision is on paper rather than rediscovered later.

+
+ +
+
1Three photographed, one drawn
+
+

Photographed, cropped to surface

+

The first three sample the middle of Snapmaker's own flat product shot; Cool + Steel is drawn, because Snapmaker does not sell one. Worth noting because I had guessed + wrong before looking: Smooth PEI is matte black, not amber — only Textured PEI + is the bronze people picture when they hear "PEI".

+
+
+

The shape is measured, not traced. My first + attempt was drawn by eye and was wrong in every dimension — deep crenellations where + the plate has small nicks. So the product PNG was decoded to raw pixels and the top and + bottom edges sampled per column. The plate is 391×413 (aspect 0.947); the three + top notches sit at x 26 / 50 / 74, each about 6 wide and 2.9 deep; the + bottom has a tongue from x 23 to 77.5 protruding 2.9 below the sides; + corners are radius ~2. All as percentages of the plate's own box.

+

How literal to draw it was a real choice. A notch 2.9% deep on a + 40 px-tall thumbnail is just over one pixel — drawn truthfully, the plate + is a rounded square with three nicks nobody will see. Exaggerating is a normal icon + convention but it is a decision, so it was made deliberately: Emphasised, the + measured notches roughly doubled. The alternates stay as the record of what was weighed.

+
+

Only Snapmaker's own plates are photographed. The first three are + first-party products for their bed types, so a photo is simply what the plate looks like. + Cool Steel is drawn instead — Snapmaker does not sell it separately, and the + nearest real product is a third party's (BIQU's CryoGrip Pro, which does fit the U1). Using + that art would brand a bed type with a vendor the app never otherwise mentions, and + a user selecting Cool Steel Plate may own any plate or none. So it is a generic cool + plate: a cool blue with a soft sheen, no maker's marks, no invented texture pretending to be + a particular surface. The same rule applies to the advanced-mode plates if they are + ever added.

+

Shape as well as surface. A square crop showed the material but could + have been any plate on any printer. These are clipped to the U1 plate's own outline + — three shallow notches along the top and a wide tongue along the bottom — so + the three plates share a shape that says they belong to the same machine, which is most of + what a thumbnail can usefully carry.

+

Aspect is preserved, not stretched. The plate is square, the card slot + is 44×40, so the drawing letterboxes rather than squashing — the notches stay the + shape they are on the real plate. The texture underneath is still sampled from the middle of + the photo, so no background or perspective creeps in at the edges.

+
+
+ +
+
2In the panel
+
+

The plate card, and the picker behind it

+

The card is 92 px — the width Bambu uses — so the label clips to + Textur… and the picture carries the identification. Click a plate below to change + the card.

+
+

This is why the card can be narrow. With a picture, a clipped label is + a caption; without one it is just clipped text, which is why the row stays full-width until + the art exists.

+
+
+ +
+
3Noted, not now — the advanced-mode plates
+
+

What else the enum can offer, and why it waits

+

PrintConfig.cpp holds three bed-type lists, not one: the + generic Bambu set of six, the U1's own set (// U1 only 4), and a seven-entry + set used when support_multi_bed_types is on (// U1 use 7 …). + Only the first three rows below are plates Snapmaker sells for the U1 — the rest appear + only in that advanced mode, and are left alone for now.

+
+ + + +
EnumName on the U1Name genericallyThumbStatus
+
+

The trap to remember when they are picked up. The same + BedType value is named differently depending on the printer: + btPEI is Smooth High Temp Plate generically but Smooth PEI Plate + on the U1, and btSuperTack is Cool Plate (SuperTack) generically but + Cool Steel Plate on the U1. So a thumbnail must key off the enum value, never + the label — otherwise the U1 shows a Bambu plate under a Snapmaker name, or nothing at all.

+

Cool Steel is the awkward one and worth flagging now: it sits in the + U1's default list of four, yet Snapmaker does not sell it separately — it ships + with the printer, so there is no product page to photograph from. If the U1's default set + should be complete, that is the one plate you would have to shoot yourself.

+
+
+ +
+
4Before any of this ships
+
+

These images are Snapmaker's. They are used here + as a design reference, the same way ui-snapshots-inspiration/ holds Bambu's + screenshots. Shipping them inside an AGPL-3.0 repository is a different question and + the answer is probably no without permission — so the options that actually ship are: ask + Snapmaker, photograph the plates yourself, or draw them. + decide before PR

+

And a format note. Two of the three sources are .webp, + which Orca's icon path cannot read — BitmapCache.cpp goes through nanosvg for + SVG and wxWidgets handlers for raster. Whatever is chosen ships as PNG at 1×/2×, or + as SVG if drawn.

+
+
+
+ + diff --git a/docs/ace-mmu/printer-panel-mockup.html b/docs/ace-mmu/printer-panel-mockup.html new file mode 100644 index 00000000000..280d9f599e9 --- /dev/null +++ b/docs/ace-mmu/printer-panel-mockup.html @@ -0,0 +1,1116 @@ + + +The U1 printer panel + + +
+ +
+

The U1 printer panel

+

Prepare and Preview share one sidebar, so the Printer section at the top of it is a single + widget seen in two tabs. This rebuilds it to Bambu Studio's shape: three cards across the top, + a bordered box per head, a green corner tick where the app agrees with the machine, an edit + popover behind the ACE row, and the two-step Sync info flow that ends by offering to sync + filaments.

+

The panel below is interactive, and the Your U1 scenario carries real values — + the ACE, its mode and its four PETG spools, read from 192.168.2.242 on 21 Aug 2026. + They are a snapshot, baked into this page: it is a static file and does not talk to the + printer, and as a published artifact it could not reach your LAN even if it tried. Press + Sync info, open a head's ACE widget, choose an ACE mode, and switch the machine + underneath it. Every screenshot further down is the Bambu original the corresponding piece is + copying.

+

One row has no Bambu equivalent, because the U1 needs it: ACE mode. The printer has its + own three-way switch — SET_ACE_MODE MODE=normal|multi|head — and it + decides whether per-toolhead wiring means anything at all. The panel mirrors it, and Sync info + reads it. That switch is also the answer to “can one unit feed more than one head”: + in Per toolhead mode, yes — tick the same unit on two heads.

+
+ + +
+
ProposedBambu's panel, for four heads
+ +
+
+ Machine + + + + + + + + +
+

+
+ +
+
+
+
Plate 1
+
+
+
+ + +
+
ReferencePiece by piece, against the original
+
+ +
+
+
+ Bambu Studio printer cards +
+

Bambu Studio · H2D

+
+
+

Three cards, and a tick that means something

+

Printer, plate, and Sync info sit side by side at equal height; the printer card + carries its thumbnail above the preset combo, the plate card an , and the + green corner triangle marks a card that agrees with the connected machine.

+

Ours is the same three. The plate card takes over today's Bed type row, + which frees the whole row it used. The corner tick appears on the printer card when the + selected preset matches the machine's reported model and nozzle size — and on a head + box when its ACE wiring matches what the printer reports. That is a claim we can actually + make, because sync_ace_topology already computes exactly that diff.

+
+
+ +
+
+
+ Bambu Studio nozzle boxes +
+

Bambu Studio · per-nozzle boxes

+
+
+

A box per head, two across

+

Bambu draws Left Nozzle and Right Nozzle as two bordered panes, each with an + AMS row, a Diameter combo and a Flow combo. The AMS row shows a small + thumbnail of the loaded spools when a unit is attached, and a chevron when none is.

+

Ours is four of the same box, wrapped 2×2. Same label position, same row + grammar, same widths — the ACE row shows the unit's spool colours as a strip, + or a chevron on a stock feeder. Flow is absent: this fork deleted it deliberately and there + is no setting behind it.

+
+
+ +
+
+
+ Bambu Studio AMS count popup +
+

Bambu Studio · AMS popover

+
+
+

The popover that sets what is attached

+

Clicking the pencil beside AMS drops a panel: a sentence, then a grey inset listing + each kind of unit with a spinner — AMS (4 slots), AMS (1 slot).

+

Ours keeps the sentence and the inset, and swaps the spinner for a tick. A count + is the wrong control here: multiACE binds a head to one named unit + (ACE_SET_HEAD_ACE HEAD=n ACE=u) or puts it on its own feeder + (ACE_SET_HEAD_FEEDER), so the list is a choice — one row per macro, + one tick. Units carry the printer's own names, ACE 1 · ACE 2 Pro, with slots, + connection and humidity beneath. Between them the two rows write exactly + ace_head_capacity and ace_head_unit. Open Toolhead 4's + ACE widget in the panel above to use it.

+
+
+ +
+
+
+ Bambu Studio sync confirmation +
+

Bambu Studio · after Sync info

+
+
+

Sync in two steps, not one

+

Bambu's Sync info reads nozzle and AMS-count information, then says so and offers + Continue to sync filaments beside Cancel — the count sync and the + contents sync are separate presses, chained by an offer.

+

Ours chains the two that already exist. Step one is today's nozzle query plus + sync_ace_topology in one press; step two hands off to + Sync Filament Information, which already lists the four toolheads and every ACE slot. + Press Sync info in the panel above to walk it, including what it says when the + printer is off and when the preset turns out to be wrong.

+
+
+ +
+
+
+ Bambu Studio project filaments +
+

Bambu Studio · the section below

+
+
+

And the half of the screenshot below the fold

+

The same screenshot continues into Project Filaments: a numbered, colour-coded badge + per filament in two columns, a menu on each, add/remove/sync/settings in the + sub-header, a Purging volumes pill on the title bar, and a corner tick on each + filament that matches what the AMS holds.

+

That is a different section and a different PR. This fork already has it as + Filament Management — badges, combos, per-filament menus, flushing volumes and + Color Mixing — so it is a restyle rather than a rebuild, and it is the natural + follow-on once the printer section lands. The panel above ends on its title bar to show + where the seam is.

+
+
+ +
+
+ + +
+

The four places it cannot follow Bambu, and what it does instead

+
    +
  1. Four heads, not two nozzles. Bambu's two boxes sit side by side across the sidebar. + Four at that width would be 110 px each — too narrow for a labelled combo. So the same + box is wrapped 2×2, which keeps Bambu's proportions and costs one extra row of + height. That row lands in Preview too, where the sidebar competes with the sliced preview: + the head boxes are the section to collapse first if it proves too tall.
  2. + +
  3. An ACE is not an AMS, and the popover is a choice rather than a count. Bambu counts + interchangeable units by kind, so a spinner fits. multiACE binds a head to a named unit + with ACE_SET_HEAD_ACE HEAD=h ACE=a, or puts it on its own feeder with + ACE_SET_HEAD_FEEDER HEAD=h ENABLE=1 — two macros, so two kinds of row and a + tick rather than a number. Units are named as the printer names them: ACE 1 · ACE 2 + Pro from protocol: "v2", ACE Pro from "v1", with slot + count, connection and humidity underneath.
  4. + +
  5. No Flow row. Bambu's third row picks a flow calibration; this fork removed the control + and there is no setting behind it. Adding a dead combo to look like the screenshot would be + worse than the gap. The head box has two rows, not three.
  6. + +
  7. Contents are LAN-only. Bambu reads AMS state over its cloud, so its panel is populated + whenever the printer is online. The multiACE endpoint answers only on the local network, so over + a cloud connection there is no snapshot at all. The panel then draws the preset's topology + — which is real, and is what offline slicing uses — with contents muted rather than + blank, so it does not change height when a printer appears.
  8. +
+
+ +
+

One unit, several heads — and the two bugs it uncovers

+

Read from the printer's own firmware + (/printer/gcode/help on 192.168.2.242), not inferred:

+
    +
  1. The mode is the printer's, so the panel should not invent one. + SET_ACE_MODE MODE=normal|multi|head. Normal is stock feeders and no ACE; + head wires each head individually; multi pools units onto one ACE head. Only in + head mode does per-toolhead assignment mean anything — which is why the mode row + sits above the head boxes and greys them out in Normal. The live machine is in + mode: "head".
  2. + +
  3. Sharing is legal, and the popover is where it happens. ACE_SET_HEAD_ACE + says each head is wired to exactly one ACE — it says nothing about each ACE + feeding exactly one head, and head_ace is a map from head to unit, so several + heads may name the same one. In the panel: tick ACE 1 on Toolhead 4, then tick it on + Toolhead 1. Each row then reads also feeds Toolhead 4, and a warning appears + under the boxes. Try the Shared unit scenario.
  4. + +
  5. Bug 1 — a shared unit double-counts capacity. AceMmuPlan.hpp sums + capacity per head (total_cap += cap[h]) and enforces it per head + (if (++load[h] > cap[h])). Two heads on one 4-slot unit therefore read as + 8 places when there are 4. The infeasible-plate refusal — the whole point of which + is to catch exactly this — would wave through a plate that cannot be laid out. The fix is + a per-unit pool constraint beside the per-head one; the panel already computes and shows the + honest number.
  6. + +
  7. Bug 2 — Combined mode cannot be emitted today. ace_head_capacity + already offers ACE – 6 slots and ACE – 8 slots, but + GCode.cpp's unit_of_head() returns the single + ace_head_unit[h], and the plan's slot is an index within the head. So slot + 5 of an 8-slot head emits ACE=<first unit> SLOT=5 where the machine needs + ACE=<second unit> SLOT=1 — wrong unit, wrong slot, wrong colour. Those + two enum values are not safe until the emitter maps slot → (unit, slot).
  8. +
+

So the sequencing I would take: + ship the panel with Normal and Per toolhead, and leave Combined in the list + but disabled with the reason on it, until the emitter fix lands. Sharing can ship with the panel + — it is drawn honestly and warns — but the planner's per-unit pool should be the very + next PR, because a wrong capacity is a silently wrong plate.

+
+ + +
+
DeferredWhat colour the agreement mark is — today's green stands
+ +

+ In use: #00AE42, the leftmost swatch. Weighed and left alone, because the + mark is not the only green: it is one of four sites, and recolouring it on its own would + put a blue tick inside a green outline. The rest of this section is kept as the working, not as + a pending change — if the swap is ever made it is these four together, and the numbers + below are what it would buy.

+ +
+ + + + + + + + + + + + +
Where the green isSiteWhat it colours
The markPlater.cpp:1075draw_sync_mark — the corner triangle. The only one that + states a fact
The printer cardPlater.cpp:2215–2216panel_bd_col — hovered and pressed border
The head boxesPlater.cpp:9878head_bd_col — hovered border
The assign popoverAceAssignPopup.cpp:19ORCA_GREEN — the selected row and its tick
+
+ +

The corner tick is #00AE42 — Orca's + green, inherited from Bambu, and the same colour the app uses for a hover border and a primary + button. The Device page next to it is built on one accent, #0C63E2, and the mark + is the only thing on the Prepare sidebar that answers a question the Device page also answers: + is this what the machine has?

+ +

Two numbers decide this, and they are not the same + number. The triangle has to be visible against the card, and the white check has to be + legible against the triangle. On a white card those are the same measurement; on a dark + one they pull in opposite directions, which is why the green already has a second value in + dark mode and why one of the options below has one too.

+ +
+
+
In use — #00AE42
+
+
light
+
dark
+
+

check on triangle 2.94
+ triangle on card 2.94 / 7.19

+
+ +
+
A — the accent, straight
+
+
light
+
dark
+
+

check on triangle 5.39
+ triangle on card 5.39 / 3.18

+
+ +
+
B — the mark only
+
+
+ Sync infolight
+
+ Sync infodark
+
+

the mark's numbers are A's.
+ What changes is the pair: blue states, green acts

+
+ +
+
C — a pair, one per ground
+
+
light
+
dark
+
+

check on triangle 5.39 / 3.11
+ triangle on card 5.39 / 5.50

+
+
+ +

What the numbers say. The green is the weakest + of the four where it matters most: a white check on #00AE42 is + 2.94:1, under the 3:1 that non-text graphics are held to, and it is the reason the tick + reads as a green corner with something in it rather than as a check. Every blue here fixes + that on a white card.

+ +
+
+

A — one accent, everywhere the green was

+

Replace #00AE42 with #0C63E2 in draw_sync_mark, and + the two surfaces agree without a second token to keep in step. Costs: the hover + border and the Sync-info button go blue with it, and on a dark card the triangle drops to + 3.18:1 against the panel — visible, but weaker than the green it replaces. + Simplest to hold: one value, one place.

+ +

B — the mark is blue, the affordances stay green

+

Only draw_sync_mark changes. A corner tick states a fact — this + agrees with the machine — while a hover border and a primary button invite an + action; they have shared one colour for no reason beyond inheritance. Blue then means + read from the machine on both surfaces, and green goes on meaning go. + Costs: two accents live in one sidebar, and someone has to be told which is which.

+ +

C — a pair, one value per ground

+

As A, plus #4D93F0 when dark_color_mode is on — the only + option that holds both contrast jobs on both grounds (5.50:1 against the dark panel, + against A's 3.18). Costs: draw_sync_mark gains a branch it does not have + today; the green never had one either, so this is new work rather than a swap.

+ +

Not offered, and why. A blue outline tick instead of + a filled triangle: it reads as a control at 22 dip, and the mark must not look + pressable — it already forwards its clicks to the combo underneath precisely so that + it is not a dead spot.

+
+
+ +

Contrast is WCAG relative luminance, + computed rather than judged: the seam between two colours is the thing the eye is worst at and + the arithmetic is exact. Light card #FFFFFF, dark card #181C20, + check #FFFFFF in every case.

+
+ + +
+

What already exists

+
+ + + + + + + + + + + + + + + + + + +
PieceWhereState
The panelPlater.cpp:2199–2565Title bar, preset card, Bed type row, nozzle notebook — built once in the Sidebar constructor. The three-card row replaces the first two.
The head boxesSidebar::update_nozzle_settings, Plater.cpp:8682Already rebuilds one page per nozzle_diameter entry. Becomes a 2×2 grid of boxes instead of a notebook.
Sync, step one (nozzles)Plater.cpp:2214–2350Queries the machine, opens NozzleDiameterSelectDialog on mixed diameters, selects the matching preset. Keep as-is; it becomes half the press.
Sync, step one (topology)TabPrinter::sync_ace_topology, Tab.cpp:4652Reads /multiace/api/state, diffs per head, writes both options and reports what changed. Lift out of TabPrinter so the sidebar can call it too.
Sync, step two (filaments)Sidebar::show_sync_filament_dialog, Plater.cpp:8467The non-destructive filament sync, already listing U1 toolheads and ACE slots via append_ace_filament_list. This is what Continue to sync filaments opens.
The topologyace_head_capacity, ace_head_unitPer-head coInts in the printer preset, so the panel and offline slicing share one source.
Live contentsAceMmuProvider, AceSnapshotUnits, slots, colours, materials, humidity, per-head ace/slot bindings. One fetch_once().
+
+

One thing to know before building: the U1 connects as a + PrintHost through the webview, not as a MachineObject, so this panel + cannot lean on MachineObject::poll_ace_ams() the way the AMS UI does. It resolves a + host with AceMmuProvider::resolve_connected_host() and reads on demand — which + is the other reason Bambu's press-to-sync flow fits the U1 better than a background poll.

+
+ +
+ + diff --git a/docs/u1-webui/00-shared/01-shared-models.md b/docs/u1-webui/00-shared/01-shared-models.md new file mode 100644 index 00000000000..45c92e24f02 --- /dev/null +++ b/docs/u1-webui/00-shared/01-shared-models.md @@ -0,0 +1,150 @@ +# What the two surfaces share + +The Device tab and the print-processing popup are the **same Flutter application at +two routes**. That is not a surface resemblance — it has concrete consequences for +anyone rebuilding or modifying either one, because a large majority of the model is +common and only the presentation differs. + +This page is the answer to "what can we reuse?", written from the reconstructions in +`resources/web/`, where the shared parts are factored into `resources/web/shared/`. + +## The short answer + +| Layer | Shared? | Where | +|---|---|---| +| Bridge envelope and client | **fully** | `shared/js/sswcp.js` | +| Command names | **mostly** | `shared/js/protocol.js` — `CMD` | +| Machine-state model and field filters | **fully** | `shared/js/protocol.js` — `SUBSCRIBE_OBJECTS` | +| State store and typed accessors | **fully** | `shared/js/state.js` | +| `print_task_config` | **fully — but different verbs** | `shared/js/protocol.js` — `TASK_CONFIG` | +| Fault decoding | **fully** | `shared/js/protocol.js` — `decodeErrorCode` | +| Simulated host | **core shared, fixtures per surface** | `shared/js/mockhost.js` | +| Design tokens | **shared base, per-surface override** | `shared/css/base.css` | +| Control limits | Device only | `LIMITS` | +| Rendering | per surface | each surface's `ui.js` | + +Measured over the two reconstructions: **1,067 lines shared**, against 604 lines for the +Device tab and 752 for the popup. Roughly **44% of the total is common code**, and the +shared half is the part that encodes the protocol — the risky part. + +## 1. One bridge, one envelope + +Both host windows forward script messages to the *same* C++ entry point: + +```cpp +// PrinterWebView::OnScriptMessage and WebPreprintDialog::OnScriptMessage +SSWCP::handle_web_message(evt.GetString().ToUTF8().data(), m_browser); +``` + +So the envelope, the `seqid` correlation, the `event_id` subscription rule, the +ack-then-push ordering and the `code: 200` success contract are identical. A bridge +client written for one surface works unmodified in the other — `shared/js/sswcp.js` +is imported by both without a per-surface branch. + +## 2. One state model + +Both surfaces subscribe to the same 24 Klipper objects with the same per-object field +filters. The popup does not define a narrower subscription; it reuses the Device tab's, +because it needs `print_task_config`, which only arrives as part of that set. + +This means `SUBSCRIBE_OBJECTS` and the whole `MachineState` store — including the typed +accessors `toolheads()`, `taskConfig()`, `bed()`, `job()` — are common. + +## 3. `print_task_config` is the hinge + +This is the most useful thing to know when modifying either surface. + +``` + print_task_config + | + reads | writes + ┌────────────────────┴────────────────────┐ + │ │ +Device tab Print-processing popup +labels each toolhead with edits the filament→toolhead +the filament loaded in it mapping and the three toggles +``` + +Same object, same fields, opposite verb: + +| Field | Device tab | Popup | +|---|---|---| +| `filament_type`, `filament_color` | shows per toolhead | shows per filament row | +| `extruder_map_table` | — | **writes** the mapping | +| `extruders_used` | which heads are in the job | which heads the mapping targets | +| `flow_calibrate` | — | **writes** — *Extrusion Flow Calibration* | +| `time_lapse_camera` | — | **writes** — *Time-lapse Camera* | +| `auto_bed_leveling` | — | **writes** — *Auto Leveling* | + +Practical consequence: **a change to how filament is modelled affects both surfaces.** +If you add a field to the mapping, the Device tab's toolhead cards are the second place +it has to be handled. `TASK_CONFIG` and `PRINT_PREFERENCES` in `shared/js/protocol.js` +exist so those field names are written once. + +## 4. Commands: a shared core and two small tails + +Of the commands the reconstructions issue, the identity and state calls are common; +each surface then adds its own tail. + +**Common** — `sw_GetConnectedMachine`, `sw_GetMachineSystemInfo`, `sw_GetPrinterInfo`, +`sw_GetSoftwareInfo`, `sw_SetSubscribeFilter`, `sw_GetMachineState`, +`sw_SubscribeMachineState`, `sw_StopMachineStateSubscription`, plus the logging +commands (`sw_FileLog`, `sw_Log`, `sw_UploadEvent`). + +**Device tab only** — the control surface: `sw_ControlBedTemp`, +`sw_ControlExtruderTemp`, `sw_ControlMainFan`, `sw_ControlGenericFan`, `sw_ControlLed`, +`sw_ControlPrintSpeed`, `sw_ControlPurifier`, `sw_SendGCodes`, and print-job control +(`sw_MachinePrintPause` / `Resume` / `Cancel`). + +**Popup only** — the send flow: `sw_GetActiveFile`, `sw_GetPrintLegal`, +`sw_GetPrintZip`, `sw_GetFileFilamentMapping`, `sw_UpdateMachineFilamentInfo`, +`sw_SetFilamentMappingComplete`, `sw_FinishFilamentMapping`, `sw_FinishPreprint`, +`sw_StartLocalPrint` / `sw_StartCloudPrint`. + +## 5. One simulated host + +`shared/js/mockhost.js` implements the envelope machinery, the subscription +bookkeeping and a U1 simulation. Each surface passes a `handlers` map for the commands +only it needs: + +```js +// print_processing/js/mock.js +const host = installMockHost({ log, handlers: { + sw_GetFileFilamentMapping: (_p, ctx) => { /* built FROM print_task_config */ }, + ... +}}); +``` + +The popup's filament fixture is **derived from the same `print_task_config` the Device +tab reads**, so the two surfaces cannot drift apart in the simulation either. + +## 6. Tokens, with a caveat + +`shared/css/base.css` defines the palette and the build badge. Each surface layers its +own stylesheet on top. + +One trap worth recording: the Device tab commits to a single dark look and overrides +`:root` wholesale. It must therefore define **every** token `base.css` styles against — +not just the ones its own rules use — or shared components render this surface's dark +panels with the base stylesheet's light-theme text. `device.css` now carries explicit +aliases (`--ink`, `--ink-2`, `--accent-in`) for exactly that reason. + +## What is genuinely not shared + +- **Rendering.** The two `ui.js` modules have no overlap; the layouts are unrelated. +- **Control limits.** `LIMITS` (bed 0–100 °C, speed 50–150 %) come from the Device tab's + validation strings and mean nothing in the popup. +- **Lifecycle.** The popup is modal and closes itself over the bridge + ([lifecycle](../03-print-processing/02-lifecycle.md)); the Device tab is a persistent + panel with no close protocol. + +## Reuse checklist + +When you next modify either surface: + +1. Protocol change → `shared/js/protocol.js`, then run + `python3 resources/web/shared/tests/conformance_test.py`. +2. Bridge behaviour change → `shared/js/sswcp.js`; it is imported by both. +3. Filament or print-config change → check **both** surfaces; see §3. +4. New simulated command → shared if both use it, otherwise the surface's own `mock.js`. +5. New visual token → `shared/css/base.css`, and add the alias to `device.css`. diff --git a/docs/u1-webui/00-shared/02-build-badge.md b/docs/u1-webui/00-shared/02-build-badge.md new file mode 100644 index 00000000000..f5d9e1722e0 --- /dev/null +++ b/docs/u1-webui/00-shared/02-build-badge.md @@ -0,0 +1,81 @@ +# The build badge + +Both reconstructed surfaces render a small badge in the bottom-right corner naming the +surface, the version, the bundle build number and the git commit: + +``` +Device · v2.3.26 · build 20260813142841 · 40ce3f0c38+ +Print processing · v2.3.26 · build 20260813142841 · 40ce3f0c38+ +``` + +A trailing `+` on the commit means the working tree had uncommitted changes when the +stamp was written — so what is on screen is not exactly that commit. + +## Why it exists + +It is a **visual marker for telling a reconstruction apart from the shipped Flutter +bundle**. The reconstructions imitate the real surfaces closely enough that a screenshot +alone is ambiguous; the badge removes the ambiguity at a glance, and names which of the +two surfaces you are looking at. + +It is deliberately the smallest possible change that is still unmissable: one fixed +element, no layout shift, no interaction. + +## Where the number comes from + +`shared/js/buildinfo.js` **merges** three sources rather than picking one, because none +of them carries all three facts: + +| Source | Supplies | When | +|---|---|---| +| `../shared/build-stamp.json` | the git commit, branch, dirty flag | when stamped — see below | +| `../flutter_web/version.json` | bundle version + build number | always; it ships in the repo | +| `sw_GetSoftwareInfo` | Orca's own version | only with a real host | + +The ordering matters. `sw_GetSoftwareInfo` returns a `version` but **no** build number, +so it refines the version and leaves the build number to `version.json`. An earlier +version of this code treated the sources as exclusive and paired Orca's real version +with a hard-coded fallback build number — which looked right and was wrong. + +The commit cannot come from the bundle, so it is stamped into a small JSON file: + +```bash +python3 resources/web/shared/stamp_build.py +``` + +Re-run it after committing. The stamp is optional — without it the badge simply omits +the commit. + +```json +{"app_name":"orca","version":"2.3.26","build_number":"20260813142841","package_name":"orca"} +``` + +Hovering the badge shows the source it resolved from and the build number formatted as a +timestamp (`2026-08-13 14:28:41`). + +## Verifying a rebuild + +1. Open either surface — in Orca, or standalone with `?mock=1`. +2. The badge names the surface and the build. +3. Change `build_number` in `resources/web/flutter_web/version.json`, reload with no host, + and the badge follows — which confirms the page is reading the real file rather than a + baked-in constant. +4. For the commit: make a commit, re-run `stamp_build.py`, reload. The hash follows and + the `+` disappears once the tree is clean. + +Inside Orca, the badge is also how you tell the reconstruction from the shipped Flutter +page — see [serving the reconstructions in Orca](03-serving-in-orca.md). + +## Implementation + +Both surfaces mount it identically, which is the point: + +```js +import { mountBuildBadge } from '../../shared/js/buildinfo.js'; +mountBuildBadge(ui.$('#build-badge'), 'Device', bridge); // device_page +mountBuildBadge(ui.$('#build-badge'), 'Print processing', bridge); // print_processing +``` + +Styling lives in `shared/css/base.css` under `.build-badge`. A surface that overrides +`:root` wholesale — as the Device tab does for its dark look — must alias the shared +tokens (`--ink`, `--ink-2`, `--accent-in`) or the badge inherits mismatched text colours. diff --git a/docs/u1-webui/00-shared/03-serving-in-orca.md b/docs/u1-webui/00-shared/03-serving-in-orca.md new file mode 100644 index 00000000000..45a131e52d4 --- /dev/null +++ b/docs/u1-webui/00-shared/03-serving-in-orca.md @@ -0,0 +1,131 @@ +# Serving the reconstructions in Orca + +Orca loads the reconstructed Device tab and print-processing popup instead of the shipped +Flutter bundle. This page covers the wiring, what still has a switch and what does not, +and what the bundle is still there for. + +## The Device tab has one implementation + +**`?path=2` is not loaded by anything any more.** The tab is +`/web/device_page/index.html`, unconditionally, and `PrinterWebView` no longer carries the +Original / Rebuilt switcher it had while the two were being compared. + +That is a decision about ownership rather than about preference. The shipped Device page +was doing one thing for Orca that no other surface does — telling it what filament is in +the machine, which is what the sidebar's filament combo boxes are built from — and while +both pages existed it was possible to have that work by accident, because someone had the +other one open. The reconstruction does it itself now +([12-orca-integration.md](../02-device-page/12-orca-integration.md)), and with that done a +switch back is a switch to a page that would fight it for the same state: two Device pages +mean two MQTT sessions to one printer and two writers of one record. + +```cpp +// GUI_App.cpp +case U1Surface::DeviceTab: + path = "/web/device_page/index.html"; + break; +``` + +`is_u1_device_tab_url()` matches that URL and only that URL. It deliberately no longer +accepts `flutter_web…path=2`: nothing loads it, and treating it as the Device tab would +register a second page for state pushes only one document can own. + +## The popup still has both + +One config key, still read in the same one function: + +| `u1_reconstructed_ui` | Print popup | +|---|---| +| `true` *(default)* | `/web/print_processing/index.html?mode=print` \| `?mode=upload` | +| `false` | `/web/flutter_web/index.html?path=4` \| `…?path=5` | + +Flip it in Orca's config file — `Snapmaker_Orca.conf` in the data directory — and +restart. No rebuild needed. The key no longer affects the Device tab. + +## What changed in the C++ + +Five sites built the Device-tab URL by hand and two built the popup's. All seven now go +through `get_u1_surface_url()`: + +| File | Was | +|---|---| +| `PrinterWebView.cpp` | `…?path=2` | +| `Plater.cpp` | `…?path=2` | +| `GUI_App.cpp` | `…?path=2` | +| `SSWCP.cpp` (×2) | `…?path=2` | +| `WebPreprintDialog.cpp` (×2) | `…?path=4`, `…?path=5` | + +Two places also *tested* those URLs, and a bare swap would have silently broken them: + +```cpp +// PrinterWebView::load_url - decides whether to register the printer view +if (url.find("path=2") != std::string::npos) -> if (GUI_App::is_u1_device_tab_url(url)) + +// PrinterWebView::isSnapmakerPage +return url.find("flutter_web") != npos -> return GUI_App::is_u1_surface_url(url) +``` + +Both helpers accept either implementation, so callers never need to know which is +active. + +Untouched, because they are different surfaces we did not reconstruct: `?path=0` +(`WebViewDialog`), `?path=3` (`deviceControlOld`), `?path=discovery` +(`WebDeviceDialog`). See "The bundle is still shipped" below. + +## Why the URLs resolve + +`HttpServer` picks the root by whether the URL mentions `flutter_web`: + +```cpp +if (trimmed_url.find("flutter_web") == std::string::npos) + res = resources_dir() + trimmed_url; // the reconstructions +else res = data_dir() + trimmed_url; // the shipped bundle, copied on first run +``` + +So `/web/device_page/…` and `/web/shared/…` come from `resources/`, where they live, and +`../flutter_web/version.json` still comes from the data directory. No server change was +needed. + +Two details that make it work: query strings are stripped before path resolution (so +`?mode=print` is harmless), and `.js` is served as `text/javascript` — without which ES +modules would refuse to execute. + +`resources/` is installed wholesale (`install(DIRECTORY "${SLIC3R_RESOURCES_DIR}/" …)`) +and symlinked into the build tree during development, so the new directories are picked +up with no CMake change. + +## The bundle is still shipped, and still used + +Retiring the Device page does not retire `flutter_web`. It is one monolithic +`main.dart.js` with a `?path=` dispatcher, so there is no such thing as removing one +route, and three surfaces still load it: + +| | | +|---|---| +| `?path=0` → `home` | the **Home tab** (`WebViewDialog`). This is where the account lives: the Sign-in control is here, and it is the only caller of `sw_UserLogin` besides the two dialogs below and the Device page's own rail row | +| `?path=3` → `deviceControlOld` | `Plater.cpp`, for a non-Snapmaker host on 127.0.0.1 | +| `?path=discovery` | `WebDeviceDialog`, the Add-Device dialog | + +**The Snapmaker login is not part of any of this.** `sw_UserLogin` opens +`SMUserLogin`, a native wxDialog that loads `https://id.snapmaker.com?from=orca` — +Snapmaker's own login page, over the network, not out of the bundle. Nothing about it +changed and nothing about it should: no reconstructed page has a login form, and the +Device page's rail asks for that dialog rather than offering one. + +## What the reconstructions still do not do + +- Firmware update +- The popup's printer picker (it resolves from `sw_GetConnectedMachine` only), and a + real upload — progress is simulated and `sw_GetPrintZip`'s content is unused +- Cloud printing and account binding, which is the account surface rather than the + machine's + +Device discovery, pairing, the file browser, the camera and the timelapses are built; that +list is older than they are. `check_coverage.py` is the current answer to this question — +every command is either issued by a module a panel is handed or excluded with a reason. + +## Verifying which one you are looking at + +The reconstructions carry a [build badge](02-build-badge.md); the Flutter bundle does +not. If the badge is present you are on the reconstruction, and it names the surface, +the version, the bundle build number and the git commit. diff --git a/docs/u1-webui/01-architecture/01-layer-model.md b/docs/u1-webui/01-architecture/01-layer-model.md new file mode 100644 index 00000000000..f77b0dc558e --- /dev/null +++ b/docs/u1-webui/01-architecture/01-layer-model.md @@ -0,0 +1,99 @@ +# Layer model + +## Four layers + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Layer 1 — the page (Flutter web, compiled to JS) │ +│ resources/web/flutter_web/main.dart.js (5.2 MB, dart2js) │ +│ │ +│ • owns the UI for BOTH the Device tab and the print popup │ +│ • owns the JSON-RPC envelopes sent to the printer │ +│ • owns the machine-state subscription list and field filters │ +│ • owns the MQTT session (the host only relays) │ +└───────────────┬──────────────────────────────────────────────────┘ + │ window.wx.postMessage(JSON) ← page to Orca + │ window.postMessage(JSON,'*') ← Orca to page +┌───────────────┴──────────────────────────────────────────────────┐ +│ Layer 2 — the WCP bridge (C++) │ +│ src/slic3r/GUI/SSWCP.cpp (7,685 lines, 129 commands) │ +│ │ +│ • parses {header, payload{cmd, params, event_id}} │ +│ • routes each sw_* command to one of 8 instance classes │ +│ • owns subscriptions, caching, file I/O, dialogs, account state │ +└───────────────┬──────────────────────────────────────────────────┘ + │ JSON-RPC 2.0 +┌───────────────┴──────────────────────────────────────────────────┐ +│ Layer 3 — the Moonraker client (C++) │ +│ src/slic3r/Utils/MoonRaker.cpp (3,006 lines) │ +│ │ +│ • Moonraker — plain HTTP/WebSocket variant │ +│ • Moonraker_Mqtt — MQTT-over-TLS variant (the one U1 uses) │ +└───────────────┬──────────────────────────────────────────────────┘ + │ MQTT/TLS :8883, topics keyed by printer serial +┌───────────────┴──────────────────────────────────────────────────┐ +│ Layer 4 — Snapmaker U1: Klipper + Snapmaker's Moonraker fork │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Who owns what + +The structural fact that makes this whole surface reverse-engineerable is that +**layer 1 owns the protocol**. The bundle constructs complete JSON-RPC envelopes, +including method names and parameter maps, and hands them down. Layers 2 and 3 +transport, authenticate and apply policy; they do not define the wire format. + +Two consequences: + +- The full RPC surface can be read out of `main.dart.js` even though it is + minified, because **string literals survive dart2js**. Only identifiers are + mangled. +- `SSWCP.cpp` and `MoonRaker.cpp` are a second, independent source for the same + contract, so each side cross-checks the other. + +## The page's own internal structure + +The page logs its construction to the host through `sw_FileLog`, which makes its +internal wiring directly observable. Captured from a live boot +([harness](../tools/harness/README.md)): + +| Component | Role | +|---|---| +| `OrcaGateway` | Owns the WCP client, registers everything else | +| `OrcaAuthBridge` / `OrcaLoginAdapter` | Account state, token → API authorisation | +| `LavaDeviceViewModel` | Device list, connection status, display status | +| `OrcaUseViewModel` | User/session state | +| `OrcaControlViewModel` | The Device page's control surface | +| `ThemeVM` | Light/dark theme | +| `DeviceKeyIvProvider` | Key/IV material for device payload encryption | +| `ConnectionFactory` | Builds a connection per transport (LAN / WAN) | + +`lava_*` is Snapmaker's internal package prefix — see +[bundle inventory](02-bundle-inventory.md). + +## How each surface is mounted + +Both host windows do the same three things: build a loopback URL with a `path` +query parameter, create a `wxWebView`, and forward every +`wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED` verbatim to `SSWCP::handle_web_message`. + +```cpp +// PrinterWebView.cpp — the Device tab +LOCALHOST_URL + port + "/web/flutter_web/index.html?path=2" + +// WebPreprintDialog.cpp — the print popup, 714x750, modal +LOCALHOST_URL + port + "/web/flutter_web/index.html?path=4" // upload and print +LOCALHOST_URL + port + "/web/flutter_web/index.html?path=5" // upload only +``` + +Three host details worth knowing: + +- **DevTools are unconditionally enabled.** In `PrinterWebView::update_mode()` + the `developer_mode` config check is commented out and replaced with a literal + `true`. You can right-click → Inspect the live Device page in a shipping build. + This is the single most useful RE affordance available. +- `PrinterWebView::SendAPIKey()` monkey-patches `fetch` and `XMLHttpRequest` to + inject an `X-API-Key` header, guarded by a `window.__sm_apikey_hooked` flag. + This serves the *legacy HTTP* printer path, not the U1 MQTT path. +- `WebPreprintDialog` is modal and drives its result through the bridge rather + than through a button: see [dialog lifecycle](../03-print-processing/02-lifecycle.md). diff --git a/docs/u1-webui/01-architecture/02-bundle-inventory.md b/docs/u1-webui/01-architecture/02-bundle-inventory.md new file mode 100644 index 00000000000..26c01e86182 --- /dev/null +++ b/docs/u1-webui/01-architecture/02-bundle-inventory.md @@ -0,0 +1,90 @@ +# Bundle inventory + +Everything below is at `resources/web/flutter_web/` in this repo. + +## Identity + +| Field | Value | Source | +|---|---|---| +| App name | `orca` | `version.json` | +| Version | `2.3.26` | `version.json` | +| Build number | `20260813142841` | `version.json` | +| Package name | `orca` | `version.json` | +| Build id | `3bf924fcd55c18a0a3670b1d4d819c93` | `.last_build_id` | +| Flutter engine revision | `b8800d88be4866db1b15f8b954ab2573bba9960f` | `flutter_bootstrap.js` | +| **Renderer** | **`html`** | `flutter_bootstrap.js` | +| Bundle size | 36 MB total, `main.dart.js` = 5,265,242 bytes / 188,957 lines | — | + +## The renderer matters + +`buildConfig.renderer` is `html`, not `canvaskit`. The page is therefore composed of +**real DOM elements and inline SVG**, not pixels in a ``. `canvaskit/` ships in +the bundle but is not the active path. + +For reverse engineering this is the single biggest lever, and it compounds with +`PrinterWebView` enabling DevTools unconditionally: you can right-click the live Device +page, inspect the element tree, and read the layout directly. It also means an HTML +reimplementation is a like-for-like substitute rather than an approximation. + +## What is *not* in the bundle + +- **No source maps.** No `.map` files anywhere. +- **No deferred chunks.** No `.part.js` — the app is one monolithic `main.dart.js`. +- Identifiers are minified (`A.cA`, `B.aR`, `aFQ`). **String literals are not**, which is + what makes the protocol recoverable. + +## Dart dependencies + +242 packages are credited in `assets/NOTICES`. Snapmaker's own internal packages use a +`lava_` prefix — "lava" is their internal codename: + +| Package | Role | +|---|---| +| `lava_device_control` | **The Device page itself.** Ships its own assets (`assets/packages/lava_device_control/`) | +| `lava_device` | Device model / discovery | +| `lava_core` | Shared core | +| `lava_theme` | Design system | +| `lava_dialog` | Dialog components | +| `lava_logger` | Logging | + +Third-party packages that shape the architecture: + +| Package | Role | +|---|---| +| `mqtt5_client` | **MQTT 5 client** — confirms the printer transport | +| `dio`, `pretty_dio_logger` | HTTP client for the Snapmaker cloud REST API | +| `provider`, `get_it` | State management and service location | +| `web_socket_channel`, `web_socket` | WebSocket transport (legacy/HTTP Moonraker path) | +| `sentry_flutter`, `sentry` | Crash reporting | +| `sqflite`, `shared_preferences` | Local persistence | +| `video_player`, `chewie` | Camera / timelapse playback | +| `easy_localization` | i18n, backed by `assets/i10n/*.json` | + +## Assets + +144 entries in `AssetManifest.json`: 109 SVG, 13 WebP, 11 PNG, 9 JSON, 3 fonts. + +| Asset | Why it matters | +|---|---| +| `assets/i10n/en.json` | 1,241-key UI string table — the complete user-visible vocabulary | +| `assets/i10n/zh-CN.json` | Chinese counterpart | +| `assets/files/deviceError.json` | Printer error-code table — see [error codes](../06-errors/01-live-error-catalogue.md) | +| `assets/packages/lava_device_control/assets/files/filament.json` | Filament catalogue used by the Device page | +| `assets/svgs/device/*` | ~45 icons that enumerate the Device page's feature set | +| `assets/svgs/extruder/iconExtruder{1..4}.svg` | Confirms the U1's four toolheads | +| `assets/images/device*.webp` | Connection-state illustrations (authorized, authorizing, rejected, no-network, no-response, not-connected, invalid-version) | + +The device state illustrations are a compact spec of the connection state machine — see +[the connection state machine](../02-device-page/02-connection-state-machine.md). + +## Leftovers in the shipping bundle + +- A hard-coded internal Snapmaker host: `http://172.17.100.32:8100/api`. +- Pre-production cloud hosts: `pre.api.snapmaker.com`, `pre.api.snapmaker.cn`, + `pre.id.snapmaker.com`. +- A **developer route menu** (version + build number, one button per route) built + into the bundle. It navigates by named route (`/deviceControl`, `/preUpload`, …) + and is separate from the `?path=` dispatcher — see + [entry points](03-entry-points.md). +- Test-only routes: `/testDownloadFile`, `/testPrintUploadTask`. +- `manifest.json` is still the unedited Flutter template ("A new Flutter project"). diff --git a/docs/u1-webui/01-architecture/03-entry-points.md b/docs/u1-webui/01-architecture/03-entry-points.md new file mode 100644 index 00000000000..90dbad4ed1a --- /dev/null +++ b/docs/u1-webui/01-architecture/03-entry-points.md @@ -0,0 +1,95 @@ +# Entry points and routes + +Orca opens the same bundle for every surface and selects between them with one +query parameter. Generated by [`tools/extract_routes.py`](../tools/extract_routes.py) +into [`data/entry-points.json`](../data/entry-points.json). + +## The table + +| URL | Alias | `AppModule` | Enum idx | Opened by | +|---|---|---|---|---| +| *(absent)* | — | `unknown` | 0 | — | +| `?path=1` | `home` | `home` | 1 | `WebViewDialog.cpp` | +| `?path=2` | `deviceControl` | `deviceControl` | 2 | `PrinterWebView.cpp` | +| `?path=3` | `deviceControlOld` | `deviceControlOld` | 3 | — | +| `?path=4` | `preUploadAndPrint` | `preUploadAndPrint` | **5** | `WebPreprintDialog.cpp` | +| `?path=5` | `preUpload` | `preUpload` | **4** | `WebPreprintDialog.cpp` | +| `?path=6` | `testPrintUploadTask` | `testPrintUploadTask` | 6 | — | +| `?path=7` | `testDownloadFile` | `testDownloadFile` | 7 | — | + +Anything unrecognised falls back to `home`. + +Each `path` accepts either the number or the alias — `?path=deviceControl` is +equivalent to `?path=2`. + +## The path/index swap + +For every module except two, the URL number equals the `AppModule` enum index. +The exceptions are the two print-popup routes, which are crossed: + +``` +URL ?path=4 -> AppModule.preUploadAndPrint (enum index 5) +URL ?path=5 -> AppModule.preUpload (enum index 4) +``` + +This is in the dispatcher itself, recovered verbatim from the bundle: + +```dart +// A.bFQ() in main.dart.js +if (s == "4" || s == "preUploadAndPrint") return AppModule.preUploadAndPrint; // index 5 +if (s == "5" || s == "preUpload") return AppModule.preUpload; // index 4 +``` + +It is harmless — nothing derives the URL from the index — but it means you cannot +assume `path == AppModule.index`, and the extractor flags it rather than +normalising it away. + +The C++ side is consistent with the *names*, not the numbers: + +```cpp +// WebPreprintDialog.cpp +m_prePrint_url = ".../index.html?path=4"; // upload and print +m_preSend_url = ".../index.html?path=5"; // upload only +... +set_send_page(dlg.post_action() == PrintHostPostUploadAction::None); +// send_page == true -> path=5 -> preUpload (no print) +// send_page == false -> path=4 -> preUploadAndPrint +``` + +So `preUpload` really does mean "upload only" and `preUploadAndPrint` really does +mean "upload then print". The two are confirmed to differ in the UI — +[see the print-processing screenshots](../03-print-processing/01-overview.md). + +## `path=1` is the Home page, not a developer menu + +The bundle contains a developer route menu — a column of buttons, one per route, +each calling `Navigator.pushNamed(context, "/")`: + +```dart +A.bt0: () => GJ(ctx, "/home") +A.bt1: () => GJ(ctx, "/deviceControl") +A.bt2: () => GJ(ctx, "/deviceControlOld") +A.bt3: () => GJ(ctx, "/preUploadAndPrint") +A.bt4: () => GJ(ctx, "/preUpload") +A.bt5: () => GJ(ctx, "/testPrintUploadTask") +A.bt6: () => GJ(ctx, "/testDownloadFile") +``` + +That menu is a *separate screen* which links to these routes. It is **not** what +`?path=1` shows. Loading `?path=1` in the harness renders the **Home / Model +Library** page: a "Recommended for You" grid of community models fetched live +from Snapmaker's CDN, with `Model Library` / `My Devices` / `Recent Files` +navigation and `Open Project` / `New Project` buttons. + +> Corrects an earlier note in `docs/u1-webui/` that described `path=1` as +> the developer route menu. + +The named-route strings above appear exactly once each in the bundle — only in +that menu. The `?path=` dispatcher does not use them; it returns `AppModule` +values directly. So the two mechanisms are independent. + +## Leftovers reachable through these routes + +- `?path=6` and `?path=7` are test-only harnesses (`testPrintUploadTask`, + `testDownloadFile`) and ship in the release bundle. +- `?path=3` (`deviceControlOld`) is a superseded Device page, still present. diff --git a/docs/u1-webui/01-architecture/04-routes-detail.md b/docs/u1-webui/01-architecture/04-routes-detail.md new file mode 100644 index 00000000000..b0203da5269 --- /dev/null +++ b/docs/u1-webui/01-architecture/04-routes-detail.md @@ -0,0 +1,119 @@ +# Routes and screens + +## One bundle, many entry points + +The same `main.dart.js` backs every Snapmaker web surface in Orca. The `path` query +parameter selects which, resolved by the dispatcher `bFQ()` into an **`AppModule`** enum: + +```js +bFQ(a){ var s = a.replace("/","") + if (s==="") return unknown + else if (s==="1" || s==="home") return home + else if (s==="2" || s==="deviceControl") return deviceControl + else if (s==="3" || s==="deviceControlOld") return deviceControlOld + else if (s==="4" || s==="preUploadAndPrint")return preUploadAndPrint + else if (s==="5" || s==="preUpload") return preUpload + else if (s==="6" || s==="testPrintUploadTask") return testPrintUploadTask + else if (s==="7" || s==="testDownloadFile") return testDownloadFile + return home } // fallback +``` + +Every module accepts **either a number or its alias** — `?path=2` and `?path=deviceControl` +are the same route. + +| `path=` | Module | Enum ordinal | Loaded by | +|---|---|---:|---| +| *(unmatched)* | `home` *(fallback)* | 1 | — | +| `""` | `unknown` | 0 | — | +| `1` / `home` | `home` | 1 | `WebViewDialog.cpp` (commented-out line) | +| **`2` / `deviceControl`** | **`deviceControl`** | **2** | **`PrinterWebView.cpp`** | +| `3` / `deviceControlOld` | `deviceControlOld` | 3 | `Plater.cpp` for non-U1 print hosts | +| `4` / `preUploadAndPrint` | `preUploadAndPrint` | **5** | `WebPreprintDialog.cpp` (`m_prePrint_url`) | +| `5` / `preUpload` | `preUpload` | **4** | `WebPreprintDialog.cpp` (`m_preSend_url`) | +| `6` / `testPrintUploadTask` | `testPrintUploadTask` | 6 | — | +| `7` / `testDownloadFile` | `testDownloadFile` | 7 | — | + +### Two traps + +**`path=0` and `path=discovery` are not in the table.** `WebViewDialog.cpp:40` loads +`?path=0` and `WebDeviceDialog.cpp:20` loads `?path=discovery`; neither string matches any +branch, so both fall through to the `home` fallback. The net effect for `path=0` is the +home screen either way, but discovery does *not* reach a discovery module by this route. + +**URL numbers 4 and 5 map to swapped enum ordinals** — URL `4` (`preUploadAndPrint`) is +ordinal 5, and URL `5` (`preUpload`) is ordinal 4. This matters because the page builder +`ckp()` switches on the *ordinal*, not the URL parameter: + +```js +case 4: return new PreUploadPage(filename, isPrint ?? false) // preUpload +case 5: return new PreUploadPage(filename, isPrint ?? true) // preUploadAndPrint +``` + +Read against the URL the defaults look inverted; read against the ordinal they are correct +— "and print" defaults `isPrint` to true. Both modules also accept `filename` and +`isPrint` query parameters alongside `path`. + +### Module-gated behaviour + +`ckp()` sets two flags from the resolved module, so some subsystems behave differently +outside the Device page: + +```js +$.eN.ch = moduleName !== "deviceControl" +$.b3().gca().ch = module !== AppModule.deviceControl +``` + +## The developer route menu + +The bundle still contains a developer landing screen. It renders "Welcome to Snapmaker +Orca", the version and build number, and one button per route — twelve `ElevatedButton`s +wired to the closures that push `/home`, `/deviceControl`, `/deviceControlOld`, +`/preUploadAndPrint`, `/preUpload`, `/testPrintUploadTask` and others. + +It is a genuine debug affordance left in a shipping build, and combined with +unconditionally-enabled DevTools it is the fastest way to reach any screen by hand. + +## Connection state machine + +The seven `assets/images/device*.webp` illustrations enumerate the states the Device page +can show before it has a live printer: + +| Asset | State | +|---|---| +| `deviceNotConnected.webp` | No device selected/connected | +| `deviceAuthorizing.webp` | Pairing in progress — awaiting approval on the printer | +| `deviceAuthorized.webp` | Pairing accepted | +| `deviceRejected.webp` | Pairing refused at the printer | +| `deviceNoNetwork.webp` | Client has no network | +| `deviceNoResponse.webp` | Reachable but not answering | +| `deviceInvalidVersion.webp` | Firmware too old for this client | +| `deviceExceptionMessage.webp` | Fault banner | +| `deviceDisplayDefault.webp` | Placeholder | + +These line up with the pairing handshake in [MQTT transport](../05-printer-protocol/03-mqtt-transport.md): +`deviceAuthorizing` is the window between publishing `server.request_key` and the +`state: "success"` reply, and `deviceRejected` is a non-success reply. + +`IpInputGuide.webp` / `ipInputGuideCN.webp` back the manual-IP fallback, matching the +i18n keys `input_ip_address`, `discovery_tip1`, `discovery_tip2`, `Manual connect`. + +## Device page feature set + +The `assets/svgs/device/` icon set is a compact inventory of what the Device page offers: + +| Group | Icons | +|---|---| +| Print control | `iconControlPlay`, `iconControlPause`, `iconControlStop`, `play`, `pause`, `stop` | +| Temperature | `iconHotBedTemperature`, `iconExtruderHead`, `extruderBackground` | +| Fans / air | `iconFan`, `iconAuxiliaryCooling`, `iconMainCooling` | +| Lighting | `iconLed` | +| Motion | `deviceActionHome`, `iconHome` | +| Speed | `iconSpeed` | +| Filament | `iconFilamentCheck`, `iconFilamentEdit` | +| Chamber | `iconTopCover`, `iconTopCoverInner`, `iconTopCoverOuter`, `iconTopCoverOn`, `iconTopCoverOff` | +| Files | `iconFile`, `iconModelFileFolder`, `exportFile` | +| Camera | `liveCamera`, `videoCall`, `videoPlay`, `iconTimeLapse` | +| Device mgmt | `addDevice`, `iconBind`, `iconEdit`, `delete`, `logout`, `firmwareUpdate`, `wifi`, `iconScan`, `iconSearch` | +| Settings | `settings`, `iconMoreSetting`, `instructions` | + +`assets/svgs/extruder/iconExtruder{1,2,3,4}.svg` confirms the four-toolhead layout. diff --git a/docs/u1-webui/02-device-page/01-overview.md b/docs/u1-webui/02-device-page/01-overview.md new file mode 100644 index 00000000000..547fa9126ad --- /dev/null +++ b/docs/u1-webui/02-device-page/01-overview.md @@ -0,0 +1,93 @@ +# The Device page + +The Device tab, mounted by [`PrinterWebView`](../../../src/slic3r/GUI/PrinterWebView.cpp) +at `?path=2` (`AppModule.deviceControl`). + +## Layout + +Captured from the real bundle with the [harness](../tools/harness/README.md) at +1280 × 900, with a simulated host answering the bridge. + +![Device page](../screenshots/device-control.png) + +Three regions: + +| Region | Contents | +|---|---| +| **Left rail** | the device selector (printer name + status) and a nav list — `Device control` | +| **Camera panel** | `Camera` tab, a live-view toggle and a timelapse toggle; falls back to a "no signal" U1 illustration | +| **Control panel** | `Control` tab, a refresh control, a `Print Preferences ›` link, and the control grid | + +The control grid holds, left to right: + +- a **temperature column** — one row per heater, rendered `_ /_ °C` until state arrives +- a **toolhead selector** — `Tool1` … `Tool4`, confirming the U1's four toolheads +- a **jog step selector** — `10mm` / `1mm` / `0.1mm` +- a **home** button, and the XY/Z jog arrows below + +Everything in the control panel renders disabled while the page is not connected +to a printer — which is the state in this capture. + +## The device selector + +![Device picker open](../screenshots/device-picker.png) + +The selector opens a small menu with `My Devices` and `+ Add Device`. The device +list is not read from the bridge's `sw_GetLocalDevices` response directly; it comes +from the page's own cache, replicated through the host under the cache key +`deviceList` (and `deviceFilamentInfo` for per-device filament state) via +`sw_SubscribeCacheKey`. In this capture the cache is empty, so the list is too. + +## Boot sequence + +Recorded from a live boot with the harness — the page's first bridge calls, in +order, ignoring the interleaved `sw_FileLog` traffic: + +``` +sw_SubUserUpdatePrivacy subscribe: privacy-policy acceptance +sw_SubscribePageStateChange subscribe: page visible/hidden +sw_SubscribeRecentFiles subscribe: recent files +sw_SubscribeLocalDevices subscribe: LAN device list +sw_SubscribeCacheKey subscribe: deviceFilamentInfo +sw_SubscribeCacheKey subscribe: deviceList +sw_SubscribeUserLoginState subscribe: account +sw_GetUserLoginState one-shot: account +sw_UploadEvent telemetry +sw_GetConnectedMachine one-shot: which printer is connected +``` + +Subscriptions are all established before any state is requested. `sw_FileLog` is +interleaved throughout — the page logs its own construction to the host, which is +how the [component list](../01-architecture/01-layer-model.md#the-pages-own-internal-structure) +was recovered. + +## The login gate + +![Login gate](../screenshots/device-login-gate.png) + +If the account cannot be established the page overlays a modal +"Are you sure you want to log in?" gate on whatever screen is loaded — the Device +page, the print popup, and the Home page all show it. + +It is reached by more paths than an actually-logged-out user. In the harness the +account handshake succeeded (`_getUserLoginState 请求成功`) and the gate *still* +appeared, because the page then calls the Snapmaker **cloud** REST API +(`getUserAwsIoTConfig`, `getCloudDeviceList`) and treats a failure there as +`_handleUserOffline`. So an unreachable cloud is indistinguishable, at this gate, +from a signed-out user — worth knowing when triaging "it asks me to log in again" +reports on a LAN-only setup. + +## Connection status + +The page tracks connection through several enums, all recovered in +[`reconstructed/enums.dart`](../reconstructed/enums.dart): + +| Enum | Values | Role | +|---|---|---| +| `ConnectionStatus` | 7 | the live transport state — `unknown`, `connecting`, `connectedOnline`, … | +| `DeviceStatusType` | 26 | the *displayed* status, including every failure mode | +| `DeviceStatus` | 15 | what the machine is doing — `idle`, `working`, `bedLeveling`, … | +| `DeviceConnectStatus` | 10 | pairing/authorisation progress | +| `DeviceConnectionError` | 9 | why a connection attempt failed | + +See [the connection state machine](02-connection-state-machine.md). diff --git a/docs/u1-webui/02-device-page/02-connection-state-machine.md b/docs/u1-webui/02-device-page/02-connection-state-machine.md new file mode 100644 index 00000000000..ee5fa305f6b --- /dev/null +++ b/docs/u1-webui/02-device-page/02-connection-state-machine.md @@ -0,0 +1,176 @@ +# Device connection state machine + +The page models connection with several distinct enums rather than one. All of +them are recovered verbatim from the dart2js constant pool by +[`tools/extract_enums.py`](../tools/extract_enums.py) — the value *names* and +*indices* are the real Dart declarations, not reconstructions. + +## How they relate + +``` +ConnectionStatus the live transport state, driven by the MQTT/LAN session + | (unknown -> connecting -> connectedOnline) + v +DeviceStatusType what the UI displays, folding in every failure mode + | (authorized, rejected, noNetwork, offline, unbound, ...) + v +DeviceStatus what the machine itself is doing, once reachable + (idle, working, bedLeveling, autoLoading, ...) +``` + +`DeviceConnectStatus` and `DeviceConnectionError` describe the pairing attempt +in progress; `DeviceConnectionChannel` and `ConnectionChannel` name the transport +(LAN vs cloud). + +### `DeviceStatusType` + +26 values — minified `A.dS`. The status the UI renders. Each value has a matching illustration or message; `assets/images/device*.webp` covers the visual ones. + +| Idx | Value | +|---|---| +| 0 | `unknown` | +| 1 | `notAvailable` | +| 2 | `authorizing` | +| 3 | `authorizeTimeout` | +| 4 | `authorized` | +| 5 | `rejected` | +| 6 | `lanNotSupported` | +| 7 | `deviceVersionCheckUpdate` | +| 8 | `deviceVersionCheckUpdateFailed` | +| 9 | `noNetwork` | +| 10 | `networkTimeout` | +| 11 | `serverNotReachable` | +| 12 | `networkDisconnected` | +| 13 | `downloadModelFileFailed` | +| 14 | `uploadModelFileFailed` | +| 15 | `offline` | +| 16 | `disconnected` | +| 17 | `modeNotAvailable` | +| 18 | `unbound` | +| 19 | `deviceBusinessHeartbeatException` | +| 20 | `authCheckException` | +| 21 | `authCodeLocaleNotMatch` | +| 22 | `cannotGetConnectedMachineInfo` | +| 23 | `deviceConnectFailed` | +| 24 | `deviceAuthorizationRemoved` | +| 25 | `deviceExceptionMessage` | + +### `DeviceStatus` + +15 values — minified `A.jx`. What the machine is doing. Ten of the fifteen values are calibration or filament-handling states, which is what a four-toolhead machine spends its non-printing time on. + +| Idx | Value | +|---|---| +| 0 | `idle` | +| 1 | `working` | +| 2 | `xyzCalibrating` | +| 3 | `bedLeveling` | +| 4 | `flowCalibrating` | +| 5 | `vibrationCalibrating` | +| 6 | `upgrading` | +| 7 | `error` | +| 8 | `manualSpringScrewAdjusting` | +| 9 | `autoLoading` | +| 10 | `autoUnloading` | +| 11 | `manualLoading` | +| 12 | `dockingCoordinateCalibrating` | +| 13 | `homeOriginCalibrating` | +| 14 | `offline` | + +### `ConnectionStatus` + +7 values — minified `A.n8` + +| Idx | Value | +|---|---| +| 0 | `unknown` | +| 1 | `connecting` | +| 2 | `connected` | +| 3 | `connectedOffline` | +| 4 | `connectedOnline` | +| 5 | `disconnected` | +| 6 | `discontinue` | + +### `DeviceConnectStatus` + +10 values — minified `A.jU` + +| Idx | Value | +|---|---| +| 0 | `unknown` | +| 1 | `disconnected` | +| 2 | `connecting` | +| 3 | `authorizing` | +| 4 | `authorized` | +| 5 | `rejected` | +| 6 | `connected` | +| 7 | `connectionFailed` | +| 8 | `authorizationFailed` | +| 9 | `disconnecting` | + +### `DeviceConnectionError` + +9 values — minified `A.m3` + +| Idx | Value | +|---|---| +| 0 | `none` | +| 1 | `internalError` | +| 2 | `paramsError` | +| 3 | `connectionFailed` | +| 4 | `authorizationFailed` | +| 5 | `timeout` | +| 6 | `networkError` | +| 7 | `certificateError` | +| 8 | `deviceRejected` | + +### `DeviceConnectionChannel` + +4 values — minified `A.EN` + +| Idx | Value | +|---|---| +| 0 | `unknown` | +| 1 | `cloud` | +| 2 | `lan` | +| 5 | `wcp` | + +### `DeviceConnectionTask` + +6 values — minified `A.q2` + +| Idx | Value | +|---|---| +| 0 | `getPrinterObjectList` | +| 1 | `subscribePrinterObjects` | +| 2 | `setSubscribeFilter` | +| 3 | `queryPrinterStatus` | +| 4 | `updateDeviceInfo` | +| 5 | `updateDeviceSystemInfo` | + +### `DeviceDisplayStatus` + +5 values — minified `A.zH` + +| Idx | Value | +|---|---| +| 0 | `online` | +| 1 | `weakConnection` | +| 2 | `offline` | +| 3 | `connecting` | +| 4 | `unknown` | + +## Observed transition + +From a live harness boot, with a simulated host and a stubbed cloud API: + +``` +[LavaDeviceVM] getDeviceDisplayStatus: sn=, connectionStatus=ConnectionStatus.unknown +WcpConnection, _observeClientStatus, isResponding: true, status: ConnectionStatus.connecting +[LavaDeviceVM] getDeviceDisplayStatus: sn=U1SIM0000000001, connectionStatus=ConnectionStatus.connectedOnline +``` + +The serial number appears only once the device is resolved; `getDeviceDisplayStatus` +is called with an empty `sn` on the first pass and is the function that maps +`ConnectionStatus` onto a `DeviceStatusType` for display. + diff --git a/docs/u1-webui/02-device-page/03-strings-and-controls.md b/docs/u1-webui/02-device-page/03-strings-and-controls.md new file mode 100644 index 00000000000..998c605ee92 --- /dev/null +++ b/docs/u1-webui/02-device-page/03-strings-and-controls.md @@ -0,0 +1,75 @@ +# Strings, controls and limits + +`assets/i10n/en.json` holds 1,241 keys — 360 UI strings and 876 error strings (442 codes × +title/desc, plus a few singletons). The UI strings pin down the Device page's exact +control surface, including its validation ranges. + +## Control surface + +| Control | Label(s) | Limit / values | RPC | +|---|---|---|---| +| Heated bed temperature | `Heated Bed Temp.`, `Heated Bed` | **0–100 °C** | `printer.control.bed_temp{temp}` | +| Extruder temperature | `Extruder Temp.`, `Nozzle` | — | `printer.control.extruder_temp{temp,index,map}` | +| Print speed | `Print Speed` | **50–150 %** | `printer.control.print_speed{percentage}` | +| Main cooling fan | `Main Cooling Fan Speed` | — | `printer.control.main_fan{speed}` | +| Assist cooling fan | `Assist Cooling Fan Speed` | — | `printer.control.generic_fan{name,speed}` | +| Air purifier fan | `Air Purifier Fan Speed`, `Exhaust Fan Speed`, `Recirculation Fan Speed` | — | `printer.control.purifier` | +| Purifier mode | `Recirculation Mode`, `Exhaust Mode`, `Current Mode` | 2 modes | `printer.control.purifier` | +| Chamber LED | `Led` | on/off (`white`) | `printer.control.led{name:"cavity_led",white}` | +| Toolhead park/pick | `Park Extruder`, `Pick Extruder` | — | `printer.gcode.script` | + +The exact limits come from the validation tips: + +``` +dialog_device_control_modify_heated_bed_temperature_tips + "Heated bed temperature must be set between 0°C and 100°C" +dialog_device_control_modify_print_speed_tips + "Print speed must be set between 50% and 150%" +``` + +## Machine activity strings + +`machine_state_manager.main_state` / `action_code` are rendered through a fixed set of +activity strings. They are a readable map of the U1's automated routines: + +| Phase | Strings | +|---|---| +| Startup checks | `Checking toolheads...`, `Checking Extruder Park...`, `Checking Extruder Pick...` | +| Bed | `Bed detecting...`, `Heated bed Calibrating` | +| Nozzle cleaning | `Cleaning Nozzle 1 (1/4)` … `Cleaning Nozzle 4 (4/4)`, `Wiping Nozzle...`, `Cooling Nozzle...` | +| Toolhead calibration | `Calibrating Toolhead 1...` … `4...`, `Extruder Docking Calibrating...` | +| Flow calibration | `Toolhead 1 Calibrating extrusion flow` … `4` | +| Print | `Paused`, `Resuming printing...`, `Resuming print...`, `Print Preprocessing` | + +The consistent `(n/4)` and per-toolhead phrasing is another confirmation of the +four-head architecture. + +## Confirmations and guards + +| Key | Text | +|---|---| +| `cancel_print_confirm_message` | "Cancel printing Do you want to cancel the printing task ?" | +| `close_led_confirm_message` | "AI monitoring is enabled. Turning off the LED may reduce detection accuracy…" | +| `nozzle_mismatch_tip` | "The nozzle diameter does not match the preset…" | +| `direct_print_tips` | "'Print Now' is only available in Cloud Mode" | +| `camera_auto_shutdown_message` | Camera powers down after inactivity; `camera_turn_off_countdown` | +| `network_unstable_partial_operation_failed` | "Network unstable, partial operation may failed" | + +`close_led_confirm_message` is a genuine coupling: the vision-based defect detection +(subsystem `0532`) depends on the chamber LED, so turning the light off degrades it. + +## Discovery + +| Key | Text | +|---|---| +| `discovery_tip1` | "1. Only printers within the same LAN can be found" | +| `discovery_tip2` | "2. The device only supports a 2.4GHz Wi-Fi connection" | +| `input_ip_address`, `Manual connect`, `Nearby machine` | Manual-IP fallback | + +## Filament catalogue + +`filament_color` maps 8-digit `#RRGGBBAA` hex to display names, and +`filament_official_color` maps numeric SKU ids (`34056`…) to official colour names. +`assets/packages/lava_device_control/assets/files/filament.json` carries the catalogue +itself, and the bundle models entries as `VenderMaterialModel{material, serie}` and +`FilamentColorModel{nameZh, rgba}`. diff --git a/docs/u1-webui/02-device-page/04-implementation-report.md b/docs/u1-webui/02-device-page/04-implementation-report.md new file mode 100644 index 00000000000..c27cc77edd1 --- /dev/null +++ b/docs/u1-webui/02-device-page/04-implementation-report.md @@ -0,0 +1,175 @@ +# Report — reverse engineering and reimplementation + +> **The screenshots on this page are stale (flagged 2026-08-24).** Every image under +> `screenshots/01-` … `08-` shows an early dark-theme iteration of the reconstruction, +> before the panel layout was matched to the shipped page. They do not represent what the +> Device tab looks like now, and should not be used to judge parity — use +> [`05-visual-reference.md`](05-visual-reference.md) and the `original-*` / +> `reconstruction-*` captures for that. They are kept only because the prose below refers +> to them; regenerating needs a working browser, which the current environment does not +> have (chromium will not start without libnspr4/libnss3). + + +## What was done + +The Snapmaker U1 Device tab in Orca is a compiled Flutter web app +(`resources/web/flutter_web/`, 5.2 MB of dart2js output, no source maps). It was +reverse engineered end to end, and then **reimplemented from the documentation alone** +as a plain HTML/JS page, to prove the documentation is correct and complete. + +| | | +|---|---| +| Bundle analysed | orca **2.3.26**, build 20260813142841 | +| Bridge commands recovered | **111** live (117 called by the page, 127 dispatched by Orca) | +| JSON-RPC methods recovered | **35**, with literal parameter names | +| State model | **24** objects with per-object field filters | +| Dart classes recovered | **163** original class names, ~105 domain classes | +| Error codes catalogued | **442**, with a decoded 4-group code scheme | +| Reimplementation | ~900 lines of JS, no dependencies, no build step | +| Tests | 12 conformance checks + 28 browser assertions, all passing | + +## How the bundle gave itself up + +dart2js minifies identifiers but not string literals. Three consequences made this +tractable: + +1. **Method names and wire keys survive**, so the JSON-RPC envelopes the page builds are + readable verbatim. +2. **`toString()` bodies survive.** Dart's conventional `toString` embeds the class name + and field names as a literal, which recovered the entire domain model — + `WcpPacket`, `RequestPayload`, `Extruder`, `PrintTaskConfig`, `DeviceCertConfig` and + 160 more — despite minification. +3. **Constant lists survive**, so the subscription list and field filters came out intact. + +Two further affordances in the shipping build: the renderer is `html`, not CanvasKit, +so the page is real DOM; and `PrinterWebView::update_mode()` enables DevTools +unconditionally (the `developer_mode` check is commented out), so the live page can be +inspected in a release build. + +Everything was cross-checked against Orca's own C++ — `SSWCP.cpp` (7,685 lines) and +`MoonRaker.cpp` (3,006 lines) — rather than trusted from the bundle alone. + +## Headline findings + +- **The U1 runs Klipper**, behind a Moonraker-derived JSON-RPC 2.0 API with Snapmaker + extensions (`printer.control.*`, `server.client_manager.*`, `camera.*`, `custom.*`). +- **Transport is MQTT over mutual TLS**, not HTTP, with topics namespaced by serial + number: `/request`, `/response`, `/status`, `/notification`. +- **Pairing** is a plain-MQTT `server.request_key` handshake against `/config/request` + that returns the mTLS material; the cloud path gets the same material from + `/user/device/getMqttCert`. +- **The Flutter page owns the protocol.** Orca's C++ is transport and policy. That is + why a replacement client is straightforward. +- Left in the shipping bundle: a developer route menu, test-only routes, and an internal + Snapmaker host `http://172.17.100.32:8100/api`. + +## The reimplementation + +`resources/web/device_page/` implements the live machine-control surface: four +toolheads, heated bed, fans, chamber LED, air purifier, print job control, print speed, +fault decoding, and a live protocol trace. + +It auto-detects its host — real Orca bridge when `window.wx` exists, a simulated U1 +otherwise — so it runs with no hardware. + +### Running Device page, mid-print + +Four toolheads with their filament assignments, the active head highlighted, live +temperatures, and the SSWCP trace at the bottom. + +![Device page, printing](screenshots/01-device-page-printing.png) + +### Toolhead grid + +`extruder`, `extruder1`, `extruder2`, `extruder3` — Klipper names the first head +`extruder`, not `extruder0`. Filament type and colour come from `print_task_config`. + +![Toolheads](screenshots/02-toolheads.png) + +### Bed, cooling, light and purifier + +Slider ranges are the shipped limits, not guesses: bed 0–100 °C and print speed +50–150 % come from `dialog_device_control_modify_*_tips` in the bundle's i18n table. +The purifier's two modes are `Recirculation Mode` / `Exhaust Mode`. + +![Bed and chamber](screenshots/03-bed-and-chamber.png) + +### Control round-trip + +Bed target driven to 85 °C via `sw_ControlBedTemp{temp:85}`; the change comes back on +the next `heater_bed` state push and the UI follows. + +![Control round-trip](screenshots/07-control-roundtrip.png) + +### Pause + +`sw_MachinePrintPause` → `print_stats.state` becomes `paused`, and the button becomes +Resume. + +![Paused](screenshots/05-paused.png) + +### Fault decoding + +Codes are 16 hex digits in four groups — `SSSS MMMM UUUU EEEE`. Here +`0002052300020000` decodes to subsystem `0523` (toolhead), unit index 2 → **Toolhead 3**. +The catalogue entry for that code is "Filament runout detected in Toolhead 3". + +![Fault banner](screenshots/06-fault-banner.png) + +### Live protocol trace + +Every packet in and out, so the bridge can be watched while the page runs. + +![SSWCP trace](screenshots/04-sswcp-trace.png) + +## Verification + +Two independent test layers, both passing. + +**1. Conformance** — re-derives the constant tables from `data/` and fails if +`shared/js/protocol.js` has drifted. This is the guard that ties the code to the evidence. + +``` +12/12 checks passed +``` + +It was negative-controlled: injecting a wrong field filter, a bogus command name and a +wrong temperature limit produced three failures and a non-zero exit, so it can go red. + +**2. Browser end-to-end** — drives the real modules against the simulated printer in +headless Chromium. + +![Self test](screenshots/08-selftest.png) + +``` +28/28 checks passed +``` + +Covering: envelope shape (`header.seqid`, `payload.{cmd,params,event_id,metadata}`), +one-shot vs subscription, ack-then-push ordering, partial-state merging (a push carrying +only `temperature` must not erase `target`), control round-trips for bed, nozzle, speed, +LED and fan, pause/resume reflected in `print_stats.state`, error decoding, and +`SswcpError` on an unknown command. + +## Reproducing + +```bash +python3 docs/u1-webui/tools/run_all.py # regenerate all data/ +python3 resources/web/shared/tests/conformance_test.py # constants vs evidence +python3 resources/web/device_page/tests/run_selftest.py # browser e2e (playwright) +python3 resources/web/device_page/tests/capture_screenshots.py docs/u1-webui/02-device-page/screenshots +``` + +## Honest limits + +- The reimplementation covers **live machine control only**. Discovery, pairing, cloud + login, file browser, camera, timelapse, firmware update and filament-mapping dialogs + are documented but not built. +- Everything was verified against a **simulator built from the same documentation**, so + the tests prove internal consistency and that the client logic works — they do not + prove the printer agrees. Validating against real U1 hardware is the outstanding step. +- Printer-side semantics are inferred from the client contract and Orca's C++, not from + firmware. The error-class field (`0001`/`0002`/`0003`) is observed but its exact + severity meaning is unconfirmed. +- Parameters marked *dynamic* in the RPC reference are assembled at runtime, so their key + lists are not literals in the bundle and are not fully recovered. diff --git a/docs/u1-webui/02-device-page/05-visual-reference.md b/docs/u1-webui/02-device-page/05-visual-reference.md new file mode 100644 index 00000000000..5a28633d6c3 --- /dev/null +++ b/docs/u1-webui/02-device-page/05-visual-reference.md @@ -0,0 +1,212 @@ +# Visual reference — measuring the shipped Device page + +The first reconstruction of this surface was functionally faithful and visually +unrelated: a dark dashboard where the real page is a light, three-panel layout. This +page records how the real one was measured, so a rebuild matches it rather than +approximating it. + +## Why measurement was possible + +The bundle's renderer is `html`, not CanvasKit — so the page is real DOM, and its +computed styles can be read out of a running instance. Two caveats: + +1. Everything lives inside a **shadow root** on ``. A DOM walk that + only follows `element.children` finds two nodes; it has to descend into + `element.shadowRoot` as well. +2. Flutter flattens some subtrees into **raster layers** — the control panel's interior + comes through as a single 803×509 ``. Those regions were measured from pixels + instead. + +The probe is `tools/harness/` plus a DOM walk; see the harness README. + +## Measured frame + +Captured at 1280×900, with the simulated host attached. + +| Element | Position | Size | Fill | +|---|---|---|---| +| Left rail | `0, 0` | `262 × full` | `#FFFFFF` | +| Rail divider | `x 262` | `1px` | `#D9D9D9` | +| Device selector text | `69, 39` | 16px | `#333333` | +| Nav row (selected) | `0, 114` | `262 × 48` | `rgba(12, 99, 226, .10)` | +| Nav label | `76, 129` | 14px | `#242424` | +| Panel column | `x 356` | `830` wide | — | +| Panel header | `356, 20` | `830 × 40` | `#E8E8E8`, radius `8px 8px 0 0` | +| Header title | `384, 31` | 14px | `#333333` | +| Header separator `\|` | `455, 31` | 14px | `#666666` | +| Selected tab pill | `473, 24` | `52 × 36` | `#FFFFFF`, radius `6px` | +| Panel body | `356, 60` | `830 × 548` | `#FFFFFF` | +| Control header | `356, 628` | `830 × 40` | `#E8E8E8` | +| Control refresh pill | `469, 628` | `56 × 40` | `#FFFFFF`, radius `6px` | +| Print Preferences pill | `1051, 636` | `120 × 24` | `#F5F6FA`, radius `4px` | +| Page ground | — | — | `#F5F6FA` | + +The 830px column sits centred in the space right of the rail: `1280 − 262 = 1018`, +`(1018 − 830) / 2 = 94` each side. Panels stack with a 20px gap. + +Note the two headers differ: **Camera** has a `|` separator between title and tabs, +**Control** does not — its refresh pill simply starts 36px after the title. + +## Four panels, not one dashboard + +Top to bottom: **Camera**, **Control**, **Printing Task**, **Filament**. Each is a 40px +header bar over a **548px** white body, which is what puts the four headers at +`y = 20 / 628 / 1236 / 1844`. Measuring at a viewport shorter than ~2400px hides the +Filament panel entirely — the first pass at this page missed it for exactly that reason. + +The Camera body shows the bundle's own `deviceNotConnected.webp` (803×509, drawn at +panel width) when there is no stream. + +The Control body holds, left to right: + +- a bordered status card, ~160px wide: four toolhead temperature rows, bed, an LED + toggle, a fan row and a purifier row, each ~56px tall +- a `Tool1…Tool4` segmented selector (233px) and a `10mm / 1mm / 0.1mm` selector + (205px), with an 83px home button at the right +- a jog cluster: an extruder column, the XY rosette with its centre hub, and a Z column +- a wide extrude bar beneath + +**The shipped page renders this whole surface and fades it while the machine is +unreachable** — it does not hide it. The reconstruction matches that: `.control-grid` +carries `data-enabled="0"` and drops to 18% opacity. + +## The Filament panel + +![Original filament panel](screenshots/original-filament.png) + +*The shipped panel, with all four slots empty.* + +Header: title, a 37px gap, the refresh pill, and a `?` help glyph right-aligned at +`1151, 1854` (20×20). Body holds four slot cards: + +| | | +|---|---| +| Card | `64 × 140`, the bundle's own `extruderBackground.svg` | +| First card at | `x 478, y 2085` | +| Pitch | `174px` (so a 110px gap between cards) | +| Slot circle | `+15, +29`, `36 × 36` | +| Filament bar | `+3, +71`, `58 × 19`, `#6E6E6E` | +| Edit pencil | centred, `+100`, `20 × 20` | + +An **empty** slot draws the circle as a checkerboard and the bar as `/`. A loaded slot +fills the circle with the filament colour and puts the type in the bar — both read from +`print_task_config`, the object the print-processing popup writes. See +[what the two surfaces share](../00-shared/01-shared-models.md). + +![Rebuilt filament panel](screenshots/reconstruction-filament.png) + +*The reconstruction, against the simulated host — which is why its slots are loaded.* + +## Which printer the page shows + +Two bridge commands answer **different questions**, and using only the first is why a +configured printer can fail to appear at all: + +| Command | Returns | +|---|---| +| `sw_GetConnectedMachine` | the `DeviceInfo` of a device whose `connected` flag is **true** — an empty object otherwise | +| `sw_GetLocalDevices` | a **bare array** of every `DeviceInfo` Orca has saved, connected or not | + +```cpp +// SSWCP.cpp — only a connected device is ever returned +for (const auto& device : devices) { if (device.connected) { m_res_data = device; break; } } + +// SSWCP.cpp — the list, unfiltered +auto devices = wxGetApp().app_config->get_devices(); +m_res_data = devices; +``` + +A printer that is saved but idle — the normal state when Orca has just started — is +therefore invisible to `sw_GetConnectedMachine`. The page must ask for the list as +well, and fall back to it so the rail still names the machine. + +`sw_SubscribeLocalDevices` pushes the same bare array +(`GUI_App::device_card_notify(app_config->get_devices())`). + +### Field names are the C++ struct's + +`DeviceInfo` is serialised by `NLOHMANN_DEFINE_TYPE_INTRUSIVE`, so the wire keys are the +member names verbatim — **snake_case**, not the camelCase a JS client would guess: + +``` +ip dev_id dev_name model_name preset_name connected img nozzle_sizes +sn protocol api_key user password ca cert key clientId port +link_mode userid id +``` + +`shared/js/protocol.js` exports these as `DEVICE.*`, plus `asDeviceList()` (which +accepts the bare array and tolerates a `{devices: […]}` wrapper) and `deviceLabel()`. +Three conformance checks parse the field list out of `AppConfig.hpp` and fail if a +constant drifts from it. + +## Interaction + +Every control is wired; nothing is decorative. + +| Control | Behaviour | +|---|---| +| Device selector | opens an anchored menu listing every saved printer, each marked connected or not, plus `Add Device` | +| Camera tabs | switch live / time-lapse | +| Control + Filament refresh | re-read `sw_GetConnectedMachine` and `sw_GetMachineState` | +| Print Preferences | modal with the three `print_task_config` toggles | +| Printing Task tabs | switch job info / files | +| Temperature rows | modal editor, clamped to the shipped validation limits | +| Cooling row | modal for main and assist fan | +| Purifier row | modal with Recirculation / Exhaust | +| LED | toggles, and follows the state push back from the machine | +| Tool / step selectors | select, and the jog controls use the selection | +| Jog + home | send G-code | +| Filament slot | modal to edit type and colour, written to `print_task_config` | + +![Device menu](screenshots/interaction-menu.png) + +Menus and modals are in `js/overlay.js`; the earlier `window.prompt()` placeholders are +gone. + +Motion is worth a note: **no bridge command covers jogging**, so the controls send +G-code through `sw_SendGCodes` — `G28` to home, and `G91` / `G0` / `G90` for one +relative step. The extruder column prefixes the selected tool (`T2\nG91\nG0 E0.1…`). +That the shipped page also drives motion this way is inferred from the command set, not +observed. + +## Assets + +The icons are the bundle's own, copied into `resources/web/device_page/icons/` so the +surface is self-contained rather than reaching into the Flutter bundle's data +directory: + +`deviceControl`, `keyboardArrowDropDown`, `videoCall`, `videoImgPlay`, +`deviceActionHome`, `iconExtruder1…4`, `iconHotBedTemperature`, `iconLed`, `iconFan`, +`iconSpeed`, `exclamationMark`, `iconModelFileFolder`, `printPreferenceArrow`, +`deviceNotConnected.webp`. + +One icon is **not** in the bundle: the Control header's refresh glyph is Flutter's +Material `Icons.refresh`, drawn as a path. It is recreated as `icons/refresh.svg` at +the same 24×24 box. + +## Result + +Re-measuring the reconstruction the same way gives the same numbers: + +| | Original | Rebuilt | +|---|---|---| +| rail | `262 × #FFFFFF` | `262 × #FFFFFF` | +| nav row | `y114, 262×48, rgba(12,99,226,.1)` | `y114, 261×48`, same fill | +| camera header | `356,20 830×40 #E8E8E8` | identical | +| camera body | `356,60 830×548 #FFFFFF` | identical | +| control header | `356,628 830×40` | identical | +| panel headers | `y 20 / 628 / 1236 / 1844` | `20 / 628 / 1236 / 1844` | +| filament card | `64×140`, pitch `174` | identical | +| Print Preferences pill | `120×24 #F5F6FA` | `128×24`, same fill | + +![Original](screenshots/original-full.png) + +*The shipped Flutter Device page, disconnected, all four panels.* + +![Rebuilt](screenshots/reconstruction-full.png) + +*The reconstruction at the same viewport, against the simulated host — which is why it +shows live temperatures and loaded filament where the original shows placeholders.* + +The build badge in the corner is the one intentional addition; see +[the build badge](../00-shared/02-build-badge.md). diff --git a/docs/u1-webui/02-device-page/06-connection.md b/docs/u1-webui/02-device-page/06-connection.md new file mode 100644 index 00000000000..85492b9f322 --- /dev/null +++ b/docs/u1-webui/02-device-page/06-connection.md @@ -0,0 +1,311 @@ +# Bringing a session up + +How the Device page connects to a U1. Every step is a bridge command; the page owns the +sequence and Orca owns the socket. Recovered from `SSWCP.cpp` and `MoonRaker.cpp`. + +## Why `sw_Connect` is not the answer + +The obvious-looking command is a stub: + +```cpp +void SSWCP_MachineConnect_Instance::sw_connect() { +} +``` + +The real work is spread across the **MQTT agent** group — `sw_create_mqtt_client`, +`sw_mqtt_connect`, `sw_mqtt_set_engine` — with `sw_GetPincode` in front of it the first +time a machine is paired. + +## Two paths + +Which one applies depends on whether the saved `DeviceInfo` already carries mTLS +material (`ca`, `cert`, `key`). A machine that has never been paired has all three empty, +even though `ip`, `port` and `clientId` are already populated. + +``` + already paired first-time LAN pairing + ────────────── ────────────────────── + sw_GetPincode { ip, userid, + nickname, port:1884 } + → the printer displays a code + → the user types it in + + sw_create_mqtt_client sw_create_mqtt_client + server_address: server_address: + mqtts://: mqtt://:1884 + + ca, cert, key clientId + → { type, id } → { type, id } + + sw_mqtt_connect { id } sw_mqtt_connect { id } + + sw_mqtt_set_engine sw_mqtt_set_engine + { engine_id, ip, port, sn, { engine_id, ip, port:1884, sn, + ca, cert, key, user, password } code: } +``` + +## What each command requires + +Every handler rejects a missing or mistyped param explicitly, so the contract is exact. + +| Command | Required | Optional | +|---|---|---| +| `sw_GetPincode` | `ip`, `userid`, `nickname` | `port` (default **1884**) | +| `sw_create_mqtt_client` | `server_address`, `clientId` | `ca`, `cert`, `key`, `username`, `password`, `clean_session` | +| `sw_mqtt_connect` | `id` | — | +| `sw_mqtt_subscribe` | `id`, `topic`, `qos`, **and an `event_id`** | — | +| `sw_mqtt_publish` | `id`, `topic`, `qos`, `payload` | — | +| `sw_mqtt_set_engine` | `engine_id`, `ip`, `port`, `sn` | `code`, `ca`, `cert`, `key`, `user`, `password`, `need_reload` | + +`sw_create_mqtt_client` picks the scheme itself: all three of `ca`/`cert`/`key` present +gives an `mqtts` client, otherwise plain `mqtt`. It returns +`{ type, id }`, where `id` is the client pointer rendered as a decimal string — every +later command addresses the engine by it. + +## What `sw_mqtt_set_engine` actually does + +It is the command that finishes a connection, and it does considerably more than its +name suggests: + +1. builds a `Moonraker_Mqtt` print host against `ip:port` and registers it as Orca's + current connect host; +2. attaches the engine created above (`host->set_engine`); +3. when a `code` is supplied, runs `Moonraker_Mqtt::ask_for_tls_info()`, which + subscribes `/config/response`, publishes `server.request_key` with the client + id, and waits up to 60s for + `{ state, sn, clientid, ca, cert, key, port }`; +4. stores the returned material on the device record and sets `connected = true`; +5. pushes the updated device list to every `sw_SubscribeLocalDevices` subscriber. + +Step 5 is how the UI learns it worked — there is nothing to poll. + +### Pass `need_reload: false` + +`need_reload` defaults to **true**, and on that path the host reloads the Device webview: + +```cpp +if (reload_device_view) { + wxString url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::DeviceTab); + wxGetApp().mainframe->load_printer_url(get_international_url(url)); +} +``` + +A page that calls `sw_mqtt_set_engine` on itself and leaves the default gets torn down +halfway through its own connect sequence. The reconstruction passes `false`. + +## `DeviceInfo.connected` is not a reachability signal + +Worth stating plainly, because it is a trap: + +```cpp +// AppConfig.cpp:887 - on every config save +for (size_t i = 0; i < j["devices"].size(); ++i) + j["devices"][i]["connected"] = false; +``` + +The flag is **forced to false for every device whenever the config is written**. It is a +runtime-only value that never survives a save, so on disk it is false by construction and +says nothing about whether a printer is reachable. + +A page that renders its connection indicator from that flag will show a perfectly healthy +printer as disconnected. The authoritative signal is **live machine state**: if subscribed +objects are arriving, there is a session. The reconstruction uses that, and treats the +flag only as a secondary hint. + +## The page connects itself on load + +The shipped page does not wait to be told. The first harness capture caught it emitting +`sw_create_mqtt_client` unprompted, straight after reading the device list — which is why +a printer that was never manually connected still appeared live on the original page. + +The reconstruction matches this, with one deliberate limit: it auto-connects only where +that can succeed without a prompt — a machine that already holds keys, or a signed-in +account that can be issued them from the cloud. LAN pairing needs a human to read a code +off the printer, so it stays a deliberate action. + +## Cloud credentials + +A signed-in account can be issued mTLS material without touching the printer's screen: + +``` +GET /user/device/getMqttCert?sn= +Authorization: Bearer +``` + +The reply carries the same `{ca, cert, key, port, clientId}` the LAN handshake produces, +after which the transport is identical. `connection.js` tries this first when the saved +device has no keys and a token is available, and falls back to LAN pairing when it fails. + +This matters because Orca never persists cloud-issued keys onto the device record — so a +machine that connects perfectly well through the account still looks unpaired on disk. + +## The key exchange the page must do itself + +Verified against real hardware (U1 firmware reporting through Orca 2.3.6). Two things +that reading the source alone got wrong: + +**1. `sw_mqtt_set_engine` does not connect anything.** It looks like it would — +`Moonraker_Mqtt::connect()` calls `ask_for_tls_info()`, which performs the whole +handshake — but that path is dead: + +```cpp +// SSWCP.cpp:6643, inside sw_mqtt_set_engine's worker +bool res = true; // connect() is never called +std::string ip_port = host->get_host(); +if (res) { … } +``` + +The host expects to be handed an **already-connected mTLS engine**. Passing it a plain +:1884 engine and a `code` returns `ok` and leaves you with a session that answers +nothing — every subsequent `printer.objects.query` times out. + +**2. The auth method is `server.client_manager.request_lan_auth`, not +`server.request_key`.** The C++ sends `server.request_key`; this firmware answers it +with: + +```json +{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},…} +``` + +The method that works takes `{clientid, app_id}`, where `app_id` is a freshly generated +`orca-` and `clientid` is the saved one: + +```json +→ 12345678/config/request + {"jsonrpc":"2.0","method":"server.client_manager.request_lan_auth", + "params":{"clientid":"orca-9bbcbf74-…","app_id":"orca-1787558…"}, + "id":n,"cli_time":1787558371,"dev_time":-1} + +← 12345678/config/response + {"jsonrpc":"2.0","result":{"state":"success","clientid":"orca-9bbcbf74-…", + "sn":"8110025…","ca":"-----BEGIN CERTIFICATE-----…","cert":…,"key":…}} +``` + +**`cli_time` and `dev_time` are required.** `TimeSyncManager::addTimeFields()` stamps +every request the C++ sends — `cli_time` in unix seconds, `dev_time` `-1` until the +clocks have synced. Without them the printer ignores the request entirely: no reply, no +error. That silence cost an entire debugging round. + +The reconstruction sends `request_lan_auth` first and falls back to the other two, so a +different firmware still has a path. The printer is the oracle: an unsupported method +comes back as `-32601`, which makes probing cheap and safe. + +## The LAN auth code is fixed — `12345678` + +This is the single most important thing on this page, and it is the difference +between a reconstruction that reconnects on its own and one that cannot. + +`12345678` is **not a user-entered PIN**. It is a literal in the shipped bundle — +18 occurrences, appearing in a device-model constructor next to port `1884` — and it +is what the real Device page uses as the auth code for a LAN machine. + +A connect captured from a working Orca session proves it. The page subscribes: + +``` +811002511261022618B3/status /status +12345678/config/response /config/response +811002511261022618B3/notification /notification +12345678/config/notification /config/notification +``` + +Those `12345678/config/*` topics are exactly what +`Moonraker_Mqtt::ask_for_tls_info()` derives from ` + "/config/response"`. +No PIN prompt is ever shown, and no `getMqttCert` call is made. + +So: **a machine that already trusts our client id authorises itself with the fixed +code.** A PIN is only needed when it does not. The reconstruction tries the fixed code +first and falls back to prompting. + +## The observed connect, in two phases + +Confirmed end to end against real hardware: keys obtained, engine attached, +`snapshot ok: 24 objects`, live subscription up. + + +From the same capture. Note the throwaway client for phase 1. + +``` +phase 1 — plain MQTT, authorisation + sw_create_mqtt_client mqtt://:1884 + clientId orca-try- ← fresh, throwaway + ca/cert/key empty + keepAlivePeriod 30, clean_session false, link_mode lan, sn + sw_mqtt_connect + sw_mqtt_subscribe /status + sw_mqtt_subscribe 12345678/config/response + sw_mqtt_subscribe /notification + sw_mqtt_subscribe 12345678/config/notification + → server.client_manager.confirm_lan_status { clientid: } + ⇒ { state: authorized, action: approve } + +phase 2 — mTLS, the real session + sw_create_mqtt_client mqtts://:8883 + clientId ← the persistent one + ca/cert/key present + sw_mqtt_connect (isMqtts: true) + sw_mqtt_subscribe /status, /response, /notification + sw_mqtt_set_engine + sw_SubscribeMachineState, sw_GetMachineState +``` + +The page also sends the client id under **both** spellings, `clientId` and `clientid`. + +## Why the keys are never on disk + +`SSWCP.cpp` stores the device after a connect — with the certificate fields +deliberately blanked: + +```cpp +info.ca = /* auth_info["ca"] */ ""; +info.cert = /* auth_info["cert"] */ ""; +info.key = /* auth_info["key"] */ ""; +``` + +Combined with `AppConfig.cpp:887` clearing `connected` on every save, a saved device +always looks unpaired and disconnected at startup. Both flags are runtime-only. A client +that treats either as ground truth will refuse to connect to a perfectly healthy printer. + +## Pairing in detail + +`sw_GetPincode` connects a throwaway plain-MQTT client to `mqtt://:1884` as +`"Snapmaker Orca"`, subscribes `cloud/config/response`, and publishes to +`cloud/config/request`: + +```json +{"jsonrpc":"2.0","method":"server.client_manager.request_pin_code", + "params":{"userid":"…","nickname":"…"},"id":} +``` + +The printer shows the code on its screen; the reply's `result` comes back to the page. +The code is then handed to `sw_mqtt_set_engine`, which exchanges it for the keys. + +All three params are required to be *present*, but empty strings are accepted — pairing +works with nobody signed in. + +## The implementation + +`resources/web/shared/js/connection.js`. `connect(bridge, device, opts)` picks the +path, and `opts.requestPin` is an async callback so the UI decides how to collect the +code (the reconstruction opens a modal). + +Errors are wrapped in a `ConnectError` naming the step that failed, because "connect +failed" on its own is useless — `set engine: … incorrect pairing code` is not. + +In the UI: the device menu offers **Connect** or **Pair and connect…** for a machine +that is down, and **Disconnect** only for a session the page itself opened — the engine +is addressed by the id `sw_create_mqtt_client` returned, and a session Orca brought up +on its own has no id the page can name. + +## Verification, and its limit + +Both paths are exercised against the simulated host, which implements this contract: +pairing (including a wrong code), reconnect-without-PIN over `mqtts://`, disconnect, and +the resulting device-list push that re-enables the control surface. + +Nine conformance checks pin the sequence, three of them by parsing the required-param +guards straight out of `SSWCP.cpp` so a param drifting from the handler fails the build. + +**What this does not prove:** the simulator was written from the same reading of the C++ +that the client was. The command names, parameter sets and ordering are read from the +source and are solid; the actual MQTT handshake with a real U1 — TLS negotiation, the +`server.request_key` round trip, the printer's PIN UI — is untested. That is the +outstanding step, and it needs hardware. diff --git a/docs/u1-webui/02-device-page/07-parity.md b/docs/u1-webui/02-device-page/07-parity.md new file mode 100644 index 00000000000..b393d8917f5 --- /dev/null +++ b/docs/u1-webui/02-device-page/07-parity.md @@ -0,0 +1,146 @@ +# Parity with the shipped Device tab + +> **Superseded, and worth reading anyway.** This document reported the command surface +> complete on the strength of `CMD.NAME` appearing somewhere in the source. That proves a +> command is *mentioned* — not that a control exists to issue it, and a panel with no +> button counted every command it would have called. Seven user-visible faults were found +> on hardware against this "complete" page. +> +> `check_coverage.py` asks the reachability question too now, by reading the command +> module each panel is actually handed. See +> [09-restructure.md](09-restructure.md) and +> [08-function-gap-analysis.md](08-function-gap-analysis.md). + + +What the reconstruction covers, what it does not, and how that was measured rather +than estimated. + +## Scoping the question + +The bundle references **117** `sw_*` commands, but those span every surface it serves — +the home page, projects, login, the print popup. Counting the reconstruction against all +117 measures the wrong thing. + +The Device tab's own surface is its four panels plus the rail. Scoped that way: + +| Area | Commands | Wired | +|---|---|---| +| Rail — device list, pairing, connect | 11 | **11** | +| Camera panel | 3 | **3** | +| Control panel | 12 | **12** | +| Printing Task panel | 8 | **8** | +| Filament panel | 1 | **1** | +| | **35** | **35** | + +## Feature by feature + +| Feature | Original | Reconstruction | +|---|---|---| +| Four panels, responsive 2×2 grid | ✓ | ✓ — breakpoint and widths measured | +| Device selector, saved-printer list | ✓ | ✓ | +| Pair a new printer (PIN) | ✓ | ✓ | +| Connect / disconnect | ✓ | ✓ | +| Rename / forget a printer | ✓ | ✓ | +| Live camera view | ✓ | ✓ — frame fetched over HTTP, measured | +| Time-lapse list | ✓ | ✓ — listing only, no playback | +| Toolhead + bed temperatures | ✓ | ✓ | +| Chamber LED, fans, purifier | ✓ | ✓ | +| Tool / step selectors, jog, home | ✓ | ✓ — via G-code | +| Print Preferences | ✓ | ✓ | +| Job progress | ✓ | ✓ | +| Pause / resume / cancel | ✓ | ✓ | +| Machine file browser | ✓ | ✓ — list, print, delete | +| Start a print from a machine file | ✓ | ✓ | +| Filament slots, edit type/colour | ✓ | ✓ | +| Print speed override | ✓ | ✓ | +| Firmware / system info panel | ✓ | ✓ — read-only; no update flow | +| Bed-mesh abort | ✓ | ✓ — abort only, no calibration wizard | +| Defect-detection configuration | ✓ | ✓ | +| Time-lapse delete | ✓ | ✓ — delete only; playback is Orca's own window | +| File thumbnails, metadata, download | ✓ | ✓ | +| Machine storage usage | ✓ | ✓ | +| Fault banner, decoded against the 442-code catalogue | ✓ | ✓ | + +Also added: network discovery, add-device, connect-another-machine, a liveness +heartbeat, Klipper object discovery, and file transfer status. + +## What is left + +**Nothing on the command surface.** Every command the host dispatches and the bundle +references is either implemented or excluded with a written reason, and the coverage +check fails the build otherwise. + +Three honest qualifications remain: + +1. **Some flows delegate to Orca.** `sw_AddDevice` and `sw_ConnectOtherMachine` open + Orca's own native dialogs — that is what the commands *do*, so calling them is the + complete implementation. Time-lapse playback is likewise Orca's own window. +2. **Depth, not breadth.** A firmware *update* flow and a calibration *wizard* are not + built; the reconstruction reads system info and can abort a bed mesh, which is the + whole of what the bridge exposes for them. +3. **Response shapes that needed hardware.** Camera frames, file thumbnails and + discovery results are pass-throughs from the printer, and their field names are + literals in neither the bundle nor the C++. Two of the three are now **measured** + against a real U1 and the client rebuilt around what it found — the camera does not + push frames at all, and the thumbnail command the page asked first returns paths + rather than image bytes. See + [05-printer-protocol/06-mqtt-topics.md](../05-printer-protocol/06-mqtt-topics.md), + and `docs/u1-webui/data/hardware-shapes.json` for the capture the conformance suite + now pins those constants to. Discovery is still open: `sw_StartMachineFind` is Orca's + own Bonjour sweep rather than a pass-through, so it needs the running app. + +## How coverage is enforced, not judged + +The gaps above were originally found by a person noticing them. That does not scale and +should not be trusted, so it is now inverted: + +```bash +python3 docs/u1-webui/tools/check_coverage.py +``` + +It enumerates every command the host dispatches **and** the shipped bundle references +(111 of them), subtracts what the reconstruction actually issues — measured by `CMD.*` +references from code that runs in the page, not by string literals, which would count the +constant table and the simulated host — and requires that **everything left over is +listed with a written reason**. + +| | | +|---|---| +| Command surface | 111 | +| Implemented | 35 | +| Excluded, each with a reason | 76 | +| **Unclassified** | **0** | + +Anything neither implemented nor excluded is reported as `UNCLASSIFIED` and fails the +build. A future bundle that adds a command surfaces here rather than in a bug report. The +check also flags *stale* exclusions — something listed as missing that has since been +implemented — which caught four wrong entries the first time it ran. + +It runs as part of `run_all.py` and as a conformance check, and is negative-controlled: +removing a command from the client turns the suite red. + +## How this is kept honest + +- **31 conformance checks** re-derive the constant tables, the device record's field + names, the success code, the control limits and the connection sequence from + `docs/u1-webui/data/` and from `SSWCP.cpp` / `AppConfig.hpp` directly. Each has been + negative-controlled. +- **28 browser checks** drive the real modules against the simulated printer. +- The parity table above is regenerated by scoping the command list, not by hand. + +One caveat that applies to most of it: the simulator implements the contract as read from +the C++. That proves client and contract agree — it does not prove a real U1 agrees. The +camera and thumbnail findings are exactly where that gap turned out to be real, and both +are now closed by measurement rather than by reading; the eleven hardware checks in the +conformance suite hold them there. The WCP trace at the foot of the page remains how the +rest is debugged. + +## A bug this work surfaced + +`map_sswcp.py` built its dispatch map by matching `m_cmd == "sw_*"` literals, but 15 +commands are dispatched through header macros (`GET_DEVICEDATA_STORAGESPACE`) and were +missing from `sswcp-commands.json` — which made `sw_GetDeviceDataStorageSpace` look +unimplemented when it has a handler at `SSWCP.cpp:3767`. The extractor now resolves the +macros, and the dispatch count went from 111 to **123**. + +The conformance suite caught this, not a human reading. diff --git a/docs/u1-webui/02-device-page/08-function-gap-analysis.md b/docs/u1-webui/02-device-page/08-function-gap-analysis.md new file mode 100644 index 00000000000..ed5c7f6dc22 --- /dev/null +++ b/docs/u1-webui/02-device-page/08-function-gap-analysis.md @@ -0,0 +1,2038 @@ +# Function-for-function gap analysis + +Seven things a user found broken on real hardware, against a page whose parity table said +"nothing on the command surface" was missing. This is what was actually wrong, why the +checks did not catch any of it, and what the fix is. + +Measured against `811002511261022618B3` on 2026-08-24 with +[`tools/u1_probe.py`](../tools/u1_probe.py) — every "real" value below is a capture, not a +reading of the C++. + +## Why the coverage check said 100% + +`check_coverage.py` counts a command as implemented when the token `CMD.NAME` appears in +client source. That proves the command is **mentioned**. It does not prove: + +- a control exists that reaches the call site, +- the call site is reachable at all, +- the response is parsed correctly, +- anything is rendered. + +A panel with no button still counts every command it would have called. The parity table +then reported command coverage as though it were functional coverage — that was the +error, and it was mine. The number was true and the claim built on it was not. + +The second reason is sharper. **The simulator was written from the same reading of the C++ +as the client**, so it returns what the client expects rather than what the printer sends. +Every mismatch below passes the browser suite and fails on hardware. The docs named this +risk and then did not test for it. + +## Root cause A — the JSON-RPC envelope is never unwrapped + +> **Corrected 2026-08-25, and this correction matters.** What follows is true only of a +> `passthrough` response target. Every ordinary command takes the other arm of +> `Moonraker_Mqtt::on_response_arrived`, which **reshapes** the reply before Orca ever +> hands it over: +> +> ```cpp +> if (passthrough || id == 20252025) { +> cb(body); // the raw envelope - what this section describes +> } else { +> res["data"] = body["result"]; // unwrapped, one level down +> res["method"] = body.count("method") ? body["method"] : ""; +> cb(res); +> } +> ``` +> +> So the page receives `{data: , method: }`. `unwrapRpc` only stripped +> `jsonrpc` envelopes, so against the real contract it was a **no-op**, and every reader +> that was not going through `unwrapStatus` was silently reading `undefined`: +> +> ```js +> cam.timelapses = (r && (r.instances || r.list || r.items)) || []; // always [] +> ``` +> +> Measured on hardware, before and after teaching `unwrapRpc` the real shape: +> +> ``` +> file panel: "No files on this machine" -> config logs gcodes timelapse camera, print_task.json 4 KB, ... +> history: keys method, data -> keys count, jobs +> ``` +> +> The file browser was **empty against a real printer** and nothing caught it, because +> the simulator agreed with the client. It surfaced only when the shipped bundle was run +> against the same host: the bundle reads `status` off that object directly, logged +> `queryPrinterStatus error` while holding all four extruders, and fell back to the push +> stream - which is why one toolhead showed and three did not. +> +> The mechanism below is real; its scope was wrong. + +This one breaks most of the list, and it is a single line. + +```cpp +// SSWCP.cpp:1194 on_mqtt_msg_arrived +obj->m_res_data = response; // the WHOLE {jsonrpc, result, id} envelope +// SSWCP.cpp:946 send_to_js +payload["data"] = m_res_data; +``` + +`sswcp.js` resolves a request with `payload.data`, so on hardware every printer-backed +command resolves to: + +```json +{"jsonrpc": "2.0", "result": { ...the payload... }, "id": 7, "cli_time": …, "dev_time": …} +``` + +The simulator instead puts the **unwrapped** result there: + +```js +// mockhost.js:44 +function reply(header, code, message, data) { ... payload: { code, message, data } } +``` + +So `r.instances`, `r.files`, `r.thumbnail_path` all resolve in the simulator and are +`undefined` on a real printer. The file browser shows the symptom clearly — the code +already half-guessed at this: + +```js +items = (dir && (dir.files || dir.result)) || []; +files.items = Array.isArray(items) ? items : []; // dir.result is an OBJECT -> [] +``` + +`dir.files` is undefined, `dir.result` is the object, `Array.isArray` rejects it, and the +list renders empty. The guess was made without ever seeing the real shape. + +**Fix:** unwrap once, centrally, in `sswcp.js` — if the payload carries `jsonrpc` and a +`result`, resolve `result`. Commands answered entirely in C++ (`sw_GetLocalDevices` and +friends) have no `jsonrpc` key and pass through untouched. + +## Root cause B — colours are not CSS + +| Source | `filament_color` | `filament_color_rgba` | +|---|---|---| +| **Printer** | `[4294198070, 4294967260, …]` — ARGB **integers** | `["F44336FF", "FFFFDCFF", …]` — hex, **no `#`** | +| Simulator | `['#E03131FF', …]` | `['#E03131FF', …]` | + +`renderFilament` does `dot.style.background = colors[i]`. Both real forms are invalid CSS, +so the assignment is silently dropped and every slot renders grey. `isDark()` has the same +assumption. + +**Fix:** a normaliser that accepts ARGB int, `RRGGBBAA`, `#RRGGBBAA` and `#RRGGBB`. + +## Root cause C — controls missing from specific render branches + +Two panels lose their controls exactly when they are needed. + +**Camera.** The live-view branch returns before the control block is reached: + +```js +if (cam.streaming && cam.frameUrl) { + ... root.appendChild(im); + return; // <- Stop button lives only in the fallback below +} +``` + +So once a frame arrives there is no way to stop the camera. *(pointer 1)* + +**Printing Task.** The idle branch renders an image and nothing else: + +```js +if (!active) { illustration(root); return; } // no text, no buttons +``` + +An idle printer therefore shows a bare illustration with no explanation and no way to +start anything. *(pointer 3)* + +## Root cause D — `toolhead` is never subscribed + +`SUBSCRIBE_OBJECTS` omits the `toolhead` object, which is where the printer keeps the two +things the control panel needs: + +```json +"toolhead": { "extruder": "extruder3", // which tool is ACTIVE + "position": [105.58, 108.25, 10.0, 0.0], + "homed_axes": "" } +``` + +Consequences, both visible: the `Tool1..4` buttons set a local variable and send nothing, +so selection is cosmetic and never reflects the machine; and the axis readouts stay at +their `------` placeholder because no position ever arrives. *(pointer 5)* + +`extruder*.state` (`"PARKED"` / `"ACTIVATE"`) is a second, independent source for the +active tool, and `motion_report.live_position` for live motion. + +## Root cause E — purifier mode is an integer + +Real: `"purifier": {"mode": 0, "exhaust_fan": {"speed":…,"delay":…}, …}`. + +The dialog offers the strings `'inner'` / `'exhaust'` and the status row prints +`purifier.mode ?? '_'`, so it shows a bare `0`. `exhaust_fan` is an **object**, not a +scalar, and `state.js` passes it through as one. + +## Root cause F — print history has no bridge command + +`server.history.list` works on the printer and is rich: + +```json +{"count": 5, "jobs": [{"job_id":"0000F9", "filename":"XYZ Test Cube_PLA_3h58m.gcode", + "status":"klippy_shutdown", "start_time":…, "end_time":…, "print_duration":0.0, + "total_duration":12.74, "filament_used":0.0, "metadata":{…}}]} +``` + +240 jobs are on this machine. But **no `sw_*` command exposes it** — the bridge cannot +reach it, so no page-only change can add this tab. The shipped bundle does reference +`server.history.list`, and its `PrintHistory` widget builds little more than a +`"Print history"` heading, sitting next to `HomeSideMenuWidget` — i.e. it is a *home page* +surface, not a Device-tab panel, and looks unfinished there too. + +**This one needs C++**, and is therefore called out rather than quietly skipped. +*(pointer 4)* + +## Pointer-by-pointer + +| # | Reported | Root cause | Needs | +|---|---|---|---| +| 1 | camera cannot be stopped | C — early `return` before the control block | page | +| 2 | recordings not displayed | A — `r.instances` is `r.result.instances` | page | +| 3 | task panel has no placeholder or buttons | C — idle branch renders image only | page | +| 4 | previous print tasks missing | F — no bridge command exists | **C++** | +| 5 | toolheads not selectable / movable | D — `toolhead` not subscribed; selection is local-only | page | +| 6 | filament colours missing | B — ARGB int / un-prefixed hex are not CSS | page | +| 7 | missing menus and popups | see below | page | + +On (7): the dialogs and menus do exist — `openDialog`/`openMenu` back the temperature, +fan, purifier, filament-slot and device menus. What made them look absent is that the +panels they hang off render empty or disabled on real data, so there is nothing to click. +Fixing A–E restores the affordances rather than adding new ones. + +## The check that should have caught this + +None of the 42 conformance checks compare a **response shape** against hardware, and the +browser suite drives a simulator that agrees with the client by construction. The +durable fix is to make the simulator answer with the printer's shapes — envelope included, +ARGB colours included — so that the existing browser tests start failing when the client +assumes otherwise. A simulator that flatters the client is worse than no simulator, +because it converts an unknown into a false negative. + +## What was rebuilt + +| Root cause | Change | +|---|---| +| A envelope | `unwrapRpc()` in `sswcp.js` strips `{jsonrpc, result, id}` once, centrally. Guarded on `jsonrpc`, so Orca-answered payloads and bare arrays pass through, and an `error` envelope is preserved rather than emptied. | +| B colours | `cssColor()` / `isDarkColor()` in `protocol.js` accept ARGB integers, `RRGGBBAA`, `#RRGGBBAA` and `#RRGGBB`. | +| C camera | The live-view branch renders its own **Stop** and **Refresh**; it no longer returns before the control block. | +| C task | The idle panel names the state, shows the last file, and offers **Browse printer files**. | +| D tools | The active tool is the extruder reporting `state: "ACTIVATE"`; clicking a tool sends `T`. Axis readouts come from `motion_report.live_position`. | +| D homing | `homed_axes` arrives from a one-shot `toolhead` query at connect and after `G28`, and an unhomed axis is marked — Klipper refuses the move, which previously looked like a dead button. | +| E purifier | `mode` is sent and rendered as an integer with a name; `exhaust_fan` / `inner_fan` are read as objects. | +| F history | **Not built** — no bridge command exists. See below. | + +### The subscription list was deliberately not changed + +The first attempt added `toolhead` to `SUBSCRIBE_OBJECTS`, and the conformance suite +rejected it: the shipped page does not subscribe that object, and the list is pinned to +the bundle's 24. That was the right call by the test — everything needed is already in +the subscribed set (`extruder*.state` for the active tool, `motion_report.live_position` +for coordinates). Only `homed_axes` genuinely required `toolhead`, and it is fetched +explicitly rather than by widening the stream. + +### The simulator now tells the truth + +The durable fix, and the reason this class of bug existed. `mockhost.js` now: + +- wraps replies for the **47 printer-backed commands** in their JSON-RPC envelope, exactly + as Orca passes them through — the list is derived from `SSWCP.cpp` by finding every + handler that routes its reply through `on_mqtt_msg_arrived`, and the conformance suite + re-derives it so it cannot drift; +- sends filament colour as an ARGB integer and un-prefixed hex, not as CSS; +- refuses `camera.start_monitor` with `domain: ""` and the printer's own `-32000`. + +A simulator that agrees with the client converts an unknown into a false negative. These +changes make the existing tests fail when the client assumes wrongly. + +### Testing, given no browser + +*(Superseded in round three: WebKitGTK turns out to be drivable, so there is a browser +after all — see "Testing, in a browser at last" at the end of this document. What follows +still holds for the pure-logic checks.)* + +`playwright` is absent and the vendored chromium will not start (no `libnspr4`/`libnss3`), +so the 28 browser checks cannot run here. **JavaScriptCore can** — it is what Orca's own +webview uses, and PyGObject drives it: + +```bash +python3 resources/web/shared/tests/unit_jsc.py +``` + +39 checks: all 14 modules validated as real ES modules via `check_syntax` in module mode, +then the colour normaliser, the envelope unwrapper and the state model executed against +**captured hardware payloads** rather than invented ones. Negative-controlled — reverting +either fix turns seven of them red. + +## Still open + +1. **Print history (pointer 4) needs C++.** `server.history.list` works on the printer and + returns 240 jobs here, but no `sw_*` command exposes it, so the page cannot reach it. + Adding it means a new handler in `SSWCP.cpp` plus a `Moonraker_Mqtt::async_*` — a + deliberate change to Orca, not a page edit, which is why it is called out rather than + quietly skipped. +2. **None of this is confirmed on hardware through Orca's bridge.** The printer side is + measured and the C++ pass-through is read, but the fixed page has not yet been driven + against a real U1 in the app. That is one session with the Device tab open. +3. **The purifier mode names for 1 and 2 are inferred**, not measured — confirming them + means changing the mode on a real machine. + +## Round two — what a second pass on hardware found + +Reported after the first rebuild, with the fixes above in place. + +### Tool selection did not stick, and that was a fresh bug of mine + +The pending-vs-confirmed distinction was missing. The click did: + +```js +activeTool = i; // set +handlers.selectTool(i); +renderControlMain(root, toolheads, handlers); // re-enter... +``` + +and `renderControlMain` opens with `if (head.activeIndex != null) activeTool = head.activeIndex;` +— so the click's own re-render put it straight back, and the next state push a second +later would have anyway. Storing a *request* in the variable that mirrors the *machine* +cannot work. + +`pendingTool` now holds the request until the machine agrees (its extruder reports +`ACTIVATE`) or it ages out after 45 s, which is the timescale a mechanical toolchange +actually takes. The button distinguishes the two: a green dot for the live tool, an +italic ellipsis for one being switched to. + +### Print speed + +Moved to the foot of the left column and made discrete — whole 50% steps across +`LIMITS.printSpeed` (50 / 100 / 150), replacing the continuous slider under the jog pad. + +### Filament dialog + +Rebuilt as "Materials Setting", after Bambu Studio's. It has real data behind it now: +`filament_detect.info` is one entry per slot carrying the spool's RFID tag — +`HOTEND_MIN_TEMP` / `HOTEND_MAX_TEMP` (190–260 and 205–235 on two of this machine's +spools), `BED_TEMP`, drying figures, vendor and sub-type. + +The lower half is deliberately read-only: those numbers come off the spool and from +Klipper's own calibration, so presenting them as editable would be a lie. **Klipper's +`pressure_advance` is this machine's Factor K** — the same role, already in +`EXTRUDER_FIELDS`. + +One thing the capture settled: slot 4 reports `VENDOR: "NONE"` in the tag while +`print_task_config` says PETG. A hand-set filament has no tag, so the two sources are +kept separate and the dialog says which it is rather than showing a grid of zeros. + +`setFilament` now writes colour back in the wire form (`RRGGBBAA` and the ARGB integer), +not CSS — writing `#RRGGBB` would put a value on the machine nothing else on it reads. + +### Print history — built, and it needed the C++ + +`server.history.list` had no bridge command, so this is a real addition to Orca: + +| Layer | | +|---|---| +| `PrintHost.hpp` / `MoonRaker.hpp` | `async_get_print_history` | +| `MoonRaker.cpp` | `server.history.list`, params passed through | +| `SSWCP.hpp` / `SSWCP.cpp` | `sw_GetPrintHistory` — handler, dispatch, registration | + +**It needs a rebuild**, unlike everything else on this page. + +Measured, and it changed the design: the reply's `count` is **the size of the page, not +the total** — asking for 7 returns `count: 7` on a machine with 240 jobs. The only true +total is `server.history.totals` (`job_totals.total_jobs`), which has no bridge command. +So paging stops when a short page comes back rather than counting toward a known end, and +the footer says "N shown" rather than inventing "N of M". + +### A tool that destroyed itself + +`map_sswcp.py` ended with `json.dump(out, open(sys.argv[-1], 'w'))`. Run with no +arguments — the obvious way — `sys.argv[-1]` is the script's own path, so it overwrote +itself with its output. It did that here, and was restored from git. It now requires both +arguments and refuses to write over itself. + +## Picking and parking: the flow, and what was wrong with it + +### What the printer's own UI does + +Recovered from the shipped bundle, which is the authority here — neither command appears +in `printer.gcode.help`, because both register without a help string, as `T0`–`T3` also +do. Reading that absence as evidence is what sent an earlier attempt hunting for a park +macro that does not exist. + +```js +r = a.w === "ACTIVATE" ? "Park Extruder" : "Pick Extruder" // one button, per toolhead +… +A.aDM(h, "Extruder " + (j.a+1) + " operating...", 60) // overlay, 60s +case 5: l = h === 0 ? "PARK_EXTRUDER" : "PARK_EXTRUDER" + h // park +case 7: k = "T" + j.a + " A0" // pick +``` + +Three things fall out of that: + +| | | +|---|---| +| Park | `PARK_EXTRUDER`, `PARK_EXTRUDER1`…`3` — numbered like Klipper's own `extruder`/`extruder1` | +| Pick | `T A0` — the `A0` is not optional decoration; the firmware's own `SM_PRINT_CHECK_SWITCH_EXTRUDER` passes it too | +| Wait | 60 s, with the surface blocked, and no confirmation step | + +### What was wrong + +Two faults, and the second is the one that made both operations *look* broken. + +**1. `T-1` for park.** A guess with nothing behind it. There is no such command; it did +nothing, silently. + +**2. The active toolhead was read from the wrong object.** `state.toolhead()` took +`activeKey` from `toolhead.extruder` and only fell back to the subscribed +`extruder*.state`. But `toolhead` is **not subscribed** — the shipped page does not +subscribe it, and the conformance suite holds the list to the bundle's — so it arrives +only from the one-shot query at connect and after `G28`. + +The consequence is worse than a stale badge: + +``` +pick 1 → T1 A0 dispatched, machine obeys, extruder1.state → ACTIVATE + → toolhead.extruder still says extruder3 (fetched at connect) + → activeIndex still 3 → poll for ===1 never true → "did not report active" + +park → PARK_EXTRUDER3 dispatched, machine obeys, all extruders → PARKED + → activeIndex still 3 → poll for ==null never true → "did not park in time" +``` + +Both operations **succeeded on the machine and were reported as failures**, which is the +worst shape a bug can take: the user is told to retry something that already worked. + +**Fix:** the subscribed source wins. `extruder*.state === 'ACTIVATE'` is authoritative +whenever any extruder is reporting state at all; `toolhead.extruder` survives only as a +cold-start fallback for the window before the first snapshot. Four unit tests cover it, +including the two stale-`toolhead` cases, negative-controlled by restoring the old order. + +### The flow now + +One button, and what it does follows the toolhead selected above it — the same shape the +shipped page uses, where each toolhead carries a button reading *Park Extruder* when that +head is `ACTIVATE` and *Pick Extruder* when it is not. + +| Selected toolhead | Button reads | Sends | +|---|---|---| +| not live | **Pick extruder** | `T A0` | +| live | **Park extruder** | `PARK_EXTRUDER` | + +Selecting a toolhead sends nothing — it only points the jog and extrude controls, and +now also decides what the button means. The button blocks the surface while the gantry +moves and confirms against the machine rather than the G-code ack, which only says the +command was queued. `parkTool` still checks the live head before acting, whatever it is +passed: only a live head can be parked. + +Two buttons were tried first and were the wrong shape. Pick followed the *selection* +while Park followed the *machine*, so with a head selected that was not the live one the +pair disagreed about their subject and neither could name it without a tooltip. Collapsing +to one button removes the asymmetry rather than explaining it — the target is always the +head you have selected. + +### Waiting for a toolchange, and what the original does + +A pick or park sometimes triggers an XY calibration, and then it takes minutes rather +than seconds. Three things about that are worth writing down. + +**The bridge gives up long before the machine does.** `sw_SendGCodes` does not return +until Klipper has finished the move, but `TIMEOUT_MS` in `sswcp.js` is **15 s**. So the +request rejects while the printer is still working, and an implementation that awaits it +reports a failure for an operation that is going fine. That was the bug. + +**The machine says what it is doing.** `machine_state_manager.action_code` is on the +subscribed stream, and the bundle carries the labels: + +| | | +|---|---| +| 768 | Extruder Docking Calibrating… | +| 769 | Checking Extruder Park… | +| 770 | Checking Extruder Pick… | +| 832 | Homing Calibration… | + +36 codes in all, extracted by [`tools/extract_activity.py`](../tools/extract_activity.py) +into `shared/js/activity.js`. Only codes ≥ 128 are taken: below that the bundle has two +further switches over small integers whose cases collide with these — 1 is both "Working" +and "Homing" — so a merged table would be wrong wherever it is ambiguous. + +**What the original does: the same thing, badly.** Its handler is +`aDM(ctx, "Extruder N operating…", 60)` — a non-dismissable barrier that auto-dismisses +after 60 s *whatever has happened* — then it fires the command and clears the barrier in +a `finally`. It does not poll state and does not read `action_code`. When its own request +times out, `oJ`'s error path says **"Request timeout, please try again later."** So the +shipped page tells you to retry a toolchange that is still running, and its 60 s is a +ceiling on the overlay rather than any kind of completion signal. + +This does not copy that. The command is fired and deliberately not awaited; a rejection +mentioning a timeout is swallowed as "still working" while any other error is reported. +The wait then watches the machine: it shows the current activity label, and every time +the machine reports being busy the deadline is pushed out again. Silence for 60 s ends +it, and a hard cap of ten minutes stops it waiting forever. + +### Z is the bed, and up is negative + +The bed row's arrows were inverted: pressing **↑** lowered the bed. + +On this machine the bed is the Z axis, and Klipper's Z measures the nozzle-to-bed gap — +so a larger Z is a *wider* gap, which on a moving bed means the bed is further *down*. +Raising the bed is therefore a **negative** Z move. The printer's own config settles that +without appealing to convention: + +``` +stepper_z position_endstop 275.0, homing_positive_dir true → Z homes to its maximum +PRINT_END G0 Z200 F2000 → drives UP for clearance +``` + +Both only make sense if larger Z means more room between nozzle and bed. + +The arrows now send `Z−` for up and `Z+` for down, and each button's tooltip names both +the physical direction and the G-code it will send — *"Move the bed up, toward the nozzle +by 1 mm (Z−1)"*. An arrow on its own is ambiguous the moment the moving part is the bed +rather than the head, and that ambiguity is what the bug was made of. + +### What a toolchange actually reports + +The dialog stayed blank through a toolchange because it was watching two objects that say +nothing during one. Settled by driving a real `T0 A0` on an unhomed U1 over MQTT and +logging every field that moved: + +``` + 0.7s toolhead.homed_axes "z" idle_timeout.state "Printing" + 2.7s toolhead.homed_axes "" + 4.7s toolhead.homed_axes "y" +14.7s toolhead.homed_axes "xy" <- the "XY calibration" a user sees +28.7s extruder.activating_move true +30.7s extruder.state "ACTIVATE" <- done +32.7s idle_timeout.state "Ready" +``` + +Silent for the entire 31 seconds: **`machine_state_manager`** (`{main_state: 0, +action_code: 0}` throughout) and **`extruder_offset_calibration.calibration_step`** +(`"idle"` throughout). Those were the two the wait consulted. `display_status.message` +never set either. + +So the progress signal is **`toolhead.homed_axes`**, bracketed by `idle_timeout`. Neither +is available on the stream as the page subscribes it — `toolhead` is not subscribed at +all, and `activating_move` is not among `EXTRUDER_FIELDS` — so the wait polls both once a +second. + +`idle_timeout` is now trusted as a busy signal, having been excluded earlier for reading +`"Printing"` on an apparently idle machine. That was Klipper's timeout not having elapsed, +not a false reading, and across the capture it bracketed the operation exactly. Every wait +has its own `done()` and a hard cap besides, so a lingering `Printing` costs nothing. + +Measured durations, both verified live: **4 s** when the machine is already homed, +**31 s** when it is not. The second is the case that outran the bridge's 15 s request +timeout and made a working toolchange look like a failure. + +### `toolhead.extruder` does not mean "engaged" + +Park reported "did not report a parked toolhead" with nothing having moved. The command +was fine; the panel was asking the wrong head to park. + +Measured on a fully parked U1: + +``` +toolhead.extruder : "extruder" <- names a head +extruder.state : "PARKED" <- ...that is not engaged +extruder1..3 : all PARKED <- nothing is engaged at all +``` + +`toolhead.extruder` is Klipper's *current extruder for G-code purposes*. It goes on +naming the last head used after that head has been parked. It was being used as a +fallback for "which head is live", so with no `ACTIVATE` anywhere the panel still +believed head 1 was engaged: the button offered **Park**, sent `PARK_EXTRUDER`, and got +an instant `ok` for a head that was not there. Nothing moved, and the wait then sat out +its full timeout for an `activeIndex` that could never change. + +The instant `ok` is the trap. Measured against a head that *is* live, `PARK_EXTRUDER2` +does not reply for over 20 s — it blocks while it works. Against one that is not, it +returns immediately and successfully. **A G-code ack cannot distinguish those**, which is +why the wait confirms against `extruder*.state` instead. + +Engagement is now read only from `extruder*.state === 'ACTIVATE'`. Nothing reporting it +means nothing is engaged — an answer, not a gap to fill from elsewhere. Two unit tests +that previously asserted the fallback have been inverted; they had encoded the bug. + +## Round three — setting a temperature + +Two faults from ordinary use: the temperatures *did* apply — the printer heated — but the +row lied about it either side of the round trip. + +### A zero is not a reading + +Every idle row showed `24 / 0 °C`, and that `0` had to be selected and deleted before a +temperature could be typed. It was never information: a target of zero **is** a heater +that is off, and five rows all saying so at once is noise standing exactly where the +input goes. + +The field now holds an empty string for a zero target and shows `—` through the +placeholder, and focus selects whatever is there so a set value is typed over rather than +edited. Clearing the field is no longer read as "switch it off" — that is asked for by +typing a zero, which cannot happen by walking away from a half-edited field. + +`select()` on `` is worth a note: `selectionStart` and +`setSelectionRange` do not apply to that type and throw, but `select()` does apply. It +also needs the mousedown/mouseup guard, or the mouseup that follows a click collapses the +selection focus had just made. + +### The value that vanished, and the setpoint that was never confirmed + +A committed temperature would disappear a second later, or come back as `0`. + +`updateTempRow` wrote the machine's target into the field on every state push. The push +that follows a commit lands roughly a second before the printer has reported the change, +so it wrote back the value from *before* the edit. The field being left alone while +focused was not enough — the commit happens **on blur**, so by the time the value is on +its way the field is no longer focused and no longer protected. + +Same bug class as the tool selection one: *the request was stored in the thing that +mirrors the machine*. It is now pending-until-confirmed, the same model: + +| | | +|---|---| +| sent | the asked-for value stays on screen, dashed, until the machine echoes it | +| confirmed | `extruder.target` comes back equal to it, and the mark clears | +| refused | the bridge rejects the command — the row stops waiting at once | +| ignored | ten seconds with no echo: the row drops back to the machine's value and says which value did not take | + +That last row is the one worth having. **A command that succeeds and changes nothing is +exactly what a silently-ignored setpoint looks like** — the same trap as the instant `ok` +from `PARK_EXTRUDER` — and it must not sit there looking applied. + +### There was no sign the heater was working + +A nozzle climbs about a degree a second. Watching one number tick while another sits +still reads as nothing happening, so a temperature that was accepted and one that was +dropped looked identical for the first half-minute. + +The row now says which of four things it is doing, from the two numbers both heaters +already report — `power` would say it directly, but only the extruders publish it, and +`heater_bed` is subscribed for `temperature` and `target` alone: + +``` +heating cur < target - 2 warm ink, and a bar under the numbers +ready |cur - target| <= 2 the ok ink, no bar +cooling cur > target + 2 cool ink and bar, and only while cur > 40 - + and cur > 40 a nozzle at 120 with the heater off is not an idle one +off target 0, cold nothing +``` + +The bar runs from the temperature the ramp *started* at to the target, so it moves for as +long as the machine does. Against 0 it would jump about whenever the target changed. + +The readings column is 126px and every pixel was already spoken for, so the feedback had +to cost no width: colour, and 2px under the numbers. + +### The column could not hold its own numbers, and would not hold still + +Found while looking at the result: at 30px the target field clipped a three-digit value, +so a nozzle at 220 read as `22`. Measured in the page rather than estimated — 9.00px per +tabular digit, so 27 + 6 padding + 2 border = 35px. The row gap goes 5px → 4px to pay for +it, which leaves the widest case the row can be asked to show (350 over 350) fitting the +126px column with 2px to spare. + +The reading beside it had the opposite problem: it was auto-width, so a nozzle climbing +from 99 to 100 carried the slash, the target field and the unit one digit to the right, +and five rows at five temperatures never lined up with each other. `.cur` now reserves +three digits and right-aligns against the separator, so both numbers sit against the +`/` and nothing downstream of them can move. A browser check asserts it directly - +`.sl`, `.tgt` and `.unit` must share one x across every row, at one digit and at three. + +### Testing, in a browser at last + +`run_selftest.py` still cannot run — playwright is absent and the vendored chromium is +still missing `libnspr4`/`libnss3`. But **WebKitGTK is present**, because it is what Orca +renders with, and PyGObject drives it the same way it drives JavaScriptCore: + +```bash +python3 resources/web/shared/tests/run_webkit.py --shots /tmp/shots +``` + +It serves `resources/web`, loads the Device page against the simulator, and drives the +real controls. That is a better witness than chromium would have been: the checks run in +the engine that will run the page for real. It is how the three faults above were +confirmed fixed, and how the clipped digit was found in the first place. + +Screenshots come from `Gdk.pixbuf_get_from_window`, not WebKit's own snapshot API, which +returns a cairo surface there is no pycairo here to receive. It needs a display; WSLg +provides one. + +### And then against the printer, still without Orca + +The page reaches a machine only through `window.wx`, so everything above was checked +against the simulator. That limit turned out to be one user script wide. + +[`tools/u1_bridge.py`](../tools/u1_bridge.py) answers the same SSWCP contract from +Python: `run_webkit.py --real` installs `window.wx` as a WebKit user script, and the +bridge behind it speaks MQTT to the real U1 over `mqtt_min.py`. The page then runs its +own connect path — LAN auth on `:1884`, mTLS on `:8883` — and the log reads the same as +Orca's, ending in the same `snapshot ok: 24 objects`. + +`--watch` turns the same command into a window rather than a test: the checks run and +report first, then it stays open until closed, with the terminal showing what every +click sends. That is the loop the connect bugs were found in, without Orca in it. + +Three kinds of command, and the split is Orca's, not a design of ours: + +| | | +|---|---| +| transport | `sw_create_mqtt_client` / `connect` / `subscribe` / `publish` / `disconnect` / `set_engine`. The page drives the socket; the host only owns it | +| printer | 37 of the page's commands are one JSON-RPC method with the parameters passed nearly straight through | +| local | the device book and the login state, read from Orca's own config; eight more refused in as many words | + +The mapping is **generated**, by [`extract_bridge_methods.py`](../tools/extract_bridge_methods.py), +from `SSWCP.cpp` and `MoonRaker.cpp` — two hops, `m_cmd == "sw_X"` → `sw_X()` → +`host->async_x()` → `method = "printer.control.x"`. Writing it down by hand would have +been the same mistake as the parity claim: a description that stops matching the thing +it describes. + +The generator earned its keep immediately. Its first version sliced a C++ function body +at the next definition *of the same class*, which let `sw_mqtt_publish` swallow every +function defined between it and the next MqttAgent member — and come out mapped to +`machine.system_info`. A publish would have gone out as a system-info query. Bodies now +end at the first column-one `}`, and a conformance check asserts that the transport +commands map to no printer method at all. + +**What this cannot do**, and it matters: it is a *second host* speaking the same +contract, so it proves the page and the printer agree. Whether **Orca** agrees is a +different question, and the open ones — whether a camera `{state, url}` arrives as the +subscribe ack or as a push, whether `sw_SetSubscribeFilter` narrowing changes what a +wait can see — are about Orca's leg specifically. Those still need the app. Orca must +also be closed while this runs: same saved `clientId`, and a broker evicts the older +holder of a duplicate id. + +### What the printer said about the temperature work + +Driven end to end through the page's own field, on the machine +([`--drive`](../../../resources/web/shared/tests/run_webkit.py) runs a script in the +live page): + +``` +sw_ControlExtruderTemp {"temp": 57, "index": 0, "map": 0} + the row holds the asked-for value, marked unconfirmed + the printer echoed the target back 552 ms one run, 1516 ms the next + the wait ended; the row says heating + nozzle 42 -> 62 °C over 10s, the bar filling + put back to 0; the field returns to a dash +``` + +**The echo takes between half a second and a second and a half.** That is the whole bug +in one measurement: the render tick is about a second, so a push computed before the +echo arrived was writing the machine's old target — usually `0` — back over the value +just sent. It was never a race the user could win by typing faster. + +The run also found a flaw in the new bar that the simulator could not have shown: asked +for 40, the nozzle overshot to 48, and the state flipped heating → cooling with the +target unchanged. The ramp was only restarted when the *target* changed, so the bar sat +at 100% while the temperature was falling. It now restarts when the direction does. + +## Round four — the session, and what "connected" means + +Three faults, found by running the page against the machine for longer than a test. + +### A slow command is not a refused one, and two clocks say "timeout" + +Picking a toolhead on a cold machine reported **"The printer refused the command"** while +the toolchange ran to completion. The page already knows a timeout is not a refusal - it +tests the rejection and ignores it - but it was matching one wording out of two. + +| | | | +|---|---|---| +| the client's | 15s | `sswcp.js`, "sw_X timed out after 15000ms" - the page giving up on the BRIDGE | +| Orca's | **80s** | `add_response_target`'s default (`MoonRaker.hpp:344`) waiting on the PRINTER, failing as code **-2**, message **"time out"** | + +Note the space. `/timed out/i` never matched Orca's own wording, and it stayed hidden +because the client's clock is the shorter of the two: Orca's `-2` arrives long after the +request has been forgotten, and is dropped for want of a pending entry. + +`u1_bridge.py` brought it out by answering sooner - it had been given a 12s timeout, +guessed from the client's 15s rather than read from the C++. That put a failure in front +of the page before its own clock ran, with a message matching nothing. The bridge now +waits the 80s Orca waits and fails in Orca's exact shape; the test moved into +`sswcp.js` as `isTimeout()` and keys on the code as well as both wordings. + +Measured after, on a cold machine: **31.9s**, walking +`Homing — Z done → Homing axes… → Homing — X, Y done → Engaging toolhead 1…`, no refusal. + +### "Connected" was a claim about the past + +A rebooting printer went on showing as connected, its last snapshot presented as current. + +```js +const live = state.lastUpdate > 0 && ... // has anything EVER arrived +``` + +Nothing aged it, so once a session had existed the page vouched for it for the rest of +the session. The fix needed a number, and the number was surprising: **an idle U1 pushes +about twice in 30 seconds**, gaps of 4s to 14s. Klipper only sends fields that change and +an idle machine changes almost nothing - so a staleness window has to be several times +the idle gap or a quiet printer flickers as if it had gone away. 45s is about 3x the +widest gap seen, with the 30s heartbeat - now consulted rather than discarded - covering +a machine that genuinely has nothing to say. + +It also needs a clock. Every other repaint in the page is triggered by something +arriving, which is exactly what stops. + +Measured: socket killed under the page, nothing told it. Before, 115s stale and still +claiming connected. After, the dot drops and the panel fades in ~32s. + +### One attempt, at boot, and only if nothing had ever arrived + +Start the app before the printer and it stayed dark until a reload. Reboot the printer +and it never came back. Both are the ordinary way this hardware gets used. + +There is now a supervisor on the same 2s clock: not live, not already connecting, and a +device that can authorise itself → reconnect, backing off 5s / 10s / 20s / 30s and +holding. The dead engine is dropped first, because it still holds a socket at the host +and the next connect makes a new one. + +The **Connect** menu item was gated on `DeviceInfo.connected`, which is force-cleared on +every config save (`AppConfig.cpp:887`) - it answers neither "is it there" nor "do we +have a session". It is gated on live evidence now, and so is **Disconnect**. + +Measured both ways: + +``` +socket killed noticed 37.2s, reconnected 37.9s, 26 objects back +no printer at all attempts at 11s, 31s, 61s, 91s - gaps 20s, 30s, 30s + (--device-ip 192.0.2.1) + "U1 G — not connected (connect pairing client: … No route to host) — trying again in 30s" +``` + +`--device-ip` points the saved device somewhere unroutable, which is how the +nothing-there path gets exercised without switching the printer off. + +## Round five — the two cards, compared against the original side by side + +With both surfaces running on the same printer, the rebuilt cards could be put beside +the originals and measured rather than judged. + +**Camera.** The rebuild showed the not-connected illustration whenever the camera was +off, which answers "there is no printer" rather than "the camera is off", and offered a +text button the original has nothing like. It is a **black viewport** with one round +control inside it, and it says `Camera not on` - the bundle's own string, against the +invented "Camera is off". The bundle also carries `Camera start failed`, +`Camera started successfully` and `Camera loading failed. Please try again`, all of which +the rebuild had been wording for itself. + +**Print.** The rebuild swapped in an illustration and "No active print" when idle. The +original keeps **one card and zeroes it**: status badge, machine name, thumbnail, +filename, percentage, layers, time, bar, one round button. An idle machine and a +printing one differ in the numbers, not the furniture. Layer counts come from +`print_stats.info`, which was already subscribed and unread. Klipper says `standby`; the +shipped page shows `idle`, so the badge does too, with the machine's own word on the +tooltip. + +**Tabs.** Two findings, one of each kind: + +- The `|` separator carried a 20px margin each side. Measured on the shipped page - + title at 28, separator at 99, selected pill at 117 - it is **14 before and 13 after**, + and the 20 was pushing the Camera tabs 13px right. Only the two headers that have a + separator were affected; Control and Filament were already landing on 117. +- The selected pill is inset **at the top only**, so its white runs into the white body. + Centring 36px inside a 40px header leaves 2px of grey underneath, and that is the + whole difference between reading as a tab and reading as a loose button. + +The 36px tabs and the 40px full-height refresh pills are *not* an inconsistency to fix: +they are different controls. Control has no separator because its refresh pill is a +button, not a tab. + +**A dead end worth recording:** the residual title-width differences (Printing Task 8px +narrow, Filament 3px wide) are font metrics, so the bundle's own font was tried - +`HarmonyOS_Sans_SC_Regular.ttf`, which it ships. Every title came out *narrower*, Camera +by 11px. Whatever Flutter renders those titles with, it is not that face at 14px. The +system stack lands "Camera" on the measured 57px exactly, so it stays. + +**Refresh did nothing.** Both header pills called `refresh()`, which re-reads Orca's +device book: two commands that say nothing about the machine. Clicking the shipped +page's own pill and watching what left it settles what they should do - and both of its +pills send the identical set: + +``` +sw_SubscribeMachineState / sw_SetSubscribeFilter / sw_GetMachineState +sw_GetMachineSystemInfo / sw_FileGetStatus / sw_exception_query / sw_MachineFilesRoots +``` + +A stranded docstring had been sitting above an unrelated function the whole time - +*"Re-read everything the page shows, as the header refresh buttons do"* - describing +something nobody had written. + +## Round six — Storage, and four things found by using the page + +Reported from ordinary use, each settled by measuring on the machine. + +**The toolchange label named the axes done** - "Homing — Z done", then "Y done", then +"X, Y done". Klipper **clears** `homed_axes` as it re-homes, so Z was reported done and +then vanished from the list: the machine appeared to go backwards. And nobody waiting on +a toolhead wants an axis-by-axis account. One step now, `Homing axes…`, confirmed on a +cold change: `Waiting for toolhead 2… -> Homing axes… -> Engaging toolhead 2…`, 36.2s. + +**Lists reset their own scroll.** The task panel repaints on every state push and each +repaint emptied the node and built it again, which threw away the scroll position - so a +list scrolled itself to the top while being read, and Load more landed you at the top of +the old page. Lists rebuild only when their content signature changes now, and the +scroll is carried across when they do. Measured: 420px held across six pushes and across +a Load more that took the list from 20 cards to 40. + +**Opening a recording only offered to delete it** - the sheet said playback was Orca's +job and then showed a Delete button, which made "open" mean "destroy". The URL is on the +instance, and which port answers is not a guess: + +``` +http://:7125/server/files/camera/.mp4 200 video/mp4 23 MB +http:///files/camera/.mp4 200 text/html 2.9 KB <- the SPA +``` + +The second is the same trap the camera frame hit. **But this WebKitGTK build has no +H.264**: `canPlayType('video/mp4')` is empty and playback fails +`MEDIA_ERR_SRC_NOT_SUPPORTED`. Whether the engine can play a file and whether the printer +serves it are different questions and get different answers - the sheet shows the still +and says which one it is. Orca's Windows and macOS webviews are not affected. + +**Storage.** Time-lapses, finished prints, print files and logs were four shapes behind +three tabs on two panels. They are all "things on the machine you might want to look at", +so they are one destination, one picker, one card - normalised in `storageCard` rather +than four renderers kept in step by hand. `renderHistory` and `renderFiles` are gone. + +Two things worth carrying forward from building it: + +- The two views are **siblings in one document**, toggled with `hidden`. A separate page + would drop the MQTT session and re-run the LAN key exchange on every switch. +- `#view-control` is `display: contents`, so the four panels stay direct children of the + 2×2 grid. **That defeats `hidden`**: the UA's `[hidden] { display: none }` loses to an + id selector, so the control panels went on rendering underneath Storage and squeezed + its grid to 4px. It has to opt back in explicitly. + +Both grids collapsed to strips of thumbnail first, twice, for the same reason: grid rows +default to `auto` and shared the bounded panel height between them. `grid-auto-rows: +max-content`. + +## Round seven — the page was blank in Orca, and the page was not the problem + +Reported as "make sure the Device page is properly loaded in snorca". It was not. In the +app the Device tab drew the rail and **nothing else**: `.content` empty, no panels, no +error dialog. Every suite was green — 51 browser checks, 153 conformance, 144 unit — and +the same files served by any other HTTP server rendered correctly. This is the failure +mode `STATUS.md` warns about, in its purest form: **all the evidence said the page was +fine, and the page was fine.** + +### What it looked like, measured rather than guessed + +Loading the very same files **from Orca's own page server** in WebKitGTK: + +``` +readyState "interactive" (never reaches "complete") +window.__devicePage undefined (app.js never evaluated) +.content children 0 +console silent +resources 48 started, 47 finished, 1 PENDING — forever +``` + +Served by anything else: 73 started, 73 finished, 0 pending, `complete`. The stall is one +resource per load, and the ES module graph has no partial success — one module that never +arrives is a page that never runs. + +### The defect + +`session::read_next_line()` in `src/slic3r/GUI/HttpServer.cpp` read a header line with +`async_read_until(socket, buff, '\r')` and then consumed the `\n` with a **second** +`getline`. asio reads in **512-byte chunks**, so a read can stop with a `\r` as its last +buffered byte. The `\n` is not there yet, the second `getline` consumes nothing, and every +line after it arrives carrying a leading `\n`. The end of the headers is recognised by +`line.length() == 0`, which then never matches — so the session asks for one more line +forever and the request is never answered. No error, no close, no log line: the server had +already printed `request for resource:` and simply never printed `Request received:`. + +Counted in one run's server log: **810 request lines read, 785 answered.** + +### Why it worked until 2026-08-25 + +The trigger is arithmetic, not chance. WebKit's header block for a same-origin module +fetch is constant, so a request's length is decided by its **URL** length, and the hang +needs a `\r` on **byte 512**. Of the 71 requests this page makes, exactly two do: + +``` +GET /web/device_page/js/views/device-control/filament/filament-panel.js <- byte 512 is CR +GET /web/device_page/icons/printPreferenceArrow.svg <- byte 512 is CR +``` + +That module was `js/panels/filament.js` until `28fdd0feae` (*one panel, one directory*) +moved it to `js/views/device-control/filament/filament-panel.js`: **534 bytes on the wire +became 563**, and put a CR on the boundary. The restructure did not break the page; it +moved a file 29 characters to the right, and a latent server bug did the rest. + +The proof is reproducible in either direction — sweep every place a request can be split +and count the ones that never answer: + +``` +before: 515 splits tested, 2 failures (split 1, split 513) +after: 515 splits tested, 0 failures +``` + +### The fix, and a second bug found on the way + +Header lines are read on **`'\n'`** now — the terminator that cannot be orphaned — and a +trailing `'\r'` is stripped from the line. One read, one line, and a blank line that is +actually empty. + +The second one was found with a logging proxy in front of the server: WebKit was writing +requests into sockets the server **had already closed**. Every response is HTTP/1.1 with +no `Connection` header, which means *persistent* — while `session::read_next_line()` +writes the response and then calls `server.stop(self)`, which shuts the socket down. The +client pools a connection that is already gone. Every response says `Connection: close` +now, because that is what the server does. + +Both are in `HttpServer.cpp` and neither is specific to this page: any client fetching +enough URLs from Orca's page server was exposed to both. The Flutter bundle survives it +because it is one large script rather than a graph of fifty modules — a lost request there +costs an icon. + +### What it looks like now + +The Device tab in Orca, on `811002511261022618B3` over the LAN: camera, control with five +live temperatures, printing task, and the multiACE card reporting **1 ACE unit**, head +mode, Toolhead 4 fed from ACE A with all four bays named (`A1`–`A4`, PETG — the override +store merged), 38 % RH at 31 °C. Five consecutive loads: `complete`, 0 pending. + +**Two habits this pays for.** `run_webkit.py` serves the page from Python, so it could +never have seen this — the harness proves the page agrees with the printer, not that it +agrees with *Orca's own server*. Loading the page from `http://127.0.0.1:13619` and +asking for `readyState` plus the count of unfinished resources is now the cheapest check +that the app can render it at all, and it is worth running before believing a green suite. + + +## Round eight — an unreachable printer took the whole application down + +Found while checking the panel after a drawing change: Orca would not start. The printer +was switched off, and that was the whole of it: + +``` +terminate called after throwing an instance of 'mqtt::exception' + what(): MQTT error [-1]: TCP/TLS connect failure +``` + +**Paho reports a failed operation by throwing.** `token::wait_for()` calls `check_ret()`, +which throws `mqtt::exception` whenever the return code is not success, and +`async_client`'s own calls check the C return the same way. `MqttClient::Connect()` is +documented *"true if connection successful, false otherwise"* and had **no handler at +all** — so the throw went straight past it, out of the thread it was on, and into +`std::terminate`. + +Nothing else needed to change. Both callers in `MoonRaker.cpp` — `ask_for_tls_info()` and +the MQTTS connect — already branch on the `false`; the contract was right and simply was +not kept. + +**Three more methods had the same hole**, each with the same `bool` contract and each +waiting on a token that throws: `Subscribe`, `Unsubscribe`, `Publish`. `Disconnect` was +the only one guarded, which is why the fix reads as *one* guard applied five times rather +than five separate decisions — a file-local `mqtt_failed()` logs the operation, the server +and the message, and returns false. `Publish` is worth its own line: `CheckConnected()` is +a snapshot, not a lock, so the link can drop between that answer and the call, and +`publish()` then throws `MQTTASYNC_DISCONNECTED` on a client that was connected a +microsecond earlier. `CheckConnected()` itself calls `.get()` on a future that rethrows, +and now answers false instead. + +**What it looks like now**, with the printer off and the app running: + +``` +[MQTT_INFO] connect failed: MQTT error [-1]: TCP/TLS connect failure, + Server: mqtt://192.168.2.242:1884 +``` + +and on the page, at the bottom of the Device tab: + +``` +U1 G — not connected (connect pairing client: sw_mqtt_connect failed (code -1): +connect failed: MQTT error [-1]: TCP/TLS connect failure) — trying again in 27s +``` + +That second line is the reconnect supervisor from round four doing exactly what it was +written to do. It could never do it before: the application died first, so the retry it +scheduled had nothing to run in. + +**The lesson generalises past MQTT.** A function whose signature says it reports failure +by returning has to be *made* to, and a library that reports by throwing will not do it +for you. The four methods here all said `bool`, all documented "false otherwise", and +three of them could not deliver on it. + + +## Round nine — unload a head, and it offers to unload it again + +Reported from ordinary use: *"the unload functionality of a toolhead with single filament +sort of works, but after I can unload again instead of load."* + +**The panel was asking the job what the hardware knows.** `filaments()[i].loaded` is built +from `print_task_config` — `filament_exist[i]` and `filament_type[i]` — which is what the +**slicer assigned to that slot**. A physical unload does not clear it, so the head went on +reading as loaded and the verb list went on offering `Unload`. + +Measured on the machine after the unload, and printed by +[`drive/ace-verbs-real.js`](../../../resources/web/shared/tests/drive/ace-verbs-real.js): + +``` +toolhead 1 is a stock feeder · sensor says empty · print_task_config says loaded <- they disagree +``` + +**The head's own sensor answers the question actually being asked.** `filament_feed +left|right` → `extruder.filament_at_extruder` is what is *in* the head; it is on the +subscription, and it is already what the marker on the artwork draws. It is now the +authority, with the job record as a fallback only where a printer reports no feed channel +at all. + +**The same trap was one level down, and would have bitten next.** `aceVerbs()` computed +`empty = fed == null && !loaded`, where `fed` is `head_source[n]` — multiACE's record of +the last feed. It does not stop naming a bay because the filament came out, so an emptied +ACE head was never empty however the sensor answered, and offered `Swap` where `Load` was +the truth. `loaded` is the single authority now, and `fed` only says where it came from — +which is meaningful only while something is there. + +**Three places were deciding it, and one had forgotten the feeder case.** The bay sheet, +the toolhead sheet and the card menu each computed `loaded` their own way. `headLoaded()` +decides it once; deciding it once is most of the fix. + +**What it cost to find, and what it should have cost.** The simulator derived the sensor +*from* `filament_exist`, so the two could never disagree and no test could have caught +this. They are separate fields on the printer and they are separate in the simulator now — +which is what let the reported bug be reproduced in a drive script before it was fixed, and +asserted after. + +**Two checks moved to the truer answer while this was done**, both in `drive/ace-panel.js`, +and it is worth noting that neither was wrong when written: + +- *"clicking a bay names the macro it would send"* had been `ACE_SWAP_HEAD` (the panel sent + it for everything), then `ACE_LOAD_HEAD` (because `head_source` was empty), and is now + `ACE_SWAP_HEAD` again — because the head still physically holds what its stock feeder + put there, and getting the ACE's filament in means taking that out first. +- *"an empty one offers Load"* emptied the head by setting `filament_exist`, which only + worked because the panel was reading the wrong field. It sets the sensor now. + +**The bug class, stated once more.** Every round of this has had one: `toolhead.extruder` +naming a parked head, `head_ace` naming a unit that is not attached, `DeviceInfo.connected` +answering about the past, `wait_insert` not meaning empty — and now `filament_exist` +answering about the job. *The field reads plausibly and answers a different question.* + + +## Round ten — three faults from one load + +Reported from ordinary use, and all three are the same load: + +``` +Toolhead 1: load failed: sw_SendGCodes timed out after 15000ms +Printer fault · code 0000000000000240 · not in the shipped catalogue +``` + +### An activity code is not a fault code + +`0000000000000240` is `0x240` = **576**, and 576 is `action_code` for **"Auto Loading"**. +The fault banner read + +```js +const code = (exception && …) || activity.actionCode; +``` + +on the stated belief that *"`machine_state_manager.action_code` carries the active fault"*. +It does not: it is the **fine-grained activity** code, and `shared/js/activity.js` is +generated from the same bundle with its own table for it — 576 "Auto Loading", 640 +"Unloading", 832 "Homing Calibration…". `lookupFault()` padded the integer into a 16-digit +code that could never match, and the banner said so in the most alarming way available. + +activity.js's own header warns about exactly this: *"their case spaces overlap, so 1 is +'Working' in one and 'Homing' in the other. Merging them would be wrong wherever it is +ambiguous."* Decoding one against the other catalogue is that mistake one level up. +**A fault comes from `server.exception.query` and from nothing else.** + +### A load homes first, and homing outlives the request + +`sw_SendGCodes` does not return until Klipper has finished, and the bridge gives up at +15 s. A load **homes** — measured 14.7 s to `xy` and 31 s from cold — so the request +rejects while the printer is working, and the panel reported a failure for an operation +that was running. + +The Control panel solved this in round four and the filament commands never adopted it: +don't await the request, treat `isTimeout()` as *not a refusal*, and confirm against +machine state. That is what they do now. + +### So it blocks, and blocking is the point + +A non-background swap is about **three minutes** during which the machine can do nothing +else. Offered the choice between a cancellable queue and a blocking dialog, a blocking +dialog is the honest one: a second verb started underneath the first is not a queue, it is +a collision, and there is nothing to cancel once the filament is moving. + +`runFilamentAction()` is `runToolAction()`'s shape for this domain, with one improvement +the Control panel could not have: **it names the step rather than the operation.** +`action_code` says "Auto Loading"; `channel_state` says `unload_heating`, which the step +model turns into **Heat nozzle (3/6)** on the same six-step bar the card draws. It closes +on `*_finish`, reports `*_fail` verbatim, and gives up loudly rather than silently. + +**A background verb does not block** — not blocking is its entire purpose. + +### One ordering bug found on the way + +The sheet's verb rows ran `runVerb(v)` and *then* `closeDialog()`, which closed the +blocking dialog `runVerb` had just opened. They close first now. + + +## Round eleven — the same bug, and the fix that only fixed the simulator + +Round nine's report was *a toolhead unloaded, and then offered `Unload` again*. The cause +was that occupancy came from `print_task_config.filament_exist` — the slicer's assignment +to the slot, which no physical unload clears. The fix read `filament_at_extruder` instead, +on the stated ground that it *"is what is IN the head"*. Every suite went green. + +**It was reported again**, from the same machine, doing the same thing. + +### Measuring instead of reasoning + +Toolhead 1 unloaded by hand a moment before, on `811002511261022618B3`, is the one head in +a row whose true state is known. Read alongside the other three: + +``` +head channel_state channel_action_state detected inAce inTool atExt exist TRUTH +0 unload_finish unload_finish T T T T T empty +1 wait_insert unload_finish F T F T T empty +2 wait_insert none F T F F F empty +3 wait_insert none T T F T T LOADED +``` + +Four fields read like presence and not one of them is: + +| field | why not | +|---|---| +| `filament_in_ace` | true on all four, the empty one included — **a module is there**, not filament | +| `filament_at_extruder` | true on three, **two of them empty**; tracks the path having filament available, and does not go false when a head is emptied | +| `filament_in_toolhead` | **true on the head just emptied and false on the loaded one** | +| `channel_state` | `wait_insert` on an empty head *and* on a loaded one — already recorded in round eight, and the reason this needed a second look | + +`channel_action_state` separates them, and it is the only one that does, because it is not +a sensor: it is **the last operation the channel finished**. `unload_finish` on both heads +that had been unloaded; `none` on the two untouched since boot. + +So `headOccupied()` asks the sticky field, then the live one, then the topology — and that +order is by **what each field is for, not by which is fresher.** `channel_state` is the +more recent value and is also the one that decays to a word carrying no occupancy at all. + +### What the simulator could not have caught + +The simulator was green through both bugs, and it was not carelessly written — round nine +went to the trouble of *separating* the sensor from the job record so they could disagree, +which is exactly the right instinct. It still proved nothing, because the sensor it added +was computed from the same belief the panel held. **A simulator can only be wrong in the +ways it was written to be wrong.** + +Two changes follow from that, and they matter more than the one-line fix: + +- `mockhost.js` now reports `filament_at_extruder` **the way the machine does** — true on + emptied heads included — so anything reading occupancy from it fails here as it failed + there. Toolhead 4 is `wait_insert`/`none`/`at:true`, exactly as measured: **nothing in + its feed channel says it is loaded**, only the topology does, and that fallback was + never exercised before. The channels also sit on the left and right objects the way the + machine splits them, rather than all four on the left. +- `drive/ace-verbs-real.js` now asserts **on the printer** that the three fields disagree + and that the panel follows the right one. The class of bug is *"the page and the machine + hold different beliefs"*, and only the machine can referee it. + +### And it is asked in one place + +This is the third field this question has been asked of and the second that was wrong. +Round nine's own note — *"three places decided this independently, and one had forgotten +the feeder case"* — is why the second wrong answer cost one line: `headOccupied()` lives +in `multiACE.js` beside the state table, `state.headLoaded(i)` is the convenience form, +and the panel's marker now reads it too. It had been drawing *"filament at the extruder"* +on a head the same card was offering `Load`. + + +## Round twelve — an ACE macro at a toolhead with no ACE + +Reported while testing round eleven: `Load — Toolhead 2` opened the blocking dialog and +sat on **"Asking the printer…"**, and then, plainly: *doing an ace load is weird when no +ace is connected to that toolhead*. + +It is, and the macro's own help says so. A stock feeder head was sending + +``` +ACE_LOAD_HEAD HEAD=1 +``` + +with no `ACE=` and no `SLOT=`, and `printer.gcode.help` describes that macro as +**"[multiACE] Load a toolhead *from ACE*"**. There is no ACE behind head 1 — `head_feeder` +says so, and the panel had drawn it as a stock feeder all along. The command was accepted +and nothing happened, which is this machine's usual way of saying no: `ACE_SET_AUTO_DRY +THRESHOLD=` answers `ok` and changes nothing either. + +### Read out of the printer's own config + +`docs/u1-webui/tools/ace_macros.py` keeps 92 ACE macros of the **336** on the machine, and +the answer was in the other 244. Moonraker's HTTP API serves both the help and the parsed +config — and it needs no `clientId`, so it can be read **while someone else is driving the +printer**, which the MQTT path cannot: + +```bash +curl -s "http://:7125/printer/gcode/help" +curl -s "http://:7125/printer/objects/query?configfile=settings" +``` + +`AUTO_FEEDING EXTRUDER=n` is the U1's own wrapper: it maps the extruder to a +`(module, channel)` pair through `_FILAMENT_FEED_VARIABLE` and calls `FEED_AUTO`. And the +**unload form did not have to be guessed** — the machine runs it at the end of every +print, in `SM_PRINT_END_AUTO_UNLOAD_FILAMENT`: + +``` +AUTO_FEEDING EXTRUDER={i} UNLOAD=1 STAGE=prepare +AUTO_FEEDING EXTRUDER={i} UNLOAD=1 STAGE=doing +``` + +`STAGE` is the same vocabulary `channel_state` reports back in — `unload_prepare`, +`unload_doing` — so the step bar follows a feeder verb without being taught anything. + +**The load form is inferred, and it is the only thing on this page that is.** `LOAD=1` +comes from `SM_PRINT_AUTO_FEED` (`FEED_AUTO … LOAD=1 PRINTING=1`) and the two stages from +the unload above. Every other macro argument here was settled by sending it and reading +the object back; this one has not been. + +The shipped Flutter bundle turns out to contain **no feeder load command at all** — no +`AUTO_FEEDING`, no `sw_` command for it. The stock feeder auto-feeds when filament is +inserted, which is what `wait_insert` has been saying all along. + +### A silent dialog is a bug of its own + +The verb doing nothing was one fault; the panel spending ninety seconds not saying so was +another. Three changes, and none of them depend on the macro being right: + +- **The machine's own word, when neither table has a name for the state.** A dialog + reading `Printer says: wait_insert` says which state nothing is moving in. "Asking the + printer…" says only that the panel has stopped talking. +- **Twenty-five seconds to start, not ninety.** A load homes first and homing is the slow + part — but homing is *reported*: `action_code` reaches 832 within a second or two. So + nothing busy and nothing on the channel after 25 s means nothing started, and the + failure names both the channel state and what `sw_SendGCodes` actually replied. An `ok` + is reported rather than trusted. +- **`EXTRUDER=` is a toolhead too.** `runVerb` read `v.args.HEAD` only, so the first verb + addressed in the U1's vocabulary went out with no head at all: an untitled dialog with + no channel to follow. That is exactly what a bare `Load` with a permanent "Asking the + printer…" was. + + +## Round thirteen — the panel was talking to itself + +An audit of every user-facing string in the Device page, and three faults found by reading +the screen rather than the code. + +### Ten pieces of copy that were about the page, not the printer + +| where | was | now | +|---|---|---| +| the no-start dialog | `Nothing started. The printer is wait_insert and answered {"result":"ok"}. Nothing was sent twice.` | `Nothing started. The toolhead is waiting for filament.` | +| the quiet dialog | `…it may have finished; **the panel will catch up**.` | `…It may have finished.` | +| the verb guard | `Load: not a verb this panel sends` | `Load is not available` | +| the head marker | `channel_action_state: load_finish` | `Filament loaded` | +| the step bar | `channel_state load_flushing — step 6 of 6` | `Step 6 of 6` | +| a failed step | `unload_fail` | `Unload failed` | +| the edit row | `print_task_config` | *(nothing, or `Read only — the spool carries its own record`)* | +| the job status | `print_stats.state: printing` | *(the state, in words)* | +| the humidity drop | `43 % RH — hum_level2` | `43 % RH` | +| the fault banner | `not in the shipped catalogue` | `unrecognised` | + +Plus four that were reasoning rather than state: `Hand-fed — no ACE feed, retract, assist +or RFID`, `Its verbs are in the card menu.`, `Click for what can be done to it.`, and +`Printer says: wait_insert` — which was added the same afternoon, and is the same mistake +in a newer coat. + +`channelWord()` is the fix that makes the rest possible: a word table for `channel_state`, +exactly as `shared/js/activity.js` is one for `action_code`. **A caller with no word for a +state says nothing** rather than falling back to the enum. + +### A macro name is an implementation detail after all + +The rule in CLAUDE.md said a macro name was fine, *because it says what will be sent*. That +held while a macro appeared beside one refused control. It stopped holding once every verb +on the toolhead sheet carried one under its name: a dialog for moving filament read as a +G-code console, and a muted row saying `ACE_BG_SET_HEAD` explained a refusal in a language +the reader has to already know. The gate button was the macro line itself. + +So the toolhead's verbs are names, the refusals are reasons, and the gate says **Enable +for this toolhead**. The wire has not gone anywhere — the trace pane carries every packet, +and `drive/ace-verbs.js` now asserts what was sent by reading `printer.gcodeLog`, which is +a better check than reading it off the screen ever was. + +### A swap is not something you do to a spool + +Offered on a bay, `Swap` reads as an operation on the filament — *swap this one*. What it +does is move a **toolhead** from one bay to another, and `ACE_SWAP_HEAD HEAD=n` says which +end it addresses. The bay sheet now says what is in the bay and what state the head is in; +the toolhead's sheet brings every bay to it labelled with what each would do, which is the +same choice made where the target is named. **Load stays on the bay** — there the bay is +the whole argument and there is no other end to it. + +### The mark disagreed with the click + +`View this filament` sat beside the edit **pencil**, and every slot on the four-slot form +wore the pencil whether its spool carried a tag or not. A tagged spool opens read-only — +that is the whole reason the row says *View* — so it wears the **eye**, the same pair the +bay marks have carried all along. The eye branch had never been exercised: the simulator +ships four untagged spools, so the check that covers it now sets `filament_detect.info` +first. + + +## Round fourteen — the printer answered in one second and the page waited twenty-five + +Reported with a screenshot: `Swap A4 → Toolhead 4` sat, then + +> Nothing started. The toolhead is waiting for filament. + +That message is true and useless. The printer had answered almost immediately, and +**Moonraker's console history had the answer all along**: + +``` +[com] ACE_SWAP_HEAD HEAD=3 ACE=0 SLOT=3 +[res] [multiACE] === Mid-print swap: HEAD 4 -> ACE 1 / Slot 4 (temp=270) === +[res] // multiace_event swap_imminent head=3 ace=0 slot=3 from_ace=0 from_slot=2 seq=1 +[res] // park extruder1 !!! +[res] // pick extruder3 !!! +[res] // multiace_event swap_failed head=3 ace=0 slot=3 status=error seq=2 +[res] !! Must home Z axis first: 229.300 250.000 277.000 [0.000] +``` + +`toolhead.homed_axes` was `"xy"`. **`ACE_SWAP_HEAD` parks and picks a head and does not +home first** — it is written for mid-print swaps, where the machine already is homed. The +U1's own feeder verbs home themselves, which is why round ten went looking for a homing +allowance and found the opposite problem here. + +### Three sources, and the page was reading none of them + +| source | said | the page | +|---|---|---| +| the `sw_SendGCodes` reply | `ok` | awaited it, then ignored it — correctly, since **an `ok` is not a yes** here | +| `ace.last_swap_result` | `{head:3, ace:0, slot:1, status:"error", ts:9893.3}` | **parsed since round eight and never read** | +| Klipper's `!!` channel | `Must home Z axis first` | not reachable, it was assumed | + +The second was the embarrassing one: `swapping`, `swapPhase` and `lastSwap` have been in +`parseAce()` the whole time, listed in the handover as *"still unread — no value for them +has ever been captured"*. A value was captured the moment something went wrong. + +So the wait now watches `last_swap_result` against the one that was there when it started, +and multiACE's own verdict ends it **in a second rather than in twenty-five**. + +### And the reason comes from the printer, not from a guess + +`GET /server/gcode_store` on Moonraker's HTTP port carries the console, and Moonraker +reflects the Origin there exactly as it does for the override store this page already +reads — checked: `Access-Control-Allow-Origin: http://127.0.0.1:13619`. So a failed verb +reports **the printer's own sentence**: + +> The printer stopped: Must home Z axis first: 229.300 250.000 277.000 [0.000] + +`lastPrinterError()` takes the last `!!` line and nothing else: `//` is Klipper's *note* +channel, and `// multiace_event swap_failed status=error` reads like an error while being +one of those. An unreachable Moonraker returns null and the panel falls back rather than +reporting the fetch as the fault. + +**No Home button in that dialog.** Homing is the Control panel's, a panel is handed its +own commands and nothing else, and the remedy is a control already on screen in the same +view. Guessing which verbs need a homed Z would also have been guessing — the unload in +the same console ran fine on `"xy"`. + +> **Round fifteen overtook this.** No verb on this panel needs a homed Z. The unload ran +> fine because it does not move Z, and neither does the load; only `ACE_SWAP_HEAD` does, +> and the panel does not send it any more. The error-reading built here stays, because a +> macro that declines still answers `ok`. + +### The rest of the macro lines + +Round thirteen stopped at the toolhead actions and left the dryer and the settings menus +showing G-code. They do not now. The dryer's preview used to print + +``` +ACE_DRY ACE=0 TEMP=45 DURATION=240 · ACE_SET_AUTO_DRY ACE=0 ENABLE=0 +``` + +and says **"Dries at 45 °C for 4 h, and not automatically."** The macro line was honest +while the numbers and the wire disagreed — the dialog offers HOURS and `ACE_DRY` takes +MINUTES, and one offering 4 would have dried for four minutes. That is a reason to get the +conversion right, which it is, and not a reason to make the reader check the arithmetic in +G-code. **Both of those facts are still asserted** — moved from the preview text onto +`printer.gcodeLog`, which is where they were always really about. + + +## Round fifteen — the swap that wanted a homed Z was the print's swap + +Reported: *the Device page, on swap, tells me to home Z first — odd, since a normal +load/unload without ACE does not need it, and HelixScreen can swap too.* + +Both halves of that were right, and together they were the answer. **The requirement was +never a property of swapping. It was a property of `ACE_SWAP_HEAD`**, which is the macro +a *print* uses, and the Device page was the only UI on this machine sending it from a +button. + +### Read out of the plugin, not inferred from the failure + +Round fourteen had the symptom and stopped at it: + +``` +[com] ACE_SWAP_HEAD HEAD=3 ACE=0 SLOT=3 +[res] // park extruder1 !!! +[res] // pick extruder3 !!! +[res] !! Must home Z axis first: 229.300 250.000 277.000 [0.000] +``` + +The park and the pick are XY and they succeeded. What failed is four lines into +`cmd_ACE_SWAP_HEAD` in multiACE's `klipper/extras/ace.py`: + +```python +self.gcode.run_script_from_command('G91') +self.gcode.run_script_from_command('G1 Z2 F600') +self.gcode.run_script_from_command('G90') +self.toolhead.wait_moves() +``` + +A 2 mm lift off the part before the unload — right for a mid-print swap, meaningless on an +idle machine, and refused outright by Klipper when Z is not homed. + +| macro | Z motion | needs a homed Z | +|---|---|---| +| `ACE_SWAP_HEAD` | `G91 / G1 Z2 F600 / G90` | **yes** | +| `ACE_LOAD_HEAD` | none | no | +| `ACE_UNLOAD_HEAD` | none | no | +| `ACE_BG_SWAP`, `ACE_BG_UNLOAD` | none in `ace_bg_swap.py` | no | + +So *"an unload ran fine on `"xy"` in the same console"* — round fourteen's reason for not +gating anything — was not luck. It is the whole rule, and reading the source rather than +the console is what turned one measurement into it. + +**multiACE guards this exact hop everywhere else it can run idle.** `_discard_wipe()` and +`_bg_pick_flow_check()` both open with a `homed_axes` check and bail with *axes not +homed - skipped*. `cmd_ACE_SWAP_HEAD` is the one place the same three lines are unguarded, +because a print is homed by definition. + +### The two neighbours had both already answered it + +Neither other UI that drives this plugin sends `ACE_SWAP_HEAD` for a swap someone asked +for: + +| | what it sends | in its own words | +|---|---|---| +| **multiACE's own dashboard** — `web/frontend/app.js`, `loadSlot` | `ACE_UNLOAD_HEAD HEAD=h` then `ACE_LOAD_HEAD HEAD=h ACE=a SLOT=s` | *"The print's OWN swaps (`ACE_SWAP_HEAD` from the gcode file) … do not go through these buttons"* | +| **HelixScreen** — `AmsBackendMultiAce::do_load_filament` | the same pair | *"the single-command `ACE_SWAP_HEAD` this backend does not use"* | + +The unload cannot be folded into the load, and that is why it is two commands rather than +one: `ACE_LOAD_HEAD`'s own guard *refuses* a head that already holds filament rather than +swapping for it. + +### What the panel does now + +`aceVerbs()`'s Swap carries **two lines**, and `sw_SendGCodes` already took a +newline-separated script — the feeder verb has sent two since round twelve. `withCmd()` +generalised from *one macro, optionally repeated with a `STAGE`* to *a list of steps*, and +each verb now also carries `macros`, every name it would put on the wire, because +`VERB_MACROS` is a gate on what is sent and checking one of two is not a gate. + +Nothing on screen moved: the DOM walker's dump is byte-identical either side of the change +apart from the trace pane's own rolling log. + +`ACE_SWAP_HEAD` is `NOT BUILT` in `check_coverage.py` with the reason written down, which +is the point of that table — the next person to notice that the macro taking a slot is not +being used will find out why before sending it. + +### The same question about `ACE_BG_SWAP`, asked and answered + +Reasonable next thought: if Swap is now two commands, should Background swap be +`ACE_BG_UNLOAD` + a background load? **No, twice over.** + +- **There is no `ACE_BG_LOAD`.** The whole family the machine registers is five: + `ACE_BG_SWAP`, `ACE_BG_UNLOAD`, `ACE_BG_SET_HEAD`, `ACE_BG_MOVE`, `ACE_BG_STATUS` + (`ace_bg_swap.py`, lines 90-99, and `data/ace-macros.json` says the same). The load half + exists only *inside* `ACE_BG_SWAP` — its help spells the sequence out: *"unload (if + loaded), then feed+grip+prime the target slot through the OPEN dock"*. There is nothing + to decompose it into. +- **The reason `ACE_SWAP_HEAD` had to go does not apply.** It was never "a swap should be + two commands"; it was one specific thing, that macro moving Z. `ace_bg_swap.py` contains + **no motion G-code whatsoever** — grepped for `G0`/`G1`/`G28`/`G91`/`G90`/`MOVE_TO`, zero + hits, and its single `run_script_from_command` is a `SAVE_VARIABLE` persisting the + enabled-heads list. It moves filament by appending to a **private trapq** on the parked + head's extruder stepper (`stepper.set_trapq` / `trapq_append`), which is what "background" + means here: it bypasses the toolhead's motion queue entirely, so it never reaches the + kinematics that raise `Must home Z axis first`. + +And its refusals are nothing like `ACE_SWAP_HEAD`'s silent `ok`: it raises on a head that +is not bg-enabled, not ACE-driven, not in head mode, already busy, already loaded from that +slot, or **is the active toolhead** — *"bg swaps are for parked heads"*. `ACE_BG_SWAP` +stays exactly as it is. + +### What went with it + +The mid-print machinery, all of which the pair does without: the Z hop, the XYZ/E position +restore, `_pause_for_recovery`, `last_swap_result`, and **`KEEP_HEAT` between the halves** — +so the nozzle cools and reheats where a print-time swap holds it. Neither neighbour passes +a temperature either, and picking one here would be inventing a number. + +`last_swap_result` is still watched by the blocking dialog, and only `ACE_SWAP_HEAD` writes +it: what the watch now catches is the **print's** swap failing underneath a verb someone +started, which dooms that verb too. Round fourteen's console read stays for the same reason +it was built — a macro that declines still answers `ok`, and Klipper's `!!` channel is the +only place the reason exists. + +### Checked + +`unit_jsc.py` asserts the pair, its order, and that a load on an empty head is still one +line — pure logic, no DOM. `drive/ace-verbs.js` asserts it **on the wire**, off +`printer.gcodeLog`, because the panel puts no macro name on screen. `drive/ace-verbs-real.js` +had gone stale against round thirteen's copy pass — it was reading `.verb-cmd` off rows +that no longer carry one — and now reads the model and prints the machine's live +`homed_axes` beside it, which is the state the swap used to care about. + +``` +171/171 unit_jsc.py 61/61 run_webkit --size 1920x1080 + 47/47 drive/ace-verbs.js 54/54 run_webkit (single column) + 75/75 drive/ace-panel.js 153/153 conformance_test.py + 8/8 drive/no-printer.js 0 unaccounted check_coverage.py +``` + +**Still unrun on hardware**: a swap sent as the pair, watched. The macros are the ones the +machine has and the two neighbours send, but this page has not put them out over a real +ACE — and the handover has said since round fourteen that one real swap is a person's +decision, not a suite's. + + +## Round sixteen — the eye and the pencil were asking the wrong question + +Reported: *the view and edit buttons are switched. Some filaments are read by RFID and +should not be changeable — they even read "From the spool tag" in the popup.* + +Both halves were true, and they were the same bug seen from two sides. + +### The sheet was always a form + +`editSlot()` drew three editable inputs and a **Confirm** for every slot, tagged or not. +Around it, four separate pieces of copy promised the opposite on a tagged one: the mark +was an **eye**, the menu row read **View this filament**, its hover read *"Read only — the +spool carries its own record"*, and the sheet headed a block **From the spool tag**. Every +word said reading; every button said form. + +### And "has a tag" is not the question + +The panel decided read-only from `f.tag` — `filament_detect.info[i].MAIN_TYPE !== "NONE"`. +The machine answers a different and better question itself, in two fields that have been +on the subscription since the beginning and were never read: + +| | | +|---|---| +| `print_task_config.filament_official[i]` | is the identity in use the spool's own record | +| `print_task_config.filament_edit[i]` | **may this slot be edited** | + +Read off 811002511261022618B3 on 2026-08-28, over Moonraker's HTTP port: + +``` +head 0 1 2 3 +filament_vendor Jayo Forshape NONE Kingroon +filament_type PLA PLA NONE PETG +filament_sub_type Marble "" NONE Basic +filament_exist true true false true +filament_official true FALSE false false +filament_edit false TRUE false TRUE +``` + +and `filament_detect`: + +``` +0 Jayo PLA Marble OFFICIAL true CARD_UID 04 7B F3 AD 7D 26 81 NTAG tigertag +1 Forshape PLA Silk OFFICIAL true CARD_UID 04 2C EE AE 7D 26 81 NTAG tigertag +2 — 3 — +``` + +**Head 2 is the case that settles it.** It carries a decodable physical tag and the machine +still says `filament_edit: true`, because the record in use has been overridden — the +sub-type in `print_task_config` is `""` where the tag says `Silk`. The tag rule marked it +read-only; the machine was happy to have it edited. So the mark was wrong in one direction +and the sheet in the other, on the same slot. + +The rule the machine states, on this sample: **editable ⟺ loaded and not official.** The +page does not re-derive that. It reads `filament_edit`, because that is the field the +shipped UI branches on too — its editor is `FilamentUnofficialWidget` and there is no +official counterpart in the bundle. + +> **Round eighteen qualified this.** Reading the permission was right; *following it +> alone* was not. `filament_edit` is a **latch** — a write clears `filament_official`, so +> one edit unlocks a tagged spool for good. The page requires the permission **and** the +> absence of a tag. + +### What changed + +- `filaments()` gained `official` and `editable`. `editable` is the machine's bit; the + fallback for a firmware that does not report it is the old tag rule, named as a fallback + rather than left as the rule. +- The mark, the menu row's label, its hover and **what the click opens** are now one + decision. Read-only means no inputs at all and a single **Close** — not disabled fields, + because there is nothing to type. +- The **RFID badge keeps its own question.** A chip on the spool is a fact about the spool; + whether the record may be edited is a fact about the slot. Head 2 wears the badge and + takes the pencil, and that is not a contradiction. +- The read-only sheet shows **the record in use** above **what the spool claims** — two + readings, because on head 2 they differ. + +### This machine is running the Extended Firmware + +`filament_detect` here carries `CARD_TYPE` and `TAG_FORMAT: "tigertag"`, and **neither +string exists anywhere in the shipped Flutter bundle** — they are +[SnapmakerU1-Extended-Firmware](../../../../SnapmakerU1-Extended-Firmware/docs/design/filament_detect.md) +additions. That firmware also exposes `POST /printer/filament_detect/set`, which writes the +**tag record itself** (`VENDOR`, `MAIN_TYPE`, `SUB_TYPE`, `RGB_1`, `ALPHA`, +`HOTEND_MIN_TEMP`, `HOTEND_MAX_TEMP`, `BED_TEMP`, `CARD_UID`, `SKU`) and mirrors a full +update into `print_task_config`. HelixScreen already uses it — `AmsBackendSnapmaker:: +set_slot_info` POSTs there. **Not adopted here**: it would make an official head editable, +which is a decision about what this panel is for rather than a bug in it. + +### The ACE side is a different subsystem and did not move + +Measured in the same session: every ACE bay reads `rfid: 0`, `head_tag_seen` is `{}`, and +all four bays are named in multiACE's override store (`0_0`…`0_3`, Kingroon/Generic PETG +Basic). So on this machine **no bay is ever read-only** — `PROV.rfid` is unreachable for a +bay — and every bay is `override`, wearing the pencil. + +**So every bay wears the eye now.** The pencil was a promise the panel cannot keep: +**naming a bay from the panel is not built** (tier 2b — `ACE_SPOOL_ASSIGN`, since +multiACE's own `POST /api/slot-override` is behind the missing CORS header), and three of +the four bays wore one over nothing. The PROV *word* is unchanged, because multiACE's +`rfid → override → derived` is the thing worth knowing about a bay; only the glyph stopped +varying with it. When 2b lands, `override`, `derived` and `unknown` get the pencil back and +`rfid` never does. + +And editing an **ACE-fed head** in Materials Setting is a trap worth knowing about: it +writes `print_task_config`, and the next `ACE_LOAD_HEAD` re-pushes the bay's identity over +it via `SET_PRINT_FILAMENT_CONFIG`. The bay is where that edit belongs — which is the other +reason not to imply the head's form is the way to name one. + +### Decided, not defaulted + +Three forks were put to the reporter rather than guessed: + +| | | +|---|---| +| A filament the machine calls its own | **stays read-only.** Matches the shipped UI, which has no editor for official filament. `POST /printer/filament_detect/set` is reachable on this machine and was **not** adopted — writing the spool's own record is a different product decision | +| What the form may input | **type, vendor, colour** — the three `UpdateMachineFilamentInfo` is measured to accept. `filament_sub_type` is carried by the machine and shown by the panel, and is **not** writable from here: whether that key round-trips has never been measured | +| Naming an ACE bay | **not from this panel yet.** Hence the eye above | + +### Checked + +`unit_jsc.py` holds the model to the measured payload — that both tagged heads read a tag, +that only one is official, that the permission is the printer's, that the overridden +tagged head is editable, and that an absent `filament_edit` does not read as "edit +anything". `drive/ace-panel.js` reaches both states with the **tag held constant**, so the +only thing varying is the thing under test, and asserts the sheet each one opens. + +``` +177/177 unit_jsc.py 78/78 drive/ace-panel.js 61/61 run_webkit 1920 + 47/47 drive/ace-verbs.js 54/54 run_webkit (1 column) 153/153 conformance_test.py +``` + + +## Round seventeen — the tag stopped showing, and the card stopped repainting + +Reported, straight after round sixteen: *the Forshape PLA is also an RFID tag, but that +does not show.* + +Correct, and it had never shown on this machine. Two separate defects behind it. + +### The RFID badge lives in a shape this printer never draws + +The green `RFID` word is `renderSlots()`'s, in the **four-slot form** — the shape a printer +with no `ace` object gets. Measured by rendering the reported payload and walking the DOM: + +``` +the panel drew 4 toolhead cards (ace.present = true) +shape per card: T1=feeder T2=feeder T3=feeder T4=cabinet +the four-slot form is drawn: false <- where the green RFID word lives +``` + +Heads 1–3 are on their **stock feeder** (`head_feeder {0,1,2 true}`) and are drawn as a +feeder box inside a card. That box has only ever carried the provenance mark. So a tagged +feeder spool on an ACE-equipped printer had no tag drawn anywhere. + +It was *masked* until round sixteen: the eye was driven by `f.tag`, so a tagged head got +one for free. Pointing the eye at the machine's `filament_edit` is right and measured — and +on a head whose tagged record has been overridden it leaves a pencil and nothing at all +saying a tag is there. Exactly the head that motivated round sixteen. + +**Two marks, two questions, mirrored about the same roll.** `.ace-tag` is the contactless +arcs in `#16A34A` — the colour `.slot .slot-tag` already uses for this fact — absolute like +`.ace-prov` so a spool with a tag and one without occupy the same box. A bay is 62 px and +its chip is already an ellipsis away from truncating a material name; nothing there may +move when a tag appears. Asserted: mirrored to 0.5 px, same height, no overlap, and the +body still 456. + +``` +marks per card (edit-mark/tag-mark): T1=eye/tag T2=pencil/tag T3=pencil/- T4=eye/- +``` + +An ACE **bay** gets the same mark from multiACE's own `source: "rfid"`. No bay on this +machine has one (`rfid: 0` on every raw slot), so that arm is drawn from the model rather +than from a sighting. + +### And the card would not have repainted for it anyway + +The check failed first for a better reason than the missing mark: `cardSig()` — the +signature `keyedList` reconciles on — carried `[material, subType, vendor, color]` and +**neither the tag nor the edit permission**. Neither of those moves any of the four, so: + +- a tag arriving repaints nothing. The reader is asynchronous; it lands seconds after the + filament does. +- the machine flipping a slot between its own record and an overridden one repaints + nothing either — so round sixteen's eye/pencil would have been correct only from the + first paint. + +The card kept whatever pair it was built with. This is the failure mode the panel has hit +before, in this same function, and the comment above `channel_state` in it says so: *a card +whose signature omits something it draws simply never repaints for it.* Two more fields +are in it now. + +What caught it: a check that applied a tag to an **already-drawn** panel and found the +menu had it (rebuilt on every click) while the card did not. A check that set the state up +front and then looked would have passed on a broken panel. + +``` +177/177 unit_jsc.py 87/87 drive/ace-panel.js 61/61 run_webkit 1920 + 47/47 drive/ace-verbs 54/54 run_webkit 1-column 153/153 conformance 8/8 no-printer +``` + + +## Round eighteen — the permission was real, and it was a latch + +Reported against the real machine: *the Forshape PLA has an RFID icon and is still +editable, which it should not be.* + +Round sixteen read `print_task_config.filament_edit` and followed it. The field means what +its name says — that much was verified, not assumed, and the verification is worth keeping +because it also found the thing that was wrong. + +### What the field actually is + +Not a guess from a name and a four-point correlation this time. The U1's own +`print_task_config.py`, as carried in the Extended Firmware's `13-patch-rfid` overlay: + +```python +allowed_edit = False +if self.print_task_config['filament_exist'][ch]: + if self.print_task_config['filament_official'][ch] == False: + allowed_edit = True +tmp_filament_edit[ch] = allowed_edit +``` + +and the other half of the same gate, enforced in `SET_PRINT_FILAMENT_CONFIG`: + +```python +if tmp_print_task_config['filament_official'][config_extruder] and bool(force) == False: + raise gcmd.error("[print_task_config] filament_config, official filament, not configurable!") +``` + +So `filament_edit` **is** `allowed_edit`, and the refusal behind it is real rather than +advisory. Reading it was correct. + +### And why following it alone was not + +Eleven lines further down the same function: + +```python +tmp_print_task_config['filament_official'][config_extruder] = False +``` + +**Every write clears `official`.** So the permission is a *latch*, not a statement about +the spool: edit a tagged slot once and `filament_edit` stays `true` until the tag is read +again — which happens on the next load. Head 2 has been sitting in that state, which is +why it reads `official: false, edit: true` with an NTAG physically present and +`filament_sub_type` `""` where the tag says `Silk`. + +Following the latch means offering to type over a spool whose record will revert on its +next load, and drift further from the tag every time someone does. + +### The rule + +**The page requires the machine's permission AND the absence of a tag.** Stricter than the +permission, never looser — `allowedEdit` is still required, so an official slot and an +empty one stay closed for the machine's own reason, and the panel never offers an edit +`SET_PRINT_FILAMENT_CONFIG` would refuse. + +| head | tag | `allowedEdit` | panel | +|---|---|---|---| +| 1 Jayo | yes | false — the machine's own record | **reads** | +| 2 Forshape | yes | **true** — latch left open by an old write | **reads** | +| 3 empty | — | false | nothing to edit | +| 4 Kingroon (ACE-fed) | no | true | **edits** | + +`filaments()` carries both: `allowedEdit` is the machine's, verbatim; `editable` is this +page's, and the comment on it says which is which. Splitting them is the point — a single +field would have hidden that the panel is making a choice the machine did not. + +### What that leaves the tag mark doing + +Both tagged heads read now, for two different reasons, and the marks say which: the eye is +the refusal, the tag mark is *why this one* — the spool's own record, rather than the +machine having nothing to edit. On an untagged head the eye can only mean the latter. + +``` +marks per card (edit-mark/tag-mark): T1=eye/tag T2=eye/tag T3=pencil/- T4=eye/- +``` + +``` +180/180 unit_jsc.py 89/89 drive/ace-panel.js 61/61 + 54/54 layout +153/153 conformance 47/47 drive/ace-verbs 8/8 no-printer +``` + +### Worth keeping + +The three rounds ran: mark → machine's field → machine's field, qualified. The middle step +was not wasted — it is what produced the `official`/`edit` reading and the read-only sheet, +and both survive. What it got wrong was treating a permission as a description. **A gate +that a write can open is not a fact about the thing behind it**, and this page had no way +to notice that from the four values it had measured. The firmware source did. + + +## Round nineteen — the mode switch blacked the spools, and the machine meant to + +Reported: *switching between ACE modes on `--real` switches filaments, blacks them.* + +Both halves are real, and only one of them is a bug here. + +### The machine wipes the feeder heads on purpose + +`SET_ACE_MODE` calls `ACE_RUN_MODE_SWITCH`, and entering **head** mode runs, for every +head on its stock feeder: + +```python +for h in range(4): + if self.head_is_feeder(h) and not self.head_is_manual(h): + self._clear_filament_display(h) +``` + +which is + +``` +SET_PRINT_FILAMENT_CONFIG CONFIG_EXTRUDER=n FILAMENT_TYPE="" \ + FILAMENT_COLOR_RGBA=00000000 VENDOR="" FILAMENT_SUBTYPE="" +``` + +So the identities really do go away — that is multiACE's decision, not the panel's, and +the filament stays physically in the head while it happens. (It also clears +`filament_official`, since every `SET_PRINT_FILAMENT_CONFIG` write does; round eighteen.) + +**"Switches filaments" is also partly real**: the mode decides `head_feeder`, so which +card is a cabinet and which is a feeder box genuinely changes. + +### The blacking was ours, in one line + +`00000000` is **RRGGBBAA with alpha 00** — the machine saying *no colour*. `cssColor` threw +the alpha away: + +```js +if (/^[0-9a-f]{8}$/i.test(t) || /^[0-9a-f]{6}$/i.test(t)) return '#' + t.slice(0, 6)… +``` + +so it came back `#000000` and got painted — on the spool disc **and** on the tube, which +takes its core colour from the same value. Four black spools. + +The alpha is read now, in both forms, and only alpha **zero** is an absence: opaque black +is `000000FF` / `0xFF000000` and still returns `#000000`, because black filament is the +commonest there is. A six-digit value has no alpha to read and is taken as written. + +### And then it would have gone wrong the other way + +With the colour correctly absent, `feeder()` fell through to the checkerboard — which is +this page's word for **empty**, and the filament is still in the head. That box had only +two states where a **bay** has always had three: + +| | disc | chip | +|---|---|---| +| named | its colour | the material | +| occupied, not named | `#B7BDC6` | `?` | +| empty | checkerboard | `/` | + +It has the same three now, drawn with the same two values a bay uses. `card()` asks +`headLoaded()` once and hands the answer to both the box and the toolhead marker — the +occupancy question cannot be answered from the identity that was just wiped, which is the +whole reason this state exists. + +Same shape as round seventeen: a guard the four-slot form has (`if (f.loaded)` before +painting the dot) that the ACE card's box never had. + +### The simulator could not show this state + +`filament_vendor`, `filament_type` and `filament_color_rgba` were three hard-coded literals +in `snapshot()`, so no drive script could put a head in the wiped state at all. They are +`printer` state now, with `clearFilamentDisplay(head)` doing exactly what multiACE does, +and `filament_color` is **derived** from the rgba rather than being a second literal — +which is a stronger guarantee than two literals agreeing, because they cannot drift. +`conformance_test.py` still holds the shape: bare `RRGGBBAA` with no `#`, and an ARGB +integer with the alpha in the top byte. + +### Checked + +`unit_jsc.py` holds `cssColor` to the alpha rule in both forms, including that opaque and +partly-transparent colours survive. `drive/ace-panel.js` wipes a feeder head the way the +machine does and asserts the drawing is neither black nor empty, that the card **repaints +at all** (which needs the colour in `cardSig`), and that it comes back when the machine +names it again. It finds the feeder head rather than assuming one — an earlier block in +that script switches Toolhead 1 onto ACE A. + +``` +185/185 unit_jsc.py 153/153 conformance 97/97 drive/ace-panel.js + 61/61 + 54/54 layout 47/47 drive/ace-verbs 8/8 no-printer 0 unaccounted +``` + +### It was still wrong, and the identity was never gone + +Round nineteen drew the wipe honestly and stopped there. The reporter cycled +normal → multi → head and sent a screenshot: two toolheads that had shown their filament +were a grey `?` and a checkerboard. *"The issue is not mitigated yet."* + +Right, because **`_clear_filament_display` wipes `print_task_config` and does not touch +`filament_detect`**. Read off the machine in that exact state: + +``` +head 0: ptc vendor='' type='' rgba='00000000' exist=True | TAG Jayo PLA Marble OFFICIAL +head 1: ptc vendor='' type='' rgba='00000000' exist=True | TAG Forshape PLA Silk OFFICIAL +head 2: ptc vendor='' type='' rgba='00000000' exist=False| TAG none +head 3: ptc Kingroon PETG Basic exist=True | TAG none +``` + +The identity was sitting in the other object the whole time. So `filaments()` falls back to +the spool's own record when the working copy has none — which is **multiACE's own +precedence one level up** (`rfid → override → derived`), applied where it had not been. The +tag is only a fallback, so a record someone set deliberately still wins while it exists, +and `fromTag` says which was used. + +`loaded` went with it: it read `!!type`, so a head the wipe had blanked reported itself +unloaded even with `filament_exist: true`. Whether a slot HAS filament and whether anything +NAMES it are two questions, and only the first is `filament_exist`'s. + +**Run against the machine, not reasoned about.** `--real`, read-only, nothing sent: + +``` +head 0: type="PLA" vendor="Jayo" fromTag=true loaded=true tag=true editable=false +head 1: type="PLA" vendor="Forshape" fromTag=true loaded=true tag=true editable=false +head 2: type=null fromTag=false loaded=false +head 3: type="PETG" vendor="Kingroon" fromTag=false loaded=true editable=true +T1: feeder chips=[PLA] disc=rgb(244,67,54) prov=eye tagmark=yes "Stock feeder: PLA · Jayo" +T2: feeder chips=[PLA] disc=rgb(255,255,220) prov=eye tagmark=yes "Stock feeder: PLA · Forshape" +T3: feeder chips=[/] disc=checkerboard prov=- tagmark=- "nothing detected" +T4: cabinet chips=[PETG,PETG,PETG,PETG] prov=eye +``` + +The occupied-and-unnamed drawing from the section above is still right and still reachable — +it is what a wiped head with **no** tag gets, which is every machine whose feeder spools +carry none. Both branches are asserted now, the second by taking the tag away as well. + +### Two notes for next time + +- **A duplicate `const` in a drive script is silent.** The new block re-declared `before`, + which is a `SyntaxError`, so `evaluate_javascript` never ran the file and the harness + reported only *"the driving script never reported"* after its full 300 s. Worth knowing + before debugging the wrong thing. +- **A failed assert in an edit script rolls back the whole file.** The `card()` and + `feeder()` changes were one write; the second pattern did not match, so neither landed — + and `feeder()` then took an argument nothing passed, which reads exactly like a logic + bug. The debug script that dumped the state, not the check that failed, is what found it. + diff --git a/docs/u1-webui/02-device-page/09-restructure.md b/docs/u1-webui/02-device-page/09-restructure.md new file mode 100644 index 00000000000..8211e4d9480 --- /dev/null +++ b/docs/u1-webui/02-device-page/09-restructure.md @@ -0,0 +1,435 @@ +# The restructure, pass by pass + +The Device page was reconstructed a panel at a time, and it showed: by the end of the +first stretch of work each addition landed in five places - markup in `index.html`, +classes in `device.css`, a renderer somewhere in one shared `ui.js`, a render call plus a +slice of module state in `app.js`, and a click handler in `wireChrome`. Nothing tied the +five together and nothing listed them, so *what is on this page, what does each part +read, and what can each part do* had no answer short of reading all of it. + +Seven passes fixed that. Each is a separate commit, each was verified against the real +printer, and each is written up below with what it found - because in almost every case +the structural question found bugs that using the page had not. + +## Flutter was weighed first, and rejected + +The shipped bundle is Flutter, so the obvious question was whether to join it. + +| | | +|---|---| +| **What you would ship** | 36 MB, CanvasKit - 19 MB of WASM/WebGL plus a 5.1 MB `main.dart.js` | +| **The build step** | A Dart/Flutter SDK in a CMake tree that has neither | +| **The working method** | Everything proven here came from *edit → reload → measure on hardware*, because `build/resources` is a symlink and the server sends no cache headers. Flutter puts a compile between every hypothesis and its test, on a loop where one measurement is a 31-second toolchange | +| **The test rig** | CanvasKit paints to a ``. There is no DOM. `run_webkit.py`'s findings are DOM assertions and `unit_jsc.py` executes the modules directly - all of it goes | +| **Cross-platform risk** | Orca renders in three engines (WebView2, WKWebView, WebKitGTK). Plain DOM is the common denominator; WASM+WebGL is the fragile one | +| **What it is for** | The reconstruction exists as readable evidence that the protocol docs are right. A compiled Dart bundle is not readable evidence | + +The three things Flutter would genuinely buy - composable widgets, a state-management +story, keyed reconciliation - were all reachable without leaving the DOM, and the passes +below are how. + +## Where it ended up + +``` +js/ + registry.js the two destinations, and which panels each has + shell.js app.js page-commands.js + core/ dom render pending store session connection overlay diag mock thumbs + widgets/ rail rail-commands trace art format + views/ + device-control/ + camera/ camera-panel.js camera-view.js camera-commands.js + control/ control-panel.js control-view.js control-commands.js + task/ task-panel.js task-view.js task-commands.js + filament/ filament-panel.js filament-view.js filament-commands.js + storage/ + storage/ storage-panel.js storage-view.js storage-commands.js + fault/ fault-panel.js fault-view.js fault-commands.js +``` + +`app.js` went from 1,579 lines to 383; `index.html` from 143 to 42; `ui.js` from 1,359 to +nothing. Tests went from 140 to 150 conformance checks, 93 to 134 in `unit_jsc`, and 17 to +23 in the browser. + +--- + +### Structure, pass one: the page has an overview now (2026-08-25) + +The page had grown a panel at a time and each addition landed in five places — markup in +`index.html`, classes in `device.css`, a renderer in `ui.js`, a render call plus a slice +of module state in `app.js`, and a click handler in `wireChrome()`. Nothing tied the five +together and nothing listed them, so "what is on this page, what does each part read, +what can each part send" had no answer short of reading all of it. + +**Flutter was weighed and rejected.** The shipped bundle is 36 MB of CanvasKit — WASM and +WebGL painting to a ``. Adopting it would mean a Dart/Flutter SDK build step in a +tree that has neither, an end to *edit → reload → measure on hardware* (which is where +every finding above came from), and the loss of the whole test rig: `run_webkit.py`'s +checks are DOM assertions and `unit_jsc.py` executes the modules directly. Orca renders in +three different engines, and plain DOM is the common denominator, not the fragile one. +The three things Flutter would genuinely buy — composable widgets, a state-management +story, keyed reconciliation — are all reachable without leaving the DOM. + +[`device_page/js/panels/registry.js`](../../../resources/web/device_page/js/registry.js) +is now that answer. Every panel declares `id`, `title`, `view`, its header controls, what +it `reads` and what it `sends`, plus `mount`/`update`. `shell.js` builds the page from it; +`index.html` is down to 42 lines of shell and `wireChrome()` to the device menu alone. +Adding a panel is one file plus one line in `PANELS`. + +**`reads` and `sends` are the point.** `07-parity.md` called the command surface complete +on the strength of `CMD.NAME` appearing in source, which proves a command is *mentioned* — +not that a control exists to issue it. `check_coverage.py` now asks both questions, and +the second one immediately found what the first could not: + +- **`sw_BedMesh_AbortProbeMesh` was reachable by nothing.** `handlers.abortBedMesh` had + existed since the panel was built, with no call site. It cannot be wired either: + `bed_mesh` is not in `SUBSCRIBE_OBJECTS` and no activity label mentions probing, so an + abort button has no state to appear with. The handler is gone and the command is in + `EXCLUDED` with that reason. +- **`handlers.showFiles` is dead too** — the comment calls it "the one useful action from + an idle job card" and nothing calls it. It sends no command, so coverage cannot see it; + it is the next pass's job. +- Seven more were reachable but undeclared, and writing the declaration is what surfaced + them: the Control header's Refresh re-reads four objects that are not the Control panel's, + and the storage card's details dialog reaches `GetFileListPage`, `MachineFilesThumbnails` + and `DownloadMachineFile`. +- Declaring `sends` also caught a claim that was simply false: storage listed + `DELETE_MACHINE_FILE`, which no code issues. + +**Verified behaviour-preserving, not asserted.** A drive script dumps every element's tag, +id, classes and `data-*` across both destinations and the trip back; run against a +worktree at the previous commit and against this one, the two dumps are 949 lines each and +differ only by the `id` the shell puts on the Control panel's body so `paint()` can address +it. All three suites still pass: 140/140 conformance, 93/93 `unit_jsc`, 17/17 `run_webkit`. + +**Worth knowing:** `run_webkit.py --shots` has been writing **blank PNGs** all along in +this environment — EGL finds no driver under WSL, so the snapshot surface paints nothing. +The two files compare byte-identical at 8,515 bytes before and after this change. The DOM +assertions are what confirm anything; the images never did. + +**What is still ad hoc, and what pass two is for.** Three things, all of them the same +shape — state with no single home: + +- **State lives in four places.** `MachineState` (correct), sixteen module-level `let`s in + `app.js`, two *in the renderer* (`ui.js` holds `activeTool` and `head`, and + `renderControlMain` reassigns `head` then re-calls itself), and the DOM itself + (`root.__state`, `dataset.built`, `dataset.sig`, `dataset.pend*`). `ctx` in `app.js` is + the seam: it exposes the page's state through one door with live getters, so moving it + into a store is a change to that object and nothing else. +- **Four update disciplines answer one question.** The status card builds once and patches; + storage guards on a signature and rebuilds; control, task, filament, camera and fault + clear `innerHTML` on every frame, about once a second. Every focus, scroll and + popover-anchor bug is downstream of that, and each was fixed by inventing a new guard on + the spot. +- **The pending-until-confirmed model existed twice and was missing once. It is one + thing now.** *The request was stored in the thing that mirrors the machine* is written + up above for tool selection and for temperature, solved two unrelated ways: a + poll-until-confirmed dialog in `app.js`, DOM datasets and timers in `ui.js`. The LED + switch had neither — `ui.js` read `root.__state.led.on` for the click and the render + loop rewrote `aria-checked` from `led.on` every frame, so the mirror won until the + printer echoed. + + Driven against the machine, toggling the chamber light and putting it back, three runs + each side: + + | | printer echoed after | switch reverted while waiting | + |---|---|---| + | before | 236 / **2029** / 620 ms | **2 of 3** | + | after | 1219 / 214 / 1024 ms | **0 of 3** | + + Run 1 of the "before" set is why one measurement is not evidence: it echoed in 236 ms, + before a repaint landed, and looked clean. The trace after the fix shows the mechanism + working rather than the outcome only — `switch=true machine=false` at 102 ms, the + request on screen while the mirror still disagrees. + + [`pending.js`](../../../resources/web/shared/js/pending.js) is that one mechanism. A + request has three ends — confirmed, refused, lost — and the third is the one that + matters: *an instant `ok` is indistinguishable from success*, so a silently-ignored + command has to be timed out and reported rather than left looking applied. It touches + no DOM, so `unit_jsc.py` tests it directly against an injected clock: 12 new checks, + including that `200` and `"200"` are the same answer (the wire carries either) and that + a late refusal cannot overwrite what the user has moved on to. + + The click reads *what is shown* rather than what the machine says, so clicking twice + inside the echo window undoes the first click instead of re-sending it. + +- **A throwing panel is no longer silent.** Found the hard way during this pass: `jogWheel` + still referenced a module variable that had moved, and the whole paint runs inside one + `requestAnimationFrame` callback — so the page simply stopped having a motion column, + with nothing in the console. `run_webkit.py`'s 17 checks all passed, because they cover + the temperature rows and not the wheel. What caught it was the structural DOM dump. + `paint()` now catches per panel: the one that failed says so on the page and logs once, + and the others still paint. + +- **`ui.js` has no module-level mutable state left.** `activeTool` is the user's choice, + not the machine's, so it is page state on `ctx`; `head` existed only to survive a + render function calling itself argument-less, and is read live. `root.__state` — a + snapshot of six state objects written onto the DOM every frame for the popovers' + closures to find — is gone with them, since the context is the same idea with one copy + instead of one per frame. + +### Structure, pass two: one pending model, one render discipline (2026-08-25) + +Two of the three things the last section left open are done. The third — the sixteen +module-level `let`s in `app.js` — is next; `ctx` is already the door they all go through. + +**[`pending.js`](../../../resources/web/shared/js/pending.js).** See the LED table +above for the measurement. Three controls had the same bug and two different fixes +between them; there is one mechanism now, it touches no DOM, and `unit_jsc.py` tests it +directly against an injected clock. + +**[`render.js`](../../../resources/web/shared/js/render.js).** Four disciplines +answered *the state changed, what do I do with the DOM* — and they were spelled with four +different attributes, `data-built`, `data-sig`, `data-state` and `data-code`, which is +what made them hard to see as one question: + +| panel | was | now | +|---|---|---| +| status card | `data-built` on toolhead count, then patch | `rebuildOn` | +| storage | `data-sig`, rebuild whole, `scrollTop` saved by hand | `rebuildOn` on shape + `keyedList` | +| camera | `data-state`, rebuild whole | `rebuildOn` + patched message | +| fault | `data-code`, rebuild whole | `rebuildOn` | +| task, filament, control | `innerHTML = ''` **every frame** | `rebuildOn` / `keyedList` + patch | + +The jog wheel's twenty-four SVG sectors were being thrown away and recreated about once a +second, under whatever the pointer was over. The task card's buttons likewise. Neither is +rebuilt now unless its shape changes. + +Three bugs fell out of writing it down, none of them from using the page: + +- **The storage guard hashed the item *count*.** A list whose contents changed without + its length did not repaint — a print going `in_progress` → `completed` kept its old + badge. `keyedList` reconciles by key and rebuilds a card when its own signature + changes, so the guard has nothing left to get wrong. +- **A recurring fault never showed again.** The banner compared `data-code` on the way in + and never cleared it on the way out, so a fault that cleared and came back matched its + own stale key, took the early return, and stayed hidden while the machine was + reporting it. +- **`.stor-foot[hidden]` did nothing** — a class rule with `display` outranks the UA + stylesheet's `[hidden] { display: none }`. This is the second time it has cost an hour; + `.fault` was the first. Both are now pinned by a conformance check rather than + re-learned a third time. + +And one I introduced in pass one and found by using the page: **`is-active` moved in the +click handler**, which is almost right. Anything that changed the selection without a +click left the wrong tab lit — and the fix below does exactly that. A header belongs to +no renderer, so it now syncs from state on every paint. + +**`handlers.showFiles` is live.** The dead handler the reachability check surfaced last +pass: its own comment calls it "the one useful action from an idle job card", and the +card had a *disabled* play button titled "Nothing to start here" instead. Driven against +the simulator: cancel the print, and the card offers one enabled button, "Choose a file +to print", which opens Storage on print files with the right tab lit. + +**Verification.** 149/149 conformance, 106/106 `unit_jsc`, 17/17 `run_webkit`, coverage +clean. Against the pass-one DOM dump every difference is bookkeeping — the `key`, `sig` +and `built` attributes the primitives keep, plus a `.stor-foot` that is present and +hidden rather than absent. No content moved. + +**A note on what caught what.** `run_webkit.py`'s 17 checks passed while the page had no +motion column at all: they cover the temperature rows, and a `ReferenceError` inside the +single `requestAnimationFrame` callback silently truncated the rest of the paint. The +structural DOM dump is what found it, and `paint()` now catches per panel so the failure +is on the page instead of nowhere. Keep the dump in the loop — it is the only check here +that asks *what is on the page* rather than *is this one thing right*. + +### Structure, pass three: the page state has one home (2026-08-25) + +The last of the three. What used to be sixteen module-level `let`s in `app.js` is +[`store.js`](../../../resources/web/device_page/js/core/store.js) — one declared object, with a +line of prose on each member saying what it is for. Eleven of the sixteen were written +from more than one place, and a panel reading `cam.error` gave no clue where `cam` was +set. + +A panel now gets one `ctx` carrying three stores, and there are three because they answer +three different questions: + +| | | +|---|---| +| `ctx.state` | what the **machine** says. A mirror, not a memory | +| `ctx.store` | what the **page** knows — which view, which tab, what it has fetched | +| `ctx.pending` | what has been **asked for** and not yet confirmed | + +The third exists separately for the reason this whole restructure keeps running into: a +request stored in the mirror is overwritten by the next push. + +Deliberately **not** an observable store. Making every write notify is the obvious next +step, and taking it would mean two ways to ask for a repaint alongside `render()` — one +page with two mechanisms for one job is what this work exists to undo. If explicit +`render()` turns out to be forgotten in practice, a Proxy is a change to `createStore` +and nothing else. + +**The rename broke the connect path, and the simulator did not notice.** 112 call sites +moved; the script that moved them protected string literals, which also protected the +`${...}` inside template literals — so `` `${deviceLabel(device)} — not connected` `` +kept a name that no longer existed, `boot()` threw a `ReferenceError`, and the page came +up never connecting to anything. + +The reason the browser suite stayed green is worth carrying forward: **that line is on +the not-connected branch, and the simulator's device reports connected**, so the +simulator never executes it. What caught it was `--real`. What would also have caught it +is `--real --device-ip 192.0.2.1`, which forces the same branch with no printer involved +— that flag is worth running on any change that touches the device record, not only when +testing the nothing-there path. + +Both are verified now: **26 objects and live telemetry** against the U1, and a clean +`No route to host` down the unroutable path. + +### Structure, pass four: the session is its own thing (2026-08-25) + +Having a printer on the other end of the bridge — the connect path, whether the machine +is still there, the retry ladder, the heartbeat and the state stream — is 319 lines that +no panel reads, which is exactly why it could leave `app.js`. It is +[`session.js`](../../../resources/web/device_page/js/core/session.js) now, plus a 37-line +`diag.js` for the `?diag=1` beacon. + +Dependencies are **injected rather than imported**, because they run the other way: the +session asks the page to repaint and to say things, and a module that imported `app.js` +to do it would be a cycle. It owns four things nothing outside it reads — `engineId`, +`subscription`, `connecting`, `heartbeat` — because a socket, a subscription id and two +timer handles are not state a panel can have an opinion about. + +Verified where it matters: **26 objects and live telemetry** against the U1, and down the +unroutable path the retry ladder visibly makes its second attempt on a new client id. +149/149 conformance, 109/109 `unit_jsc`, 17/17 `run_webkit`, DOM unchanged. + +Eight conformance checks moved with the code. They grep source text for a decision, so +they name a file — the same repointing pass one needed. Worth noting as a property of +this suite rather than a nuisance: it pins *decisions* to *places*, so moving a decision +is meant to be a visible edit. + +### Structure, pass five: a panel is handed its own commands and nothing else (2026-08-25) + +The last undifferentiated thing was `handlers` — 43 functions, 687 lines, one flat bag +given whole to every renderer. It is `js/commands/.js` now, one module per panel +plus `page.js` for the two things every panel shares and `device.js` for the rail's menu, +which is about Orca rather than about the printer and so has no panel to belong to. + +`shell.js` gives each panel `commandsFor(id)` — its own module merged with the page-level +one — through a prototype-delegating context, so `state`, `store` and `pending` stay one +live object and only the commands differ. **Reaching for another panel's command is now a +`TypeError`.** + +**The tooling checks facts instead of promises.** The `sends` array each panel used to +declare is gone. `check_coverage.py` reads the `CMD.` references out of the module a +panel is *actually handed*, so referencing a command is the only way to claim one: + +``` +camera 2 control 9 device 13 fault 1 +filament 1 page 5 storage 12 task 4 elsewhere 29 +``` + +Two things fell out of that: + +- **`OWNED_ELSEWHERE` halved.** Fifteen of its entries said "rail device menu", which was + true and hand-written; `commands/device.js` says the same thing by *being* the module + that menu is handed. +- **A new check asks the question directly**: is every handler a panel is given actually + called? `check_coverage` found `abortBedMesh` because it named a command; `showFiles` + named none, so nothing could. Verified by planting a dead handler — it fails and names + it. + +**Two failures worth recording.** The page came up blank at first: `session` is built +before the command modules and holds a reference to the merged bag, so the `const` for it +was in its temporal dead zone. And a parse check over every module reported nine +"failures" that were all artefacts of the stripper — worth knowing that a green parse +sweep is not a green *load*, because what actually broke was resolution and ordering, not +syntax. + +**Verified.** 150/150 conformance, 109/109 `unit_jsc`, 17/17 `run_webkit`, coverage +clean, DOM unchanged, and against the U1: 26 objects with live telemetry, and the chamber +light echoing at 1853 ms and 812 ms with **no revert** — the pending model still holding +at nearly two seconds. + +**Where this leaves the page.** `app.js` is **375 lines** — startup, `send`/`setpoint`, +the render loop and the device menu — down from 1,595. The largest file is `ui.js` at +1,359, and it is the obvious next thing: it is still one module rendering six panels, and +each panel's renderer wants to move into the panel module that already owns everything +else about it. That is a move rather than a design decision, which is why it can wait. + +### Three reported from the running page (2026-08-25) + +**The Filament panel showed ten slots where there are four**, cycling 3-4-1-2. That one +was mine, from the render pass: `keyedList` dropped a node whose signature had changed +out of the leftovers map *and* replaced it, so the sweep at the end could no longer see +it and it stayed in the DOM. One leaked node per content change per repaint. Driven +against a real DOM: + +``` +before 4 -> 5 -> 7 -> 11 -> 13 [a3 b3 c3 d3 a2 b1 c2 d1 a2 c1 a1] +after 4 -> 4 -> 4 -> 4 -> 3 +``` + +The interleaving is exactly the reported 3-4-1-2. Five checks in `run_webkit.py` cover it +now; four of them fail without the fix. + +**The Storage panel was showing on the Device control page.** `#view-storage` sets +`display: flex`, an id rule, which outranks the UA stylesheet's `[hidden]{display:none}` - +so setting `.hidden` did nothing and it computed to `display: flex` at 588x72, the +Storage header bar sitting under Filament. `#view-control` had opted back in and this +never had. + +**Third time for that trap**, and the reason it got through is instructive: the +conformance check pinned `.fault` and `.stor-foot` *by name*. It derives the set now, and +`run_webkit.py` asks the running page the stronger version - **every element carrying +`[hidden]` must compute to `display: none`**, whatever the reason. Verified by removing +the opt-out again: both checks fail and name `#view-storage`. + +**A latent one the printer's own data exposed.** The real signatures read +`1:PLA:null:Jayo:F44336FF:[object Object]` - `f.tag` is the RFID record, an object, so +every tagged spool signed identically and swapping one for another would not have rebuilt +the card. The signature carries only what the card draws now: that a tag exists. + +Not reproduced, and recorded rather than claimed: one run reported 39 storage cards built +inside the hidden container. Three later runs report zero and a MutationObserver never +fires. The likely cause is an orphaned `WebKitWebProcess` from an earlier scripted run +that had clicked into Storage - the trap already documented above - but it was seen once. + +### Structure, pass six: one panel, one directory (2026-08-25) + +The last of it. `ui.js` was still one 1,359-line module rendering all six panels, so +changing the Camera panel meant three files in three places and hunting among six +renderers in the third. It decomposed cleanly along panel lines - nothing unclassified: + +``` +js/ + registry.js the two destinations, and which panels each has + shell.js app.js page-commands.js + core/ dom render pending store session connection overlay diag mock thumbs + widgets/ rail rail-commands trace art format + views/ + device-control/ "Device control" + camera/ camera-panel.js camera-view.js camera-commands.js + control/ control-panel.js control-view.js control-commands.js + task/ ... + filament/ ... + storage/ + storage/ storage-panel.js storage-view.js storage-commands.js + fault/ fault-panel.js fault-view.js fault-commands.js +``` + +**Every filename carries its component.** Six files called `panel.js` in a file switcher +is not a structure, it is a lottery; `camera-panel.js` is unambiguous everywhere. + +`temps()` fell out as dead - defined, exported by nothing, called by nothing. Four +helpers were genuinely shared and moved to `widgets/`: the two pieces of empty-state art, +and `clock()`. Everything else belonged to exactly one panel. + +**The move was mechanical and checked as such.** Imports were rewritten by resolving each +specifier against the file's *old* directory and re-relativising to its new one, then a +sweep confirmed all 55 + 19 specifiers resolve **and** that every named import is actually +exported. The rendered DOM is byte-identical across both the move and the rename. + +**Two checks had quietly stopped covering things**, both for the same reason - a flat +`os.listdir` where the tree is now nested: + +- `unit_jsc.py`'s per-module parse sweep went from 2 modules to **37** once it walked. + It had been checking almost nothing. +- `check_coverage.py`'s implemented-command scan reported **17** commands where there + are 55. + +Neither failed; both under-reported, which is worse. Any tool that enumerates this tree +walks it now. + +**Verified.** 150/150 conformance, 134/134 `unit_jsc`, 23/23 `run_webkit`, coverage +clean, DOM unchanged, and 5/5 against the U1. diff --git a/docs/u1-webui/02-device-page/10-multiace-filament.md b/docs/u1-webui/02-device-page/10-multiace-filament.md new file mode 100644 index 00000000000..60038928b9c --- /dev/null +++ b/docs/u1-webui/02-device-page/10-multiace-filament.md @@ -0,0 +1,375 @@ +# The Filament panel, with more than one ACE + +> **Picking this up?** Start at [11-multiace-handover.md](11-multiace-handover.md) — what +> is settled, what to build first, and the traps. This file is the reasoning behind it. + +Studies: [multiace-filament-mockup.html](multiace-filament-mockup.html) — seven shapes, +five machine states, every number measured in WebKitGTK · +[multiace-f2-iterations.html](multiace-f2-iterations.html) — the follow-on, which **runs**: +four rearrangements of a per-toolhead subpanel, driven by source, mode, unit count and +Sync · +[multiace-toolhead-card.html](multiace-toolhead-card.html) — what goes *under* the header: +five mixable axes for the filament, the box, the tube and the head's own marker · +[multiace-cabinet.html](multiace-cabinet.html) — the chosen card, with the cabinet drawn +as the badge and everything on one centred axis. All three carry pre-rendered copies of +every option so they read with no script at all. + +> **This is the reasoning. The panel is built** — see the handover for what landed, +> what changed on the way in, and what is still unproven against hardware. + +The panel shows one slot per toolhead and knows nothing about an ACE. This is the design +for the version that does: **several ACE units, each able to feed a toolhead in place of +its stock feeder**, drawn so that *which bay feeds which head* is the thing you see first. + +Bambu Studio's AMS display +(`ui-snapshots-inspiration/Device-page_AMS-info/AMS_display.png`) is the reference for the +shape — slot cards, an address badge per slot, a humidity pill, wires converging on a +merge bar, the nozzle at the bottom — and +`No_ams_nozzle_1.png` for the case where one side is a plain external spool rather than a +cabinet. Bambu draws that for **one or two nozzles**. The U1 has **four**, and up to four +units that may each feed more than one of them, which is the whole of the problem. + +## What is not negotiable + +Four numbers, none of them chosen here, and between them they eliminate three of the six +shapes before any is judged on how it reads. + +| | | from | +|---|---|---| +| Panel width | **830** | `--col-w`; the side column is `flex: 0 0 var(--col-w)` | +| Panel body height | **456** | Measured at 1920×1080 with `run_webkit.py --size 1920x1080`. Filament is the `grows` panel in its column, so a taller window gives more — and `.panel-body` is `overflow: hidden`, so a body past it is **clipped in silence** | +| A toolhead | **64×140** | `icons/extruderBackground.svg`, drawn by `.slot` | +| A bay | **64×61** | The same 36 px disc at (15,29) and 58×19 `#6E6E6E` name pill at (3,71), without the extruder body | + +**The artwork decides which way up the picture goes.** `extruderBackground.svg` draws two +stubs from `y=15` to `y=0` at `x=26` and `x=38` — the filament inlet — and its nozzle is +a triangle at `y=133..138` pointing down. A diagram that puts the ACE *below* the +toolheads draws filament climbing into a nozzle. Bambu's own display puts the cabinet +above and the nozzle at the bottom for the same reason. + +That is the one thing the earlier study's **P1** +([camera-layout-ace-mockup.html](camera-layout-ace-mockup.html), part 03) got backwards, +and it was invisible until the real artwork replaced a placeholder rectangle. Everything +else about P1 is sound; the chosen design is P1 turned over. + +**A bay is drawn as a head.** The page already has one way to say *some filament, this +colour, called this*: a 36 px disc that falls back to an 8 px checkerboard when empty, +over a `#6E6E6E` pill holding the material name. A bay is the same statement about the +same substance at the other end of one tube, so it is the same drawing. Not Orca's +`AMSLib` tube — that is the standard for the **C++** surfaces in +[16-ace-visuals.md](../../ace-mmu/16-ace-visuals.md), and it would be a fifth language on +a page that already has one. It also assumes a fill level, and there is none: see below. + +## The fork every shape is an answer to + +`ACE_SET_HEAD_ACE` binds a head to one unit and says nothing about the reverse, so **one +unit may feed several heads**. That splits the shapes in two: + +- **Head-major** — a group per toolhead, Bambu's layout repeated. A shared unit is drawn + **once per head it feeds**, with as many humidity readings as copies, all of which have + to agree. With one unit and four heads — the cheapest setup anyone will own — that is + four drawings of one cabinet. +- **Unit-major** — a box per unit, drawn once, sprouting a wire per head. Nothing is + duplicated; the cost is that the wires have to get somewhere. + +## The six, and what each measures + +Body heights in WebKitGTK, against the 456 the panel has. + +| | shape | body | | +|---|---|---|---| +| **F0** | Today: four slots, no ACE | 272 | Ships now. Says nothing about an ACE | +| **F1** | Four lanes — Bambu's group per toolhead, flexing to its source | 403 | **Rejected.** A shared unit is drawn twice; and the lane cannot flex, because a feeder lane's min-content is the 102 px source switch. It sits at 115 where its share is 109, so the ACE lane gets 416 not 434. A *second* ACE lane would get 265 and a unit needs 298 | +| **F2** | Two by two — Bambu's two-group frame, wrapped | 686 | **Rejected on height** — 230 over. *Narrowed later:* the rejection holds for this arrangement, not for the idea. See below | +| **F3** | Heads over units — the earlier P1, in the real artwork | 391 | **Rejected.** The wire climbs into the nozzle | +| **F4** | **The bus** — sources above, merge bar, four heads below | **395** | **Chosen.** 61 px spare | +| **F5** | The spine — heads on a vertical trunk, sources beside it | 282 | **Rejected.** Four 140 px toolheads stacked is 560, so it gives the artwork up and draws a 30 px disc instead — solving a layout problem by leaving the design language | +| **F6** | The ledger — four rows and a source rack, no wires | 312 | **Kept**, as F4's dense form | + +### F4, in one paragraph + +The unit box on top, Bambu's merge bar with its grey nub beneath the bays, and the four +toolheads in one row under it, each wire landing on the inlet the artwork already draws. +An available bay drops to the bar in **grey**; the bay the head is actually loaded from is +drawn in **its own colour**, from `head_source[n]`. A head on a stock feeder has no wire +at all, which is true — its spool is at the head and the ACE never touches it. The bay's +address (`A1`…`D4`, Bambu's scheme) sits **inside its disc**, the way a toolhead's number +already sits inside its own; that is a departure from Bambu, and it is what buys the +20 px a second unit needs. + +### F4 and F6 are one design at two densities + +That is the finding, and it is what stops the panel choosing between drawing the routing +and surviving four units. + +| units | body | what is drawn | +|---|---|---| +| 0 | 229 | The head row and its switches, no bus. Also the degradation path if `ace` stops answering | +| 1 | 395 | The full picture | +| 2 | 445 | One unit open, one collapsed to a 42 px row. **A collapsed unit draws no wire** — collapsing it is giving up its picture, and the head it feeds still names the bay | +| 3–4 | 435 | Folds to the rack: every unit one line, every head naming its bay. Opening one swaps its line back for the box | + +A fifth state, **ND**, is the dryer running: `dryer_status` gives target, duration and +remaining, so the pill counts down, and every source switch goes inert **with the dryer +named as the reason** rather than the mode. The routing is untouched — what is in a head +is still in it. The Normal-mode state is unchanged from part 03 of the earlier study. + +A unit box is **130** and a row is **42**, so four open boxes plus the bus and the head row +would be **815** against 456. The fold is not a preference. It is also the move Storage +already made, and the reason the unit box is a component from the first commit. + +## The follow-on: a subpanel per toolhead + +[multiace-f2-iterations.html](multiace-f2-iterations.html) re-opened F2, because the +rejection was of **one arrangement** and the idea underneath it — every toolhead gets its +own card, and that card's header chooses what feeds it — is a good one. Four +rearrangements, all of them running from a state object: + +| | arrangement | body | | +|---|---|---|---| +| **I1** | **Bands** — four full-width cards, header turned ninety degrees into a 292 px left column | **417** | **Build this** | +| **I2** | Side by side — 2×2, toolhead *beside* the bays at **full scale** | 427 | The alternative, if the artwork is judged to carry weight at 1.0 | +| **I3** | Half-scale head — 2×2, Bambu's order, artwork at 0.5 | 421 | Fits | +| **I4** | Full-scale head — the arrangement the first study drew | 561 | Still over, by 105 | + +Three findings, all of them from building it rather than drawing it: + +- **The heights do not move.** 417 / 427 / 421 / 561 are the measurements at **zero, one, + two and four** units, and at every source combination including all four toolheads on + one unit. A subpanel is the same height whether its source is one spool or four bays, so + the panel does not resize when you change what feeds a head — which on an + `overflow: hidden` body is the difference between a layout and one that clips later. +- **It never folds.** Four units is **16 bays**, drawn in the same 417 px as none, because + a subpanel shows only *its own* source. F4 has to collapse to a rack at three units + because it draws every unit whether or not the head you are looking at can reach it. + Head-major pays for *sharing*; unit-major pays for *counting* — and four units on a + four-head machine is the common shape, so head-major pays in the cheaper currency. +- **The duplication objection was half wrong.** "A shared unit is drawn once per head, + with as many humidity readings as copies, all of which have to agree" is an objection to + *drawing*. Four copies bound to one state object cannot disagree. What survives is the + reading risk — four cabinets where there is one — and that is answered where it happens: + a dashed card edge and a `⇄` in the unit's own pill, with the sentence in its title. + +**The source selector is what makes any of this possible.** The first study gave each head +a `feeder / ACE / manual` icon triple: 102 px wide, and unable to name *which* ACE. With +four units the question has six answers, so it is a list — 24 px, and it is `shell.js`'s +existing `pill` header kind. + +**Sync is the tier-1 / tier-2 boundary turned into a button.** It sets every source from +`head_manual` → `head_feeder` → `head_ace`, and fills the bay identities that the Klipper +object does not carry. Before it runs, an occupied-but-unnamed bay is drawn as a solid +neutral disc with `?` — never the 8 px checkerboard, which is the shipped page's word for +*empty*. + +**Keep F4 as a second view, not a rival.** A per-toolhead panel cannot say "one cabinet, +feeding these three heads" in one picture, and when a unit *is* shared that is the sentence +someone opened the panel to read. It belongs behind the header's overflow as *Show +routing*. + +## The card: the toolhead stops carrying filament + +[multiace-toolhead-card.html](multiace-toolhead-card.html). The subpanel header is settled; +this is what goes under it, and one decision drives the rest. + +**No colour, no material name, no pencil on the artwork.** Filament belongs to the +*source*. The head's copy of it was the one that could go stale, and with four bays visible +it added nothing the tube did not already say. What the head does have of its own is a +**sensor marker**, and the printer reports that properly: `filament_feed left|right` → +`extruder0..3` gives four positions along the path plus a fault — `filament_in_ace`, +`filament_in_toolhead`, `filament_at_extruder`, `channel_error` — already parsed in +`state.js`'s `feedChannels()` and shown, until now, nowhere but a dialog. + +### I3 or I4 — the answer is neither, quite + +| frame | S1 disc | S2 reel | S3 card | S4 well | | +|---|---|---|---|---|---| +| Head below, **0.45** | 467 | 487 | 463 | 499 | 7–43 over | +| Head below, **0.40** | 431 | 443 | **427** | 427 | **13–29 spare** | +| Head below, **full** (I4) | 599 | 611 | 595 | 595 | **139–155 over** | +| Head **beside**, full | 417 | 417 | 417 | 417 | **39 spare** | + +**I4 is not available and the number is not close.** Two rows of a 140 px toolhead with a +box above each is 595–611 against 456, and nothing in the card can pay that back — the +whole box, its bays and its tube together are 84 px. So the choice is between Bambu's +vertical order at a **0.40** head, and a **full-scale** head with the box beside it. + +Only the frame costs height. The box is 427 / 425 / 425 / 389 for cabinet / tray / rail / +none; the four tube routings are all 427; the three markers are all 427. + +### What to take + +| axis | take | runner-up | +|---|---|---| +| **Frame** | Head below at **0.40** — Bambu's order, tube straight down into the inlet | Head beside at full, if the artwork is judged to carry weight at 1.0 | +| **Filament** | **S3 slot card** — the material name sits *inside* the colour, so a bay is one object rather than a drawing with a label under it | **S4 well**, the only one that says the amount is unknown rather than implying full | +| **Box** | **B1 cabinet** — the base drawn wider than the body is what makes it read as furniture, and the geometry is lifted from [16-ace-visuals.md](../../ace-mmu/16-ace-visuals.md) rather than invented | **B2 tray**, 2 px cheaper, introduces no new material | +| **Tube** | **W3 manifold** — tubing, not a line: a `#C9C9C9` casing with the filament's colour as a **core**. The casing is always there and the core only where there is filament, which settles wired-versus-loaded without a second colour | **W2 tubing**, direct, no merge | +| **Marker** | **M1 dot** — four real states in 11 px | **M2 chip**, the only one that survives greyscale | + +**Worth arguing about:** at 0.40 the toolhead is 26×56, a small grey silhouette carrying +one dot. Now that it holds no colour and no name, it is fair to ask what it is doing. Two +answers, both real: it is where the tube *lands*, so removing it removes the diagram; and +it is the only part of the card that is the machine rather than the filament. If neither +convinces, take the head-beside frame. + +### The card, settled + +[multiace-cabinet.html](multiace-cabinet.html) is where the card is decided. What is fixed: + +| | | +|---|---| +| **Frame** | Head below the box at **0.50**, 32×70, everything on the card's centre line | +| **Bay** | **The shipped one** — `.slot`'s own 36 px disc over its 58×19 name pill, not a shrunken copy | +| **Seam** | **Through the roll**, as a hard colour stop: half of every spool in each half | +| **Greys** | `#EEEEEE` over `#CECECE` — Orca's own `AMS_CONTROL_DEF_BLOCK_BK_COLOUR` and `AMS_CONTROL_DISABLE_COLOUR` | +| **Manifold** | **Below** the cabinet, so the tube layer paints behind it | +| **Marker** | A dot, **centred on the artwork's body** — `extruderBackground.svg` draws that body `y=17.4..127.6` of its 64×140, so its middle is `(32, 72.5)`, at whatever scale | +| **Feeder** | Bare, and at an ACE bay's exact height | +| *open* | how tightly the box hugs its spools, how much room the chip gets under the roll, and what shape the socket is | + +**Two halves, and each half has a job.** Spools above, materials named below. **Splitting +the bay across the halves is what made the shipped bay affordable** — with both in the +upper half the cabinet was 71 px and a bay had to be 26/52; with the chip below, the box is +61 and 36/58 fits with room over. A bay and a toolhead are now the same drawing at the same +size, which was the point of drawing a bay as a head. + +**The box hugs its spools**: 294 px around 278 of bays, centred in a 371 px card. That is +the change that makes the cabinet an *object sitting in the card* rather than the card's own +ground — at four bays, full width was mostly empty grey. All four fits cost the same 434, so +it is decided purely on reading; room under the roll costs 6 px of body per 3 px of gap, and +**9 px** is where the roll and its label read as two things rather than one stacked object. + +**Faults the checks caught and no screenshot would have.** Across this study: an +`align-items: flex-start` inherited from the previous file left every card silently +left-aligned; an option named `0.45` produced the selector `.sb-head-0.45`, which CSS parses +as `.sb-head-0` plus an invalid `.45` — invalidating the **whole comma-joined rule list** +and taking every pre-rendered copy down while the live panel looked perfect; a feeder glyph +placed *in* its row centred the row rather than the spool and bent three tubes out of true; +a text replacement that did not match left the feeder being built by the previous version of +its own function, rendering a 5×19 bay with no sizes; D6's overhang was applied to the +cabinet but not the feeder, dropping every feeder spool half a disc below its neighbours; +and finally the shoulder fit changed *vertical* padding along with horizontal, and the +whole-bay socket padded the bay, each moving the ACE's spools and not the feeder's. + +Every one of those is a position, and every one was found by subtracting two numbers rather +than by looking. Fallback rules are written one per line; fit is a horizontal decision only; +and the feeder wears whatever the bay wears. + +## Two traps carried over, both still true + +**`head_ace` is not the answer to "what feeds this head".** The live machine reports +`head_ace {0:0, 1:1, 2:2, 3:0}` with `device_count: 1` — heads 2 and 3 name ACE 2 and +ACE 3, which do not exist. Resolve `head_manual` first, then `head_feeder`, and +`head_ace` only for what is left. Same bug class as `toolhead.extruder` naming a parked +head. + +**No bay has a level.** `spool_mode` is `spoolman` and `spool_binding` is `{}`, so nothing +is bound and no bay has a weight behind it. The disc is a colour, not a gauge. Anything +that draws a fill height here is drawing a number the machine did not give it. + +## What can be built now + +`ace` is a Klipper object and `sw_GetMachineState {objects:{ace:null}}` answers in +**277 ms** — no new bridge command. That covers topology, unit health, humidity and +temperature, the dryer, occupancy, and the full identity of what is *loaded* in each head. + +What it cannot do is name the filament in an **unloaded** bay: every raw slot reads +`{material:"", brand:"", rfid:0}`, and `/multiace/api/state` — which has it — is +CORS-refused. So the panel draws an occupied-but-unidentified bay as exactly that, and +tier 2 fills in pills which already exist. + +> **Corrected against hardware, 2026-08-26.** Tier 2 does *not* need an `sw_` proxy. The +> machine has `ACE_SPOOL_ASSIGN ACE=n SLOT=n [ID=n]` and a local spool table in +> `ace.spools` — 19 entries, each with material, vendor, colour, weight and SKU — so a +> bay is named with one macro the page can already send. What is missing is the bay's own +> sheet, not a piece of C++. See +> [11-multiace-handover.md](11-multiace-handover.md). + +Every control is a documented G-code macro and the page already owns `sw_SendGCodes`; the +full table is in the study. Two rules apply without amendment: the settings that are set +once live behind the header's overflow, not on the face of the panel; and **an instant +`ok` is indistinguishable from success** — `ACE_BG_UNLOAD`'s own help says ~3 min — so +nothing is awaited and everything is confirmed against machine state through +[`shared/pending.js`](../../../resources/web/shared/js/pending.js). + +The `[EXPERIMENTAL]` macros (`ACE_BG_SWAP`, `ACE_BG_UNLOAD`, `ACE_BG_MOVE`) carry +preconditions in their own help — *requires head mode, 1:1 wiring, an OPEN dock below the +head (purges ~60 mm!)*. They stay out of v1 and go in `check_coverage.py`'s `EXCLUDED` +list with that reason, so "not built" stays visible. + +## Build order + +**Steps 1-5 are built** (`resources/web/device_page/js/views/device-control/filament/`); +step 6 is tier 2, which needs a bay sheet rather than the C++ it was written down as +needing. The list is kept as written, because what each step *was* +is the record of why the panel is shaped the way it is — and step 4 is the one that turned +out not to be needed, since a head-major panel never folds. + +1. **Four toolhead subpanels**, each with a header carrying its name and a **source + selector** resolved `head_manual` → `head_feeder` → `head_ace` *in that order*, and the + ACE-mode pill in the panel header. With no ACE attached this is today's row plus a + selector, and it is worth shipping alone. +2. **The unit box** — the ACE cabinet with its four bays drawn as slot cards, and the + humidity pill in the subpanel header. +3. **The tube** — casing plus core, merged at a manifold, landing on the artwork's inlet; + and the head's sensor marker from `feedChannels()`. +4. **The collapsed row and the rack** — states **N2** and **N4**. +5. **The sheets** — load, unload, swap, dry, and the overflow's settings list. +6. **Tier 2** — naming an unloaded bay. Written down as an `sw_` proxy for + `/multiace/api/state`; measured as `ACE_SPOOL_ASSIGN` plus a bay sheet. + +## Checking it + +```bash +python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-filament-mockup.html +python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-f2-iterations.html +python3 resources/web/shared/tests/run_webkit.py --size 1920x1080 +``` + +The second one **drives** its study rather than reading it: every arrangement at every +unit count, every source combination, the wire endpoints, that no subpanel header overflows +its cell, and then the whole no-script path with `.js-on` removed. Four real faults turned +up that way — a band header that grew a third row once four toolheads shared a unit and +went 17 px over, a source list whose longest option pushed the header past its cell, a +humidity pill that could not coexist with a spelled-out shared tag in 391 px, and an +`