From 4879fc507920b72f6b2200634424fa147f360f9b Mon Sep 17 00:00:00 2001 From: physicsG Date: Sat, 22 Aug 2026 11:02:57 +0200 Subject: [PATCH 1/5] The U1 printer panel: three cards, an ACE mode row and a toolhead grid (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Printer section is one widget seen in both **Prepare** and **Preview**. On a U1 it drew four tabs named *Nozzle 1..4* that each held the same `Diameter` combo — changing any one writes all four and switches the whole preset, because the machine refuses a mixed set — and it hid its sync behind an unlabelled 16px glyph in the title bar. Measured by running the app, not by reading it. It now follows `docs/ace-mmu/15-printer-panel.md` and its mockup. ## What is here | Commit | | |---|---| | `docs(ace-mmu)` | Corrects the plates paired with the wrong `BedType` enum | | `feat(config)` | `ace_mode` + per-head `ace_head_unit` / `ace_head_capacity` | | `feat(sidebar)` | The panel rebuild and seven plate assets | The two design commits underneath are the docs branch this stacks on, so the design and the code arrive together. **The panel** — three cards at equal height (printer with its thumbnail over the preset combo, plate, and *Sync info*); an ACE mode row mirroring the printer's own three-way switch; a bordered box per toolhead wrapped 2×2 with an `ACE` row and a `Diameter` row. The plate card absorbs what was a full-width `Bed type` row, which is where the height for the head boxes comes from, and the whole card opens the list so the picture is the click target. Heads are named **Toolhead N**, which is what the machine's own interface calls them; the sidebar was the last place saying *Nozzle N*. **Every other printer keeps the names it had** — an IDEX machine's *Left / Right Nozzle* tabs are untouched. `update_nozzle_settings` builds those boxes instead of notebook pages, which leaves `CustomNotebook` with no callers; it goes, 269 lines and the stray `#pragma once` that warned on every build. The 135-line sync handler is lifted verbatim into `Sidebar::sync_printer_info`. **Config** — `ace_head_unit` and `ace_head_capacity` are taken verbatim from `feat/ace-mmu-slicing` so that branch merges onto this rather than colliding. `ace_mode` is new: that branch reads the mode from the printer and never stores it, and a panel has to draw something with the printer switched off. --- .gitignore | 4 +- docs/ace-mmu/15-printer-panel.md | 127 +++ docs/ace-mmu/16-ace-visuals.md | 115 ++ docs/ace-mmu/17-plate-template.md | 119 +++ docs/ace-mmu/NEXT.md | 135 +++ docs/ace-mmu/README.md | 59 ++ docs/ace-mmu/ace-visual-standard.html | 1006 ++++++++++++++++++ docs/ace-mmu/plate-thumbnails-options.html | 421 ++++++++ docs/ace-mmu/printer-panel-mockup.html | 959 +++++++++++++++++ resources/images/plate_cool.png | Bin 0 -> 525 bytes resources/images/plate_cool_steel.png | Bin 0 -> 1061 bytes resources/images/plate_engineering.png | Bin 0 -> 4516 bytes resources/images/plate_graphic_effect.png | Bin 0 -> 44071 bytes resources/images/plate_smooth_pei.png | Bin 0 -> 2020 bytes resources/images/plate_textured_cool.png | Bin 0 -> 4517 bytes resources/images/plate_textured_pei.png | Bin 0 -> 25822 bytes src/libslic3r/Preset.cpp | 2 +- src/libslic3r/PrintConfig.cpp | 76 +- src/libslic3r/PrintConfig.hpp | 13 + src/slic3r/GUI/Plater.cpp | 1101 +++++++++----------- src/slic3r/GUI/Plater.hpp | 2 + 21 files changed, 3546 insertions(+), 593 deletions(-) create mode 100644 docs/ace-mmu/15-printer-panel.md create mode 100644 docs/ace-mmu/16-ace-visuals.md create mode 100644 docs/ace-mmu/17-plate-template.md create mode 100644 docs/ace-mmu/NEXT.md create mode 100644 docs/ace-mmu/README.md create mode 100644 docs/ace-mmu/ace-visual-standard.html create mode 100644 docs/ace-mmu/plate-thumbnails-options.html create mode 100644 docs/ace-mmu/printer-panel-mockup.html create mode 100644 resources/images/plate_cool.png create mode 100644 resources/images/plate_cool_steel.png create mode 100644 resources/images/plate_engineering.png create mode 100644 resources/images/plate_graphic_effect.png create mode 100644 resources/images/plate_smooth_pei.png create mode 100644 resources/images/plate_textured_cool.png create mode 100644 resources/images/plate_textured_pei.png 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/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..6c4d847c600 --- /dev/null +++ b/docs/ace-mmu/16-ace-visuals.md @@ -0,0 +1,115 @@ +# 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()` | +| **Glyph** | 44×26 line, stroke 1.6 | a popover row, a label | `ace_glyph()` | +| **Glyph, square** | 24×24 line, stroke 1.6 | a tab, a menu, a `ScalableButton` | `ace_glyph_square()` | + +**Badge** — hood, four bays, base drawn *over* them; the base is slightly wider than the +hood, which is what makes it read as a cabinet rather than a bar chart. Bays are 5×14 +capsules at x 6/15/24/33 — **padding 4 = gap 4**, matching the proportions of Bambu's +own icon. Colour and emptiness are all that survive at this size, so the badge carries +colour only; an empty bay is white against the grey hood, with no outline. Trust and +staleness live wherever the badge is a control. + +**Glyph** — the badge's own silhouette in line: one stepped path, hood shoulders on top, +base stepping out at the bottom, bays filled. + +**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 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 | + +**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..d0320afd531 --- /dev/null +++ b/docs/ace-mmu/ace-visual-standard.html @@ -0,0 +1,1006 @@ + + +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 + five 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.

+
+

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. Four ways to treat the bays inside it:

+
+

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 +
+
+

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..c5948ef50fd --- /dev/null +++ b/docs/ace-mmu/printer-panel-mockup.html @@ -0,0 +1,959 @@ + + +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.

+
+ + +
+

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/resources/images/plate_cool.png b/resources/images/plate_cool.png new file mode 100644 index 0000000000000000000000000000000000000000..37721a8db1bffcfe17fe0849c7b90eb46ea9f4aa GIT binary patch literal 525 zcmeAS@N?(olHy`uVBq!ia0vp^8-REL2OE%Fllbf?0|Vm~PZ!6Kid%0lZRBk*;Bj?S zTeL9o|Iq{{1rC>eCta)dzP)2&Tk&~Q-d?Xcn_t;X{r73>_2P%t-RgBmPMCK&%-K-z z`q8tW=i~qJy}x=X(|Bhf!=o*&{nPyZu$VLLs4V=Pa-R9a$wxp5!2@SctJk$jGp=!D zieO;YX;5uoH8{W|k-(_KsMEkGc0dRSk+=-p37iRxJR2C;A{e}ZoCeknEI^3h0yRfK zOag0$YK55v;sP~KV-Pz4(+trHHw(mtJLASgkn#9b0PTXg8{%^6Sx#CQd=X6O2$j-5 zqSY^+H1+uH*FOtp06i&i__ZUCJ}>~2D!TW{?qqu&m$tb2ATXL4JYD@<);T3K0RT0U Bq~-ts literal 0 HcmV?d00001 diff --git a/resources/images/plate_cool_steel.png b/resources/images/plate_cool_steel.png new file mode 100644 index 0000000000000000000000000000000000000000..076f01b7e826cd6db51966245c0d827ce53aa5a5 GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0vp^8-REL2OE%Fllbf?0|WCLPZ!6Kid%2*oX!ey6luG- z=fAb3po;*DAk)F$tt~T-%#d*2!{a5BqI7T4v~|C}$4Q^t^Q~NreHZ)I&6^Ltc{jh; zeZBwjX7S^X?@ca$eD2-BYx8E$Dc)-QR(4MRb9eE_XJ0W-n_DC0&u`!7`+MDujLXIUw$JSRuGYQ&htjsj@~dv%3UQ3@_v`JuRk^LR{HWb! z!SyZQ^S#%XEz5lzyYr&S5$%V6FJIE$*CJUFo@>JUA*-T(`;RrnckWute4j7dJN3i5 zE9q7bL_hx9^CM>ufBd|j7J+*q3gFUxjavSWqSj*UEzDxhW2em2*S3IF&@oy%c z{F3;FhrRD8RoP#r@dB+|46@DyWF66pa`kt#iN`fOWR*y$VElFf7~WC9h&lj@s68L@ z?oc^4aKvjazr?*a`_?iZo_&A2ZbC)&mt0PXdsel#8QZpp-4APcxc2&XUZC)|Tt*U2tH0<1Rom5`2C5Ff9|Vk(>)UyN!ryd(s`u4CW;}d*Hz-hQ zWs-=AjpfYybdxF`#xlLn6*$cOKBywU2^g}|k633f;N3re`|k$nI{WpF5^)_5Tg4O1 h7fW|MyqmF)nT7pyXJgqK6JYjd@O1TaS?83{1OUAn`Xm4V literal 0 HcmV?d00001 diff --git a/resources/images/plate_engineering.png b/resources/images/plate_engineering.png new file mode 100644 index 0000000000000000000000000000000000000000..a88a9b1ac75904682e3b844ea8de9b41111ed53d GIT binary patch literal 4516 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 gyLe} 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 literal 0 HcmV?d00001 diff --git a/resources/images/plate_smooth_pei.png b/resources/images/plate_smooth_pei.png new file mode 100644 index 0000000000000000000000000000000000000000..3dd6f8fa5f3cffce2ae7cdc9893e6a8b629d4a74 GIT binary patch literal 2020 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 DTG@=7 literal 0 HcmV?d00001 diff --git a/resources/images/plate_textured_pei.png b/resources/images/plate_textured_pei.png new file mode 100644 index 0000000000000000000000000000000000000000..a447e8029a8baea0e37dd238dbef28835b330402 GIT binary patch literal 25822 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 s_Preset_printer_options { "printer_technology", "printable_area", "bed_exclude_area","bed_custom_texture", "bed_custom_model", "gcode_flavor", "fan_kickstart", "fan_speedup_time", "fan_speedup_overhangs", - "single_extruder_multi_material", "manual_filament_change", "machine_start_gcode", "machine_end_gcode", "before_layer_change_gcode", "printing_by_object_gcode", "layer_change_gcode", "time_lapse_gcode", "change_filament_gcode", "change_extrusion_role_gcode", + "single_extruder_multi_material", "ace_mode", "ace_head_capacity", "ace_head_unit", "manual_filament_change", "machine_start_gcode", "machine_end_gcode", "before_layer_change_gcode", "printing_by_object_gcode", "layer_change_gcode", "time_lapse_gcode", "change_filament_gcode", "change_extrusion_role_gcode", "printer_model", "printer_variant", "printable_height", "extruder_clearance_radius", "extruder_clearance_height_to_lid", "extruder_clearance_height_to_rod", "nozzle_height", "default_print_profile", "inherits", diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index d97af9e9822..df9c19897f1 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -435,6 +435,14 @@ static t_config_enum_values s_keys_map_PrinterStructure { }; CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(PrinterStructure) +// The firmware's own words, so the stored value is the SET_ACE_MODE argument verbatim. +static t_config_enum_values s_keys_map_AceMode { + {"normal", int(AceMode::amNormal)}, + {"head", int(AceMode::amHead)}, + {"multi", int(AceMode::amMulti)} +}; +CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(AceMode) + static t_config_enum_values s_keys_map_PerimeterGeneratorType{ { "classic", int(PerimeterGeneratorType::Classic) }, { "arachne", int(PerimeterGeneratorType::Arachne) } @@ -3978,6 +3986,72 @@ void PrintConfigDef::init_fff_params() def->max = 100; def->set_default_value(new ConfigOptionFloats { 0.4 }); + def = this->add("ace_mode", coEnum); + def->label = L("ACE mode"); + def->tooltip = L("How the printer wires its ACE units to its toolheads, mirroring the machine's own " + "three-way switch (SET_ACE_MODE MODE=normal|multi|head). \"Normal\" means stock " + "feeders only and no ACE. \"Per toolhead\" is the one that makes per-head wiring " + "mean anything: each head is either its own feeder or wired to exactly one unit. " + "\"Combined\" pools several units onto a single head."); + def->enum_keys_map = &ConfigOptionEnum::get_enum_values(); + def->enum_values.push_back("normal"); + def->enum_values.push_back("head"); + def->enum_values.push_back("multi"); + def->enum_labels.push_back(L("Normal")); + def->enum_labels.push_back(L("Per toolhead")); + def->enum_labels.push_back(L("Combined")); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionEnum(amNormal)); + + def = this->add("ace_head_unit", coInts); + // Stored 0-based because that is the ACE= argument the firmware takes, but never shown + // that way: every other surface calls the first unit "ACE 1". Closed list - a unit that + // does not exist is not a value worth being able to type. + def->gui_type = ConfigOptionDef::GUIType::i_enum_open; + def->enum_values.push_back("-1"); + def->enum_values.push_back("0"); + def->enum_values.push_back("1"); + def->enum_values.push_back("2"); + def->enum_values.push_back("3"); + def->enum_labels.push_back(L("None")); + def->enum_labels.push_back(L("ACE 1")); + def->enum_labels.push_back(L("ACE 2")); + def->enum_labels.push_back(L("ACE 3")); + def->enum_labels.push_back(L("ACE 4")); + def->label = L("ACE unit"); + def->tooltip = L("Which ACE unit feeds this toolhead. Each ACE-fed head is wired to exactly one unit " + "(ACE_SET_HEAD_ACE on the printer), and this value becomes the ACE= argument of every " + "filament swap, so a wrong choice addresses the wrong hardware. Units are numbered as " + "they appear on the device page: ACE 1 is the first unit. \"None\" is what a stock " + "feeder shows, since no ACE addresses it."); + def->mode = comAdvanced; + def->min = -1; + // None by default: the matching default for ace_head_capacity is a stock feeder, and + // showing "ACE 1" there claims a unit that is not feeding anything. + def->set_default_value(new ConfigOptionInts { -1 }); + + def = this->add("ace_head_capacity", coInts); + // Labelled choices rather than a bare number: "1" meaning "no ACE at all" is exactly + // the kind of magic value that got a head wired to the first ACE configured as unit 1. + def->gui_type = ConfigOptionDef::GUIType::i_enum_open; + def->enum_values.push_back("1"); + def->enum_values.push_back("2"); + def->enum_values.push_back("4"); + def->enum_values.push_back("6"); + def->enum_values.push_back("8"); + def->enum_labels.push_back(L("Stock feeder")); + def->enum_labels.push_back(L("ACE - 2 slots")); + def->enum_labels.push_back(L("ACE - 4 slots")); + def->enum_labels.push_back(L("ACE - 6 slots")); + def->enum_labels.push_back(L("ACE - 8 slots")); + def->label = L("Fed by"); + def->tooltip = L("How this toolhead gets filament. \"Stock feeder\" is the head's own side feeder - " + "one spool, no ACE. Otherwise it is the number of slots the ACE unit presents to this " + "head (sum them when several units are combined onto one head)."); + def->mode = comAdvanced; + def->min = 1; + def->set_default_value(new ConfigOptionInts { 1 }); + def = this->add("notes", coString); def->label = L("Configuration notes"); def->tooltip = L("You can put here your personal notes. This text will be added to the G-code " @@ -6429,7 +6503,7 @@ void PrintConfigDef::init_extruder_option_keys() { // ConfigOptionFloats, ConfigOptionPercents, ConfigOptionBools, ConfigOptionStrings m_extruder_option_keys = { - "nozzle_diameter", "min_layer_height", "max_layer_height", "extruder_offset", + "nozzle_diameter", "ace_head_capacity", "ace_head_unit", "min_layer_height", "max_layer_height", "extruder_offset", "retraction_length", "z_hop", "z_hop_types", "z_hop_when_prime", "travel_slope", "retract_lift_above", "retract_lift_below", "retract_lift_enforce", "retraction_speed", "deretraction_speed", "retract_before_wipe", "retract_restart_extra", "retraction_minimum_travel", "wipe", "wipe_distance", "retract_when_changing_layer", "retract_length_toolchange", "retract_restart_extra_toolchange", "extruder_colour", diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 68d18bccf2c..e2ab5b31aa9 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -320,6 +320,15 @@ static std::unordered_mapNozzleTypeStrToEumn = { {"brass", NozzleType::ntBrass} }; +// How the printer wires its ACE units to its toolheads. These are the firmware's own three +// words - SET_ACE_MODE MODE=normal|multi|head - so the value can be sent and read back without +// a translation table, and matches AceSnapshot::mode as the printer reports it. +enum AceMode { + amNormal = 0, // stock feeders only, no ACE + amHead, // each head is a feeder, or wired to exactly one ACE + amMulti // units pooled onto a single ACE head +}; + // BBS enum PrinterStructure { psUndefine=0, @@ -488,6 +497,7 @@ CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(PrintHostType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(AuthorizationType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(WipeTowerWallType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(PerimeterGeneratorType) +CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(AceMode) #undef CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS @@ -1236,6 +1246,9 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionBool, auxiliary_fan)) ((ConfigOptionBool, support_air_filtration)) ((ConfigOptionEnum,printer_structure)) + ((ConfigOptionEnum, ace_mode)) + ((ConfigOptionInts, ace_head_capacity)) + ((ConfigOptionInts, ace_head_unit)) ((ConfigOptionBool, support_chamber_temp_control)) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 63d1f5f92b1..036be9e2e07 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -578,6 +578,12 @@ wxDEFINE_EVENT(EVT_ADD_CUSTOM_FILAMENT, ColorEvent); #define PRINTER_PANEL_SIZE_SMALL (wxSize(FromDIP(98), FromDIP(68))) #define PRINTER_PANEL_SIZE_WIDEN (wxSize(FromDIP(136), FromDIP(68))) #define PRINTER_PANEL_SIZE (wxSize(FromDIP(98), FromDIP(98))) +#define SYNC_PANEL_SIZE (wxSize(FromDIP(106), FromDIP(106))) +#define PLATE_PANEL_SIZE (wxSize(FromDIP(106), FromDIP(106))) +// Doc 17 measured the plate against a 44x40 slot; the card has room for more than that, and the +// notches are what the silhouette exists to carry, so they get the pixels. The drawing still +// letterboxes - the plate is taller than wide, and squashing it would change those proportions. +#define PLATE_SWATCH_SIZE (wxSize(FromDIP(56), FromDIP(51))) // Nozzle diameter selection when multiple diameters are reported (e.g. U1 sync). // diameters_raw: list from device (may have duplicates or fewer than 4). Dedup and full-list logic inside. @@ -887,274 +893,43 @@ int SidebarProps::TitlebarMargin() { return 8; } // Use as side margins on titl int SidebarProps::ContentMargin() { return 12; } // Use as side margins contents of title int SidebarProps::IconSpacing() { return 10; } // Use on main elements int SidebarProps::ElementSpacing() { return 5; } // Use if elements has relation between them like edit button for combo box etc. -// CustomNotebook.h -#pragma once -#include -#include - -class CustomNotebook : public wxControl -{ -public: - CustomNotebook(wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize) - : wxControl(parent, id, pos, size, wxBORDER_NONE), m_selectedIndex(-1), m_tabHeight(24), m_tabPadding(10), m_roundRadius(5) - { - SetBackgroundStyle(wxBG_STYLE_PAINT); - UpdateColors(); - - Bind(wxEVT_PAINT, &CustomNotebook::OnPaint, this); - Bind(wxEVT_ERASE_BACKGROUND, &CustomNotebook::OnEraseBackground, this); - Bind(wxEVT_LEFT_DOWN, &CustomNotebook::OnLeftDown, this); - Bind(wxEVT_SIZE, &CustomNotebook::OnSize, this); - } - - void AddPage(wxWindow* page, const wxString& text) - { - m_tabs.push_back({text, page}); - if (page) { - page->Reparent(this); - page->Hide(); - page->SetBackgroundColour(m_selectedTabColor); - } - - if (m_selectedIndex == -1) { - SetSelection(0); - } - - UpdateLayout(); - Refresh(); - } - - void DeleteAllPages() - { - for (auto& tab : m_tabs) { - if (tab.page) { - tab.page->Destroy(); - } - } - m_tabs.clear(); - m_selectedIndex = -1; - UpdateLayout(); - Refresh(); - } - - size_t GetPageCount() const { return m_tabs.size(); } - - wxWindow* GetPage(size_t index) const { return (index < m_tabs.size()) ? m_tabs[index].page : nullptr; } - - int GetSelection() const { return m_selectedIndex; } - - void SetSelection(size_t index) - { - if (index >= m_tabs.size() || static_cast(index) == m_selectedIndex) - return; - - if (m_selectedIndex != -1 && m_tabs[m_selectedIndex].page) { - m_tabs[m_selectedIndex].page->Hide(); - } - - m_selectedIndex = index; - - if (m_selectedIndex != -1 && m_tabs[m_selectedIndex].page) { - m_tabs[m_selectedIndex].page->Show(); - } - - UpdateLayout(); - Refresh(); - } - -protected: - void OnPaint(wxPaintEvent& event) - { - UpdateColors(); - - wxPaintDC dc(this); - - // 1. 绘制背景 - dc.SetPen(*wxTRANSPARENT_PEN); - dc.SetBrush(wxBrush(m_bgColor)); - dc.DrawRectangle(GetClientRect()); - - // 2. 绘制标签背景区域 - dc.SetPen(wxPen(m_dividerColor, 1)); - dc.SetBrush(wxBrush(m_dividerColor)); - wxRect labelRect(0, 0, GetSize().x, m_tabHeight); - dc.DrawRoundedRectangle(labelRect, m_roundRadius); - dc.DrawRectangle(0, m_tabHeight - 2, GetSize().x, 4); - - // 3. 绘制所有标签 - wxFont font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); - font.SetPointSize(m_textSize); - dc.SetFont(font); - - auto height = dc.GetCharHeight(); - if (height > m_tabHeight - 2) { - m_tabHeight = height + 2; - Layout(); - } - - int xPos = 0; - for (size_t i = 0; i < m_tabs.size(); ++i) { - bool isSelected = static_cast(i) == m_selectedIndex; - - int textWidth, textHeight; - dc.GetTextExtent(m_tabs[i].text, &textWidth, &textHeight); - int tabWidth = textWidth + 2 * m_tabPadding; - - if (isSelected) { - dc.SetPen(wxPen(m_dividerColor, 1)); - dc.SetBrush(wxBrush(m_bgColor)); - wxRect selectedRect(xPos, 0, tabWidth, m_tabHeight + 2); - dc.DrawRectangle(selectedRect); - dc.DrawRoundedRectangle(selectedRect, m_roundRadius); - - dc.SetPen(wxPen(m_bgColor, 1)); - dc.SetBrush(wxBrush(m_bgColor)); - dc.DrawRectangle(xPos, m_tabHeight, tabWidth, 4); - } - - dc.SetTextForeground(isSelected ? m_selectedTextColor : m_textColor); - dc.DrawText(m_tabs[i].text, xPos + m_tabPadding, (m_tabHeight - textHeight) / 2); - - xPos += tabWidth; - } - - // 4. 绘制外边框 - dc.SetPen(wxPen(m_borderColor, 1)); - dc.SetBrush(*wxTRANSPARENT_BRUSH); - dc.DrawRoundedRectangle(GetClientRect(), m_roundRadius); - } - - void OnLeftDown(wxMouseEvent& event) - { - wxPoint pos = event.GetPosition(); - if (pos.y > m_tabHeight) { - event.Skip(); - return; - } - - int tabIndex = HitTest(pos); - if (tabIndex != -1 && tabIndex != m_selectedIndex) { - SetSelection(tabIndex); - Refresh(); - } - } - - void OnSize(wxSizeEvent& event) - { - UpdateLayout(); - Refresh(); - event.Skip(); - } - - void OnEraseBackground(wxEraseEvent& event) {} - -private: - struct TabInfo - { - wxString text; - wxWindow* page; - }; - - wxRect GetTabRect(size_t index) const - { - if (index >= m_tabs.size()) - return wxRect(); - - wxClientDC dc(const_cast(this)); - wxFont font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); - font.SetPointSize(m_textSize); - dc.SetFont(font); - - int textWidth, textHeight; - dc.GetTextExtent(m_tabs[index].text, &textWidth, &textHeight); - int tabWidth = textWidth + 2 * m_tabPadding; - - int x = 0; - for (size_t i = 0; i < index; ++i) { - dc.GetTextExtent(m_tabs[i].text, &textWidth, &textHeight); - x += textWidth + 2 * m_tabPadding; - } - - return wxRect(x, 0, tabWidth, m_tabHeight); - } - - int HitTest(const wxPoint& pt) const - { - if (pt.y > m_tabHeight) - return -1; - - wxClientDC dc(const_cast(this)); - wxFont font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); - font.SetPointSize(m_textSize); - dc.SetFont(font); - - int xPos = 0; - for (size_t i = 0; i < m_tabs.size(); ++i) { - int textWidth, textHeight; - dc.GetTextExtent(m_tabs[i].text, &textWidth, &textHeight); - int tabWidth = textWidth + 2 * m_tabPadding; - - if (pt.x >= xPos && pt.x <= xPos + tabWidth) { - return i; - } - - xPos += tabWidth; - } - - return -1; - } - - void UpdateColors() - { - bool is_dark = wxGetApp().app_config->get("dark_color_mode") == "1"; - - if (!is_dark) { - m_bgColor = wxColour(255, 255, 255); - m_borderColor = wxColour(240, 240, 240); - m_selectedTabColor = wxColour(255, 255, 255); - m_textColor = wxColour(194, 194, 193); - m_dividerColor = wxColour(240, 240, 240); - m_selectedTextColor = wxColour(0, 0, 0); - } else { - m_bgColor = wxColour(45, 45, 49); - m_borderColor = wxColour(76, 76, 85); - m_selectedTabColor = wxColour(45, 45, 49); - m_textColor = wxColour(104, 105, 107); - m_dividerColor = wxColour(51, 51, 55); - m_selectedTextColor = wxColour(255, 255, 255); - } - } - - void UpdateLayout() - { - if (m_selectedIndex != -1 && m_tabs[m_selectedIndex].page) { - wxSize size = GetSize(); - m_tabs[m_selectedIndex].page->SetSize(2, m_tabHeight + 1, size.x - 4, size.y - m_tabHeight - 4); - m_tabs[m_selectedIndex].page->Layout(); - } +// One asset per bed type. Snapmaker's three U1 plates are photographed from its product shots; +// Cool Steel and the advanced-mode plates are drawn, because Snapmaker does not sell Cool Steel +// and the nearest real product is somebody else's - using it would brand a *bed type*, which is +// an abstract setting, with a vendor the app never otherwise mentions. All seven carry the same +// measured silhouette in their alpha, so photographed and drawn plates sit in one shape. +// +// Keyed off the enum value and never off the label: the same BedType is named differently +// depending on the printer, and s_keys_map_BedType crosses the identifiers against their strings +// ("Textured PEI Plate" is btPTE, "High Temp Plate" is btPEI). +static const char *plate_icon_name(BedType bt) +{ + switch (bt) { + case btPTE: return "plate_textured_pei"; // Textured PEI - photographed + case btPEI: return "plate_smooth_pei"; // Smooth PEI / high temp - photographed + case btGESP: return "plate_graphic_effect"; // Graphic Effect - photographed + case btSuperTack: return "plate_cool_steel"; // Cool Steel - drawn + case btPC: return "plate_cool"; // drawn + case btEP: return "plate_engineering"; // drawn + case btPCT: return "plate_textured_cool"; // drawn + default: return nullptr; + } +} + +// Null bitmap when the bed type has no art, or the asset is missing: callers draw nothing +// rather than paint a placeholder that claims to be a plate. +static wxBitmap plate_bitmap(wxWindow *win, BedType bt, int height_dip) +{ + const char *name = plate_icon_name(bt); + if (!name) + return wxNullBitmap; + try { + return create_scaled_bitmap(name, win, height_dip); + } catch (...) { + return wxNullBitmap; } - -private: - std::vector m_tabs; - int m_selectedIndex; - - wxColour m_bgColor; - wxColour m_borderColor; - wxColour m_selectedTabColor; - wxColour m_textColor; - wxColour m_selectedTextColor; - wxColour m_dividerColor; - - int m_tabHeight; - int m_tabPadding; - int m_roundRadius; -#ifdef _WIN32 - int m_textSize = 10; -#else - int m_textSize = 13; -#endif -}; +} struct Sidebar::priv { @@ -1238,13 +1013,22 @@ struct Sidebar::priv // BBS printer config StaticBox* m_panel_printer_title = nullptr; ScalableButton* m_printer_icon = nullptr; + StaticBox* m_panel_sync_info = nullptr; ScalableButton* m_printerinfo_syncbtn = nullptr; ScalableButton* m_printer_setting = nullptr; wxStaticText* m_text_printer_settings = nullptr; wxPanel* m_panel_printer_content = nullptr; - // nozzle notebook and related controls - CustomNotebook* m_nozzle_notebook{nullptr}; + // The plate card, and the ACE mode row above the head boxes + StaticBox* panel_plate_preset = nullptr; + wxPanel* m_plate_swatch = nullptr; + wxPanel* m_panel_ace_mode = nullptr; + ComboBox* m_ace_mode_list = nullptr; + Label* m_ace_mode_hint = nullptr; + + // One bordered box per toolhead, wrapped two across; rebuilt by update_nozzle_settings() + wxPanel* m_panel_heads = nullptr; + wxGridSizer* m_heads_sizer = nullptr; std::vector m_nozzle_diameter_lists; std::vector m_nozzle_edit_btns; @@ -2113,148 +1897,6 @@ Sidebar::Sidebar(Plater *parent) p->m_printer_icon = new ScalableButton(p->m_panel_printer_title, wxID_ANY, "printer"); p->m_text_printer_settings = new Label(p->m_panel_printer_title, _L("Printer"), LB_PROPAGATE_MOUSE_EVENT); - // Use ams_fila_sync icon (sync_nozzle_info.svg does not exist in resources) - p->m_printerinfo_syncbtn = new ScalableButton(p->m_panel_printer_title, wxID_ANY, "nozzle_sync"); - p->m_printerinfo_syncbtn->SetCursor(wxCURSOR_HAND); - p->m_printerinfo_syncbtn->SetToolTip(_L("Synchronize nozzle information")); - p->m_printerinfo_syncbtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { - bool hasConnectDevice = false; - auto devices = wxGetApp().app_config->get_devices(); - for (const auto& device : devices) { - if (device.connected) - hasConnectDevice = true; - } - - if (!hasConnectDevice) - { - // showdialog tips no connect device - wxTheApp->CallAfter([this]() { - MessageDialog dlg(wxGetApp().mainframe, - _L("Printer not connected. Please go to the home page or the device page to connect the printer."), - _L("Note"), wxOK); - dlg.ShowModal(); - }); - return; - } - - std::string machine_type = ""; - std::vector nozzle_diameters; - std::string device_name = ""; - std::shared_ptr host = nullptr; - wxGetApp().get_connect_host(host); - const bool got_machine_info = SSWCP::query_machine_info(host, machine_type, nozzle_diameters, device_name); - - const auto& sync_nozzle_slots = wxGetApp().preset_bundle->m_connect_machine_info_list; - if (!sync_nozzle_slots.empty()) { - nozzle_diameters.clear(); - for (const auto& slot : sync_nozzle_slots) { - std::string nd = slot.nozzle_info; - boost::algorithm::trim(nd); - if (nd.size() > 2 && boost::iends_with(nd, "mm")) { - nd.resize(nd.size() - 2); - boost::algorithm::trim(nd); - } - if (!nd.empty()) - nozzle_diameters.push_back(nd); - } - } - if (got_machine_info && machine_type == "Snapmaker U1") - { - if (nozzle_diameters.size() <= 0) - { - wxTheApp->CallAfter([this]() { - MessageDialog dlgEx(wxGetApp().mainframe, - _L("No nozzle information detected. Please go to the printer settings to configure the nozzle."), - _L("Note"), wxOK); - dlgEx.ShowModal(); - }); - - return; - } - - bool res = false; - std::string headNozzleSize = nozzle_diameters[0]; - for (int i = 1; i < nozzle_diameters.size(); i++) - { - if (headNozzleSize != nozzle_diameters[i]) - { - res = true; - break; - } - } - - if (res) - { - std::vector diameters_raw = nozzle_diameters; - //std::vector diameters_raw = {"0.2", "0.8"}; - wxTheApp->CallAfter([this, diameters_raw]() { - NozzleDiameterSelectDialog dlg( - wxGetApp().mainframe, - _L("Note: Inconsistent nozzle diameters. Current version does not support mixed diameter printing. Please select one nozzle for this print."), - _L("Set Nozzle Diameter"), - diameters_raw); - if (dlg.ShowModal() == wxID_OK) { - std::string sel = dlg.GetSelectedDiameter(); - if (!sel.empty()) { - auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, sel); - if (preset) { - preset->is_visible = true; - - auto diameter = sel; - auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, diameter); - if (preset == nullptr) { - BOOST_LOG_TRIVIAL(error) << "get the similar printer preset fail"; - return; - } - preset->is_visible = true; // force visible - - for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) { - p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); - } - - wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); - wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); - wxGetApp().plater()->sidebar().update_nozzle_settings(true); - } - } - } - }); - return; - } - else { - // All tool heads report the same diameter: apply it without opening the picker. - std::string diameter = headNozzleSize; - boost::algorithm::trim(diameter); - if (diameter.size() > 2 && boost::iends_with(diameter, "mm")) { - diameter.resize(diameter.size() - 2); - boost::algorithm::trim(diameter); - } - wxTheApp->CallAfter([this, diameter]() { - auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, diameter); - if (preset == nullptr) { - BOOST_LOG_TRIVIAL(error) << "get the similar printer preset fail (uniform nozzle sync)"; - return; - } - preset->is_visible = true; - - for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) - p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); - - wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); - wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); - wxGetApp().plater()->sidebar().update_nozzle_settings(true); - - wxTheApp->CallAfter([this]() { - MessageDialog dlg_Ex(wxGetApp().mainframe, _L("Nozzle settings synchronized successfully"), - _L("Note"), wxOK); - dlg_Ex.ShowModal(); - }); - }); - } - } - - }); - p->m_printer_setting = new ScalableButton(p->m_panel_printer_title, wxID_ANY, "settings"); p->m_printer_setting->SetToolTip(_L("settings")); p->m_printer_setting->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { @@ -2266,8 +1908,6 @@ Sidebar::Sidebar(Plater *parent) h_sizer_title->AddSpacer(FromDIP(SidebarProps::ElementSpacing())); h_sizer_title->Add(p->m_text_printer_settings, 0, wxALIGN_CENTER); h_sizer_title->AddStretchSpacer(); - h_sizer_title->Add(p->m_printerinfo_syncbtn, 0, wxALIGN_CENTER); - h_sizer_title->wxSizer::AddSpacer(FromDIP(10)); h_sizer_title->Add(p->m_printer_setting, 0, wxALIGN_CENTER); h_sizer_title->AddSpacer(FromDIP(SidebarProps::TitlebarMargin())); h_sizer_title->SetMinSize(-1, 3 * em); @@ -2299,21 +1939,24 @@ Sidebar::Sidebar(Plater *parent) std::pair(wxColour(0x00AE42), StateColor::Hovered), std::pair(wxColour(0xEEEEEE), StateColor::Normal)); + // Three cards across the top - printer, plate, sync - at equal height, the shape the + // panel design takes from Bambu Studio's. The plate card absorbs what used to be a + // full-width "Bed type" row, which is where the room for the head boxes comes from. p->panel_printer_preset = new StaticBox(p->m_panel_printer_content); p->panel_printer_preset->SetCornerRadius(8); p->panel_printer_preset->SetBorderColor(panel_bd_col); - p->panel_printer_preset->SetMinSize(PRINTER_PANEL_SIZE_SMALL); + p->panel_printer_preset->SetMinSize(PRINTER_PANEL_SIZE); p->panel_printer_preset->Bind(wxEVT_LEFT_DOWN, [this](auto& evt) { p->combo_printer->wxEvtHandler::ProcessEvent(evt); }); PlaterPresetComboBox* combo_printer = new PlaterPresetComboBox(p->panel_printer_preset, Preset::TYPE_PRINTER); combo_printer->SetWindowStyle(combo_printer->GetWindowStyle() & ~wxALIGN_MASK | wxALIGN_LEFT); combo_printer->SetBorderWidth(0); - + ScalableBitmap bitmap_printer(p->panel_printer_preset, "printer_placeholder", 48); p->image_printer = new wxStaticBitmap(p->panel_printer_preset, wxID_ANY, bitmap_printer.bmp(), wxDefaultPosition, PRINTER_THUMBNAIL_SIZE, 0); p->image_printer->Bind(wxEVT_LEFT_DOWN, [this](auto& evt) { p->combo_printer->wxEvtHandler::ProcessEvent(evt); }); - + p->combo_printer = combo_printer; // 绑定 combo 内部按钮的事件处理(按钮现在在 combo 内部) @@ -2322,7 +1965,7 @@ Sidebar::Sidebar(Plater *parent) if (combo_printer->switch_to_tab()) p->editing_filament = 0; }); - + combo_printer->bind_connection_button_handler([this]() { wxGetApp().sm_disconnect_current_machine(); PhysicalPrinterDialog dlg(this->GetParent()); @@ -2332,10 +1975,10 @@ Sidebar::Sidebar(Plater *parent) combo_printer->bind_machine_connecting_button_handler([this]() { // machine_connecting_btn 的处理逻辑(如果有的话) }); - + // 显示编辑按钮(鼠标悬停时原来会显示,现在直接显示) combo_printer->set_show_edit_button(true); - + // 设置按钮tooltip combo_printer->set_connection_tooltip(_L("Connect to printer")); combo_printer->set_machine_connecting_tooltip(_L("The machine has been connected and is currently in working mode")); @@ -2348,50 +1991,71 @@ Sidebar::Sidebar(Plater *parent) wxBoxSizer* vsizer_printer = new wxBoxSizer(wxVERTICAL); wxBoxSizer* hsizer_printer = new wxBoxSizer(wxHORIZONTAL); - wxBoxSizer* vsizer = new wxBoxSizer(wxVERTICAL); - wxBoxSizer* hsizer = new wxBoxSizer(wxHORIZONTAL); + // Thumbnail over the preset name, not beside it: the card is a picture of the machine + // with its name under it, and the row only has to be one card tall. + wxBoxSizer* vsizer_preset = new wxBoxSizer(wxVERTICAL); + vsizer_preset->AddSpacer(FromDIP(6)); + vsizer_preset->Add(p->image_printer, 0, wxALIGN_CENTER_HORIZONTAL); + vsizer_preset->AddStretchSpacer(); + vsizer_preset->Add(combo_printer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(4)); + p->panel_printer_preset->SetSizer(vsizer_preset); - // 简化后的布局:打印机图片 + combo_printer(内含所有按钮) - combo_printer->SetWindowStyle(combo_printer->GetWindowStyle() & ~wxALIGN_MASK | wxALIGN_LEFT); + hsizer_printer->Add(p->panel_printer_preset, 1, wxEXPAND, 0); - hsizer->Add(p->image_printer, 0, wxLEFT | wxALIGN_CENTER, FromDIP(4)); - hsizer->Add(combo_printer, 1, wxALIGN_CENTRE | wxLEFT | wxRIGHT, FromDIP(6)); - hsizer->AddSpacer(FromDIP(10)); - p->panel_printer_preset->SetSizer(hsizer); + /* ---------------------------- the plate card ---------------------------- */ + // Bed type used to own a whole row of the sidebar for one combo. As a card it costs + // nothing extra: it sits in the height the printer card already takes. + p->panel_plate_preset = new StaticBox(p->m_panel_printer_content); + p->panel_plate_preset->SetCornerRadius(8); + p->panel_plate_preset->SetBorderColor(panel_bd_col); + p->panel_plate_preset->SetMinSize(PLATE_PANEL_SIZE); + p->panel_plate_preset->SetMaxSize(PLATE_PANEL_SIZE); + p->panel_plate_preset->SetCursor(wxCURSOR_HAND); + // The picture is the target, not just the narrow combo under it - the same forwarding the + // printer card uses for its preset combo. The info glyph is a button and keeps its own + // clicks, so it still reaches the wiki rather than opening the list. + p->panel_plate_preset->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& evt) { + m_bed_type_list->wxEvtHandler::ProcessEvent(evt); + }); - hsizer_printer->Add(p->panel_printer_preset, 1, wxEXPAND, 0); - vsizer_printer->AddSpacer(FromDIP(4)); - vsizer_printer->Add(hsizer_printer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(4)); - vsizer_printer->AddSpacer(FromDIP(10)); - - /*vsizer_printer->AddSpacer(FromDIP(16)); - hsizer_printer->Add(p->image_printer, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(combo_printer, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(edit_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(FromDIP(8), 0, 0, 0, 0); - hsizer_printer->Add(connection_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(machine_connecting_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(FromDIP(8), 0, 0, 0, 0); - - vsizer_printer->Add(hsizer_printer, 0, wxEXPAND, 0);*/ - - // Bed type selection - // 创建一个像打印机选择那样的容器 - p->panel_printer_preset = new StaticBox(p->m_panel_printer_content, wxID_ANY, wxDefaultPosition, wxDefaultSize, - wxTAB_TRAVERSAL | wxBORDER_NONE); - p->panel_printer_preset->SetCornerRadius(8); - StateColor panel_bd_col1(std::pair(wxColour(0x00AE42), StateColor::Pressed), - std::pair(wxColour(0x00AE42), StateColor::Hovered), - std::pair(wxColour(0xEEEEEE), StateColor::Normal)); - // p->panel_printer_preset->SetBorderColor(panel_bd_col1); - // p->panel_printer_preset->SetMinSize(PRINTER_PANEL_SIZE_SMALL); - - // 创建Bed type选择控件 - wxBoxSizer* bed_type_sizer = new wxBoxSizer(wxHORIZONTAL); - wxStaticText* bed_type_title = new wxStaticText(p->panel_printer_preset, wxID_ANY, _L("Bed type")); - bed_type_title->Wrap(-1); - bed_type_title->SetFont(Label::Body_14); - m_bed_type_list = new ComboBox(p->panel_printer_preset, wxID_ANY, wxString(""), wxDefaultPosition, {-1, FromDIP(30)}, 0, nullptr, wxCB_READONLY); + // The swatch is drawn, not photographed: a plate picture would be either a vendor's + // product shot (which this repository cannot redistribute) or a new asset per bed type. + // A texture that says smooth-vs-grained is all the card needs to say at this size. + p->m_plate_swatch = new wxPanel(p->panel_plate_preset, wxID_ANY, wxDefaultPosition, PLATE_SWATCH_SIZE); + wxPanel* plate_swatch = p->m_plate_swatch; + plate_swatch->SetBackgroundStyle(wxBG_STYLE_PAINT); + plate_swatch->SetCursor(wxCURSOR_HAND); + plate_swatch->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& evt) { + m_bed_type_list->wxEvtHandler::ProcessEvent(evt); + }); + plate_swatch->Bind(wxEVT_PAINT, [plate_swatch](wxPaintEvent&) { + wxAutoBufferedPaintDC dc(plate_swatch); + dc.SetBackground(wxBrush(plate_swatch->GetParent()->GetBackgroundColour())); + dc.Clear(); + // Read the bed type itself, never the combo's index: a U1 is offered a different and + // shorter list (enum_values_u1) than every other printer, so position means nothing. + const BedType bt = wxGetApp().preset_bundle->project_config.opt_enum("curr_bed_type"); + const wxBitmap bmp = plate_bitmap(plate_swatch, bt, 51); + if (!bmp.IsOk()) + return; + const wxRect r = plate_swatch->GetClientRect(); + dc.DrawBitmap(bmp, r.x + (r.width - bmp.GetWidth()) / 2, + r.y + (r.height - bmp.GetHeight()) / 2, true); + }); + + + // The wiki link keeps its home: the label was the link, and the card's info glyph is it now. + ScalableButton* plate_info_btn = new ScalableButton(p->panel_plate_preset, wxID_ANY, "info", wxEmptyString, + wxDefaultSize, wxDefaultPosition, + wxBU_EXACTFIT | wxNO_BORDER, false, 14); + plate_info_btn->SetCursor(wxCURSOR_HAND); + plate_info_btn->SetToolTip(_L("Bed types and what they are for")); + plate_info_btn->Bind(wxEVT_BUTTON, [](wxCommandEvent&) { + wxLaunchDefaultBrowser("https://github.com/SoftFever/OrcaSlicer/wiki/bed-types"); + }); + + m_bed_type_list = new ComboBox(p->panel_plate_preset, wxID_ANY, wxString(""), wxDefaultPosition, {-1, FromDIP(24)}, 0, nullptr, wxCB_READONLY); + m_bed_type_list->SetBorderWidth(0); const ConfigOptionDef* bed_type_def = print_config_def.get("curr_bed_type"); if (bed_type_def && bed_type_def->enum_keys_map) { for (const auto& item : bed_type_def->enum_labels) @@ -2399,25 +2063,8 @@ Sidebar::Sidebar(Plater *parent) for (const auto& v : bed_type_def->enum_values) m_bed_type_combo_enum_values.push_back(v); } - - // 添加链接事件等 - bed_type_title->Bind(wxEVT_ENTER_WINDOW, [bed_type_title, this](wxMouseEvent &e) { - e.Skip(); - auto font = bed_type_title->GetFont(); - font.SetUnderlined(true); - bed_type_title->SetFont(font); - SetCursor(wxCURSOR_HAND); - }); - bed_type_title->Bind(wxEVT_LEAVE_WINDOW, [bed_type_title, this](wxMouseEvent &e) { - e.Skip(); - auto font = bed_type_title->GetFont(); - font.SetUnderlined(false); - bed_type_title->SetFont(font); - SetCursor(wxCURSOR_ARROW); - }); - bed_type_title->Bind(wxEVT_LEFT_UP, [bed_type_title, this](wxMouseEvent &e) { - wxLaunchDefaultBrowser("https://github.com/SoftFever/OrcaSlicer/wiki/bed-types"); - }); + // Repaint the swatch whenever the choice changes; on_bed_type_change does the rest. + m_bed_type_list->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { refresh_plate_card(); e.Skip(); }); AppConfig *app_config = wxGetApp().app_config; std::string str_bed_type = app_config->get("curr_bed_type"); @@ -2430,41 +2077,137 @@ Sidebar::Sidebar(Plater *parent) int bed_type_idx = bed_type_value - 1; m_bed_type_list->Select(bed_type_idx); + refresh_plate_card(); + + // The glyph gets the corner to itself, so the plate can sit centred in the card rather + // than pushed off-centre by whatever shares its row. + wxBoxSizer* vsizer_plate = new wxBoxSizer(wxVERTICAL); + wxBoxSizer* hsizer_plate_info = new wxBoxSizer(wxHORIZONTAL); + hsizer_plate_info->AddStretchSpacer(); + hsizer_plate_info->Add(plate_info_btn, 0, wxRIGHT, FromDIP(3)); + vsizer_plate->AddSpacer(FromDIP(3)); + vsizer_plate->Add(hsizer_plate_info, 0, wxEXPAND); + vsizer_plate->AddStretchSpacer(); + vsizer_plate->Add(plate_swatch, 0, wxALIGN_CENTER_HORIZONTAL); + vsizer_plate->AddStretchSpacer(); + vsizer_plate->Add(m_bed_type_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(4)); + p->panel_plate_preset->SetSizer(vsizer_plate); + + hsizer_printer->Add(p->panel_plate_preset, 0, wxEXPAND | wxLEFT, FromDIP(4)); + + /* ---------------------------- the sync card ---------------------------- */ + // Sync info, as a card beside the printer rather than an unlabelled glyph in the title + // bar. The glyph said nothing about what it syncs and was a 16px hit box; a card carries + // the word, matches the printer card's height, and takes the whole click. + // Bordered like the cards beside it - StaticBox zeroes its border width under + // wxBORDER_NONE, so passing that style here would draw the border colour set below nowhere. + p->m_panel_sync_info = new StaticBox(p->m_panel_printer_content); + p->m_panel_sync_info->SetCornerRadius(8); + p->m_panel_sync_info->SetBorderColor(panel_bd_col); + p->m_panel_sync_info->SetMinSize(SYNC_PANEL_SIZE); + p->m_panel_sync_info->SetMaxSize(SYNC_PANEL_SIZE); + p->m_panel_sync_info->SetCursor(wxCURSOR_HAND); + p->m_panel_sync_info->SetToolTip(_L("Synchronize nozzle information")); + + p->m_printerinfo_syncbtn = new ScalableButton(p->m_panel_sync_info, wxID_ANY, "nozzle_sync", wxEmptyString, + wxDefaultSize, wxDefaultPosition, + wxBU_EXACTFIT | wxNO_BORDER, false, 24); + p->m_printerinfo_syncbtn->SetCursor(wxCURSOR_HAND); + p->m_printerinfo_syncbtn->SetToolTip(_L("Synchronize nozzle information")); + p->m_printerinfo_syncbtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent &) { sync_printer_info(); }); + + // LB_PROPAGATE_MOUSE_EVENT hands the label's clicks to the card, so the card's own + // handler is the only one - clicking the word cannot fire the sync twice. + auto sync_info_label = new Label(p->m_panel_sync_info, Label::Body_12, _L("Sync info"), LB_PROPAGATE_MOUSE_EVENT); + + wxBoxSizer* sync_info_sizer = new wxBoxSizer(wxVERTICAL); + sync_info_sizer->AddStretchSpacer(); + sync_info_sizer->Add(p->m_printerinfo_syncbtn, 0, wxALIGN_CENTER_HORIZONTAL); + sync_info_sizer->AddSpacer(FromDIP(4)); + sync_info_sizer->Add(sync_info_label, 0, wxALIGN_CENTER_HORIZONTAL); + sync_info_sizer->AddStretchSpacer(); + p->m_panel_sync_info->SetSizer(sync_info_sizer); + // Skip() matters: StaticBox's own state handler clears its pressed state on LEFT_UP, and + // this handler is bound later, so it runs first and would otherwise swallow that. The + // leave is synthesised because every path out of sync_printer_info() opens a modal: the + // pointer walks off to that dialog, the card never sees the crossing behind it, and it + // would sit drawn as hovered until the pointer happened to cross it again. + p->m_panel_sync_info->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) { + e.Skip(); + wxMouseEvent leave(wxEVT_LEAVE_WINDOW); + leave.SetEventObject(p->m_panel_sync_info); + p->m_panel_sync_info->GetEventHandler()->ProcessEvent(leave); + sync_printer_info(); + }); - // 布局Bed type控件 - bed_type_sizer->Add(bed_type_title, 0, wxLEFT | wxRIGHT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); - bed_type_sizer->Add(m_bed_type_list, 1, wxLEFT | wxRIGHT | wxEXPAND, FromDIP(0)); - p->panel_printer_preset->SetSizer(bed_type_sizer); + // The gap belongs to the card, not to the row: a plain spacer would survive the card + // being hidden on a non-U1 printer and leave 4px of nothing beside the plate card. + hsizer_printer->Add(p->m_panel_sync_info, 0, wxEXPAND | wxLEFT, FromDIP(4)); - // 添加到垂直布局 - vsizer_printer->Add(p->panel_printer_preset, 0, wxEXPAND | wxALL, FromDIP(4)); + vsizer_printer->AddSpacer(FromDIP(4)); + vsizer_printer->Add(hsizer_printer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(4)); vsizer_printer->AddSpacer(FromDIP(8)); auto& project_config = wxGetApp().preset_bundle->project_config; BedType bed_type = (BedType)bed_type_value; project_config.set_key_value("curr_bed_type", new ConfigOptionEnum(bed_type)); - p->m_panel_printer_content->SetSizer(vsizer_printer); - p->m_panel_printer_content->Layout(); - scrolled_sizer->Add(p->m_panel_printer_content, 0, wxEXPAND, 0); + /* ---------------------------- the ACE mode row ---------------------------- */ + // The printer's own three-way switch, mirrored. It decides whether per-head wiring means + // anything at all, so it sits above the head boxes and greys their ACE rows in Normal. + p->m_panel_ace_mode = new wxPanel(p->m_panel_printer_content, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); + p->m_panel_ace_mode->SetBackgroundColour(p->m_panel_printer_content->GetBackgroundColour()); + + auto ace_mode_label = new Label(p->m_panel_ace_mode, Label::Body_14, _L("ACE mode")); + p->m_ace_mode_list = new ComboBox(p->m_panel_ace_mode, wxID_ANY, wxString(""), wxDefaultPosition, {-1, FromDIP(30)}, 0, nullptr, wxCB_READONLY); + p->m_ace_mode_hint = new Label(p->m_panel_ace_mode, Label::Body_10, wxEmptyString); + p->m_ace_mode_hint->SetForegroundColour(wxColour(0x6B, 0x72, 0x76)); + + const ConfigOptionDef *ace_mode_def = print_config_def.get("ace_mode"); + if (ace_mode_def) + for (const auto &item : ace_mode_def->enum_labels) + p->m_ace_mode_list->AppendString(_L(item)); + p->m_ace_mode_list->SetToolTip(_L("SET_ACE_MODE MODE=normal|multi|head, the printer's own switch. " + "Setting it here records the wiring in the printer preset; it is not " + "sent to the machine yet.")); + p->m_ace_mode_list->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent &e) { + e.Skip(); + const int sel = p->m_ace_mode_list->GetSelection(); + if (sel < 0) + return; + auto &printers = wxGetApp().preset_bundle->printers; + printers.get_edited_preset().config.set_key_value("ace_mode", new ConfigOptionEnum(AceMode(sel))); + wxGetApp().get_tab(Preset::TYPE_PRINTER)->update_dirty(); + update_nozzle_settings(); + }); - // 创建Nozzle notebook的容器 - StaticBox* nozzle_container = new StaticBox(p->m_panel_printer_content, wxID_ANY, wxDefaultPosition, wxDefaultSize, - wxTAB_TRAVERSAL | wxBORDER_NONE); - nozzle_container->SetCornerRadius(8); - // nozzle_container->SetBorderColor(panel_bd_col); + wxBoxSizer* ace_mode_sizer = new wxBoxSizer(wxVERTICAL); + ace_mode_sizer->Add(ace_mode_label, 0, wxLEFT | wxBOTTOM, FromDIP(2)); + ace_mode_sizer->Add(p->m_ace_mode_list, 0, wxEXPAND); + ace_mode_sizer->Add(p->m_ace_mode_hint, 0, wxTOP | wxLEFT, FromDIP(3)); + p->m_panel_ace_mode->SetSizer(ace_mode_sizer); + vsizer_printer->Add(p->m_panel_ace_mode, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(8)); + vsizer_printer->AddSpacer(FromDIP(8)); - // 创建notebook - p->m_nozzle_notebook = new CustomNotebook(nozzle_container, wxID_ANY); + /* ---------------------------- the head boxes ---------------------------- */ + // One bordered box per toolhead, wrapped two across. update_nozzle_settings() fills it, + // because the number of heads follows the preset's nozzle_diameter. + p->m_panel_heads = new wxPanel(p->m_panel_printer_content, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); + p->m_panel_heads->SetBackgroundColour(p->m_panel_printer_content->GetBackgroundColour()); + p->m_heads_sizer = new wxGridSizer(0, 2, FromDIP(4), FromDIP(4)); + p->m_panel_heads->SetSizer(p->m_heads_sizer); + vsizer_printer->Add(p->m_panel_heads, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(8)); - // 创建nozzle_sizer并添加notebook - wxBoxSizer* nozzle_sizer = new wxBoxSizer(wxVERTICAL); - nozzle_sizer->Add(p->m_nozzle_notebook, 1, wxEXPAND | wxALL, FromDIP(0)); - nozzle_container->SetSizer(nozzle_sizer); - nozzle_container->SetMinSize(wxSize(-1, FromDIP(80))); + p->m_panel_printer_content->SetSizer(vsizer_printer); + p->m_panel_printer_content->Layout(); + scrolled_sizer->Add(p->m_panel_printer_content, 0, wxEXPAND, 0); - // 添加到主布局 - vsizer_printer->Add(nozzle_container, 0, wxEXPAND | wxALL, FromDIP(4)); + // update_presets() settles this on every preset change; do it once here too, or the card + // is briefly on screen for a printer that has nothing to sync. + const auto *sync_printer_model = wxGetApp().preset_bundle->printers.get_edited_preset() + .config.option("printer_model"); + p->m_panel_sync_info->Show(sync_printer_model && boost::icontains(sync_printer_model->value, "Snapmaker") && + boost::icontains(sync_printer_model->value, "U1")); // Initialize nozzle settings update_nozzle_settings(); @@ -3717,6 +3460,7 @@ void Sidebar::update_all_preset_comboboxes(bool reload_printer_view) m_bed_type_list->SetSelection(get_selection_index()); m_bed_type_list->Disable(); } + refresh_plate_card(); if (print_tech == ptFFF) { for (PlaterPresetComboBox* cb : p->combos_filament) @@ -3796,18 +3540,16 @@ void Sidebar::update_presets(Preset::Type preset_type) auto printer_config = wxGetApp().preset_bundle->printers.get_edited_preset().config; auto printer_model_opt = printer_config.option("printer_model"); - if (printer_model_opt) + if (printer_model_opt && p->m_panel_sync_info) { std::string printer_model = printer_model_opt->value; bool is_snapmaker_u1 = boost::icontains(printer_model, "Snapmaker") && boost::icontains(printer_model, "U1"); - if (is_snapmaker_u1) - { - p->m_printerinfo_syncbtn->Show(); - } - else - { - p->m_printerinfo_syncbtn->Hide(); + // The card sits in the printer row and takes width from it, so hiding it has to + // re-lay the row out - the title-bar glyph it replaces never did. + if (p->m_panel_sync_info->IsShown() != is_snapmaker_u1) { + p->m_panel_sync_info->Show(is_snapmaker_u1); + p->m_panel_printer_content->Layout(); } } @@ -8953,6 +8695,7 @@ void Sidebar::on_bed_type_change(BedType bed_type) int sel_idx = (int)bed_type - 1; if (m_bed_type_list != nullptr) m_bed_type_list->SetSelection(sel_idx); + refresh_plate_card(); } std::map Sidebar::build_filament_ams_list(MachineObject* obj) @@ -9327,52 +9070,261 @@ void Sidebar::show_SEMM_buttons(bool bshow) Layout(); } +// The card paints from curr_bed_type, and nothing else tells it that has moved. +void Sidebar::refresh_plate_card() +{ + if (p->m_plate_swatch) + p->m_plate_swatch->Refresh(); +} + void Sidebar::update_dynamic_filament_list() { dynamic_filament_list.update(); dynamic_filament_list_1_based.update(); } +// Read the connected machine's nozzle information into the preset. Lifted verbatim out of the +// title-bar button's lambda when that button became the Sync info card, so the card and the icon +// on it reach one place instead of two copies of it. +void Sidebar::sync_printer_info() +{ + bool hasConnectDevice = false; + auto devices = wxGetApp().app_config->get_devices(); + for (const auto& device : devices) { + if (device.connected) + hasConnectDevice = true; + } + + if (!hasConnectDevice) + { + // showdialog tips no connect device + wxTheApp->CallAfter([this]() { + MessageDialog dlg(wxGetApp().mainframe, + _L("Printer not connected. Please go to the home page or the device page to connect the printer."), + _L("Note"), wxOK); + dlg.ShowModal(); + }); + return; + } + + std::string machine_type = ""; + std::vector nozzle_diameters; + std::string device_name = ""; + std::shared_ptr host = nullptr; + wxGetApp().get_connect_host(host); + const bool got_machine_info = SSWCP::query_machine_info(host, machine_type, nozzle_diameters, device_name); + + const auto& sync_nozzle_slots = wxGetApp().preset_bundle->m_connect_machine_info_list; + if (!sync_nozzle_slots.empty()) { + nozzle_diameters.clear(); + for (const auto& slot : sync_nozzle_slots) { + std::string nd = slot.nozzle_info; + boost::algorithm::trim(nd); + if (nd.size() > 2 && boost::iends_with(nd, "mm")) { + nd.resize(nd.size() - 2); + boost::algorithm::trim(nd); + } + if (!nd.empty()) + nozzle_diameters.push_back(nd); + } + } + if (got_machine_info && machine_type == "Snapmaker U1") + { + if (nozzle_diameters.size() <= 0) + { + wxTheApp->CallAfter([this]() { + MessageDialog dlgEx(wxGetApp().mainframe, + _L("No nozzle information detected. Please go to the printer settings to configure the nozzle."), + _L("Note"), wxOK); + dlgEx.ShowModal(); + }); + + return; + } + + bool res = false; + std::string headNozzleSize = nozzle_diameters[0]; + for (int i = 1; i < nozzle_diameters.size(); i++) + { + if (headNozzleSize != nozzle_diameters[i]) + { + res = true; + break; + } + } + + if (res) + { + std::vector diameters_raw = nozzle_diameters; + //std::vector diameters_raw = {"0.2", "0.8"}; + wxTheApp->CallAfter([this, diameters_raw]() { + NozzleDiameterSelectDialog dlg( + wxGetApp().mainframe, + _L("Note: Inconsistent nozzle diameters. Current version does not support mixed diameter printing. Please select one nozzle for this print."), + _L("Set Nozzle Diameter"), + diameters_raw); + if (dlg.ShowModal() == wxID_OK) { + std::string sel = dlg.GetSelectedDiameter(); + if (!sel.empty()) { + auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, sel); + if (preset) { + preset->is_visible = true; + + auto diameter = sel; + auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, diameter); + if (preset == nullptr) { + BOOST_LOG_TRIVIAL(error) << "get the similar printer preset fail"; + return; + } + preset->is_visible = true; // force visible + + for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) { + p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); + } + + wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); + wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); + wxGetApp().plater()->sidebar().update_nozzle_settings(true); + } + } + } + }); + return; + } + else { + // All tool heads report the same diameter: apply it without opening the picker. + std::string diameter = headNozzleSize; + boost::algorithm::trim(diameter); + if (diameter.size() > 2 && boost::iends_with(diameter, "mm")) { + diameter.resize(diameter.size() - 2); + boost::algorithm::trim(diameter); + } + wxTheApp->CallAfter([this, diameter]() { + auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, diameter); + if (preset == nullptr) { + BOOST_LOG_TRIVIAL(error) << "get the similar printer preset fail (uniform nozzle sync)"; + return; + } + preset->is_visible = true; + + for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) + p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); + + wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); + wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); + wxGetApp().plater()->sidebar().update_nozzle_settings(true); + + wxTheApp->CallAfter([this]() { + MessageDialog dlg_Ex(wxGetApp().mainframe, _L("Nozzle settings synchronized successfully"), + _L("Note"), wxOK); + dlg_Ex.ShowModal(); + }); + }); + } + } +} + void Sidebar::update_nozzle_settings(bool switch_machine) { - if (!p->m_nozzle_notebook) + if (!p->m_panel_heads || !p->m_heads_sizer) return; - // Get new nozzle count - auto* nozzle_diameter = dynamic_cast( - wxGetApp().preset_bundle->printers.get_edited_preset().config.option("nozzle_diameter")); - size_t new_nozzle_count = nozzle_diameter ? nozzle_diameter->values.size() : 1; + const Preset& printer_preset = wxGetApp().preset_bundle->printers.get_edited_preset(); + const DynamicConfig& cfg = printer_preset.config; + + auto* nozzle_diameter = dynamic_cast(cfg.option("nozzle_diameter")); + size_t head_count = nozzle_diameter ? nozzle_diameter->values.size() : 1; + + // The machine's own interface calls these Toolhead N - see the strings in + // resources/web/flutter_web/assets/assets/i10n/en.json. A two-headed IDEX machine keeps the + // Left/Right names it has always had, because there the label names a position. + const auto *printer_model = cfg.option("printer_model"); + const bool is_snapmaker_u1 = printer_model && boost::icontains(printer_model->value, "Snapmaker") && + boost::icontains(printer_model->value, "U1"); + + const auto *ace_mode_opt = cfg.option>("ace_mode"); + const AceMode ace_mode = ace_mode_opt ? AceMode(ace_mode_opt->value) : amNormal; + const auto *head_unit = cfg.option("ace_head_unit"); + const auto *head_cap = cfg.option("ace_head_capacity"); + + // The mode row belongs to a machine that has ACE units at all. Everything else keeps a + // sidebar with no multiACE vocabulary in it. + if (p->m_panel_ace_mode) { + if (p->m_panel_ace_mode->IsShown() != is_snapmaker_u1) + p->m_panel_ace_mode->Show(is_snapmaker_u1); + if (is_snapmaker_u1 && p->m_ace_mode_list) { + p->m_ace_mode_list->SetSelection(int(ace_mode)); + switch (ace_mode) { + case amHead: p->m_ace_mode_hint->SetLabel(_L("Each head is a feeder, or wired to one ACE")); break; + case amMulti: p->m_ace_mode_hint->SetLabel(_L("Units pooled onto a single ACE head")); break; + default: p->m_ace_mode_hint->SetLabel(_L("Stock feeders only - no ACE")); break; + } + } + } - // Clear existing pages and controls - p->m_nozzle_notebook->DeleteAllPages(); + p->m_heads_sizer->Clear(true); p->m_nozzle_diameter_lists.clear(); p->m_nozzle_edit_btns.clear(); - // Recreate pages for new nozzle count - // Create tabs for each nozzle - for (size_t i = 0; i < new_nozzle_count; i++) { - wxPanel* nozzle_panel = new wxPanel(p->m_nozzle_notebook, wxID_ANY, wxDefaultPosition, wxDefaultSize, - wxTAB_TRAVERSAL | wxBORDER_NONE); - // nozzle_panel->SetBackgroundColour(wxColour(255, 255, 255)); + const bool is_dark = wxGetApp().app_config->get("dark_color_mode") == "1"; + const wxColour row_label_colour = is_dark ? wxColour(194, 194, 194) : wxColour(0, 0, 0); - wxBoxSizer* tab_sizer = new wxBoxSizer(wxHORIZONTAL); + StateColor head_bd_col(std::pair(wxColour(0x00AE42), StateColor::Hovered), + std::pair(wxColour(0xEEEEEE), StateColor::Normal)); - // Add diameter label and combobox - wxBoxSizer* diameter_sizer = new wxBoxSizer(wxHORIZONTAL); - wxStaticText* diameter_label = new wxStaticText(nozzle_panel, wxID_ANY, _L("Diameter")); - bool is_dark = wxGetApp().app_config->get("dark_color_mode") == "1"; - if (!is_dark) { - diameter_label->SetForegroundColour(wxColor(0, 0, 0)); - } - else { - diameter_label->SetForegroundColour(wxColor(194, 194, 194)); + for (size_t i = 0; i < head_count; i++) { + StaticBox* head_box = new StaticBox(p->m_panel_heads); + head_box->SetCornerRadius(8); + head_box->SetBorderColor(head_bd_col); + + wxString head_name; + if (is_snapmaker_u1) + head_name = wxString::Format(_L("Toolhead %d"), int(i) + 1); + else if (head_count == 2) + head_name = (i == 0) ? _L("Left Nozzle") : _L("Right Nozzle"); + else if (head_count > 2) + head_name = wxString(_L("Nozzle")) + wxString::Format(" %d", i + 1); + else + head_name = _L("Nozzle"); + + auto* head_label = new Label(head_box, Label::Head_12, head_name); + head_label->SetForegroundColour(wxColour(0x4A, 0x52, 0x58)); + + wxBoxSizer* box_sizer = new wxBoxSizer(wxVERTICAL); + box_sizer->AddSpacer(FromDIP(4)); + box_sizer->Add(head_label, 0, wxLEFT | wxRIGHT, FromDIP(8)); + + // The ACE row - what feeds this head. It reads the preset only: choosing a unit is the + // assign popover, which arrives with the ACE row's live contents. In Normal mode no head + // is wired to anything, so the row says so rather than showing a stale unit. + if (is_snapmaker_u1) { + const int unit = (head_unit && i < head_unit->values.size()) ? head_unit->values[i] : -1; + const int cap = (head_cap && i < head_cap->values.size()) ? head_cap->values[i] : 1; + + wxString fed_by = _L("Stock feeder"); + if (ace_mode != amNormal && unit >= 0) + fed_by = wxString::Format(_L("ACE %d"), unit + 1) + + (cap > 1 ? wxString::Format(" · %d", cap) + " " + _L("slots") : wxString()); + + auto* ace_key = new Label(head_box, Label::Body_12, _L("ACE")); + ace_key->SetForegroundColour(row_label_colour); + auto* ace_val = new Label(head_box, Label::Body_12, fed_by); + ace_val->SetForegroundColour(ace_mode == amNormal ? wxColour(0x9A, 0x9A, 0x9A) : wxColour(0x4A, 0x52, 0x58)); + + wxBoxSizer* ace_row = new wxBoxSizer(wxHORIZONTAL); + ace_row->Add(ace_key, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + ace_row->AddStretchSpacer(); + ace_row->Add(ace_val, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + box_sizer->Add(ace_row, 0, wxEXPAND | wxTOP, FromDIP(4)); } - - diameter_label->SetFont(Label::Body_14); - ComboBox* diameter_combo = new ComboBox(nozzle_panel, wxID_ANY, wxEmptyString, wxDefaultPosition, {-1, FromDIP(32)}, 0, + // The Diameter row. Every head shows one, and every one of them writes all of them: the + // U1 refuses a mixed set, so changing any diameter switches the whole preset. + auto* diameter_label = new Label(head_box, Label::Body_12, _L("Diameter")); + diameter_label->SetForegroundColour(row_label_colour); + + ComboBox* diameter_combo = new ComboBox(head_box, wxID_ANY, wxEmptyString, wxDefaultPosition, {-1, FromDIP(26)}, 0, nullptr, wxCB_READONLY); - // Visible presets for this printer_model (system + user). Imported multi-nozzle variants are // usually non-system; diameters_for_same_printer_model() only counted system and kept the combo disabled. @@ -9381,7 +9333,7 @@ void Sidebar::update_nozzle_settings(bool switch_machine) diameter_combo->AppendString(wxString(diameter) + "mm"); } if (diameter_combo->GetCount() == 0) { - const auto *pv = wxGetApp().preset_bundle->printers.get_edited_preset().config.option("printer_variant"); + const auto *pv = cfg.option("printer_variant"); if (pv) diameter_combo->AppendString(wxString(pv->value) + "mm"); } @@ -9389,16 +9341,7 @@ void Sidebar::update_nozzle_settings(bool switch_machine) diameter_combo->Enable(false); } - diameter_combo->Bind(wxEVT_COMBOBOX, [this, diameter_combo, i](wxCommandEvent& event) { - - //auto* pNotice = p->plater->get_notification_manager(); - //if (pNotice) - //{ - // pNotice->close_notification_of_type(NotificationType::CustomNotification); - // pNotice->push_notification(_u8L("Note: Printing PLA Silk on the hot end of 0.6mm hardened steel is not recommended. 0.4mm or smaller specifications are suggested."), 0); - // pNotice->set_slicing_progress_hidden(); - //} - + diameter_combo->Bind(wxEVT_COMBOBOX, [this, diameter_combo](wxCommandEvent& event) { auto printer_config = wxGetApp().preset_bundle->printers.get_edited_preset().config; auto printer_model_opt = printer_config.option("printer_model"); if (printer_model_opt) { @@ -9413,14 +9356,14 @@ void Sidebar::update_nozzle_settings(bool switch_machine) { RichMessageDialog dlg(static_cast(wxGetApp().mainframe), _L("Note: Changing this will sync all other nozzles to the same diameter."), - _L("Set Nozzle Diameter"), + _L("Set Nozzle Diameter"), wxOK); dlg.ShowCheckBox(_L("Don't show this again"), false); auto res = dlg.ShowModal(); bool isCheckBox = dlg.IsCheckBoxChecked(); if (wxID_OK == res) - wxGetApp().app_config->set("app", "sync_diameter_flags", isCheckBox); + wxGetApp().app_config->set("app", "sync_diameter_flags", isCheckBox); } } } @@ -9432,7 +9375,7 @@ void Sidebar::update_nozzle_settings(bool switch_machine) return; } preset->is_visible = true; // force visible - + for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) { //set all nozzle use the diameter p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); @@ -9441,52 +9384,30 @@ void Sidebar::update_nozzle_settings(bool switch_machine) wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); // Do not event.Skip(): select_preset rebuilds nozzle UI and can destroy this combo; skipping would let sidebar treat this as bed-type combo and use-after-free. }); - - auto diam_str = wxGetApp().preset_bundle->printers.get_edited_preset().config.option("printer_variant")->value; - - diameter_combo->SetValue(diam_str + "mm"); - - p->m_nozzle_diameter_lists.push_back(diameter_combo); - diameter_sizer->AddSpacer(15); - diameter_sizer->Add(diameter_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); - diameter_sizer->AddSpacer(10); - diameter_sizer->Add(diameter_combo, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(15)); + const auto *variant = cfg.option("printer_variant"); + diameter_combo->SetValue((variant ? wxString(variant->value) : wxString("0.4")) + "mm"); - // 删除Flow相关控件 - - tab_sizer->Add(diameter_sizer, 1, wxEXPAND | wxALIGN_CENTER_VERTICAL); - - nozzle_panel->SetSizer(tab_sizer); + p->m_nozzle_diameter_lists.push_back(diameter_combo); - // Add tab - wxString tab_name = ""; - switch (new_nozzle_count) - { - case 1: - { - tab_name = _L("Nozzle"); - break; - } - case 2: - { - if (i == 0) - tab_name = _L("Left Nozzle"); - else - tab_name = _L("Right Nozzle"); + wxBoxSizer* diameter_row = new wxBoxSizer(wxHORIZONTAL); + diameter_row->Add(diameter_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + diameter_row->Add(diameter_combo, 1, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(8)); + box_sizer->Add(diameter_row, 0, wxEXPAND | wxTOP, FromDIP(4)); + box_sizer->AddSpacer(FromDIP(6)); - break; - } - default: - { - tab_name = wxString(_L("Nozzle")) + wxString::Format(" %d", i + 1); - } - - } - p->m_nozzle_notebook->AddPage(nozzle_panel, tab_name); + head_box->SetSizer(box_sizer); + p->m_heads_sizer->Add(head_box, 1, wxEXPAND); } - p->m_nozzle_notebook->Layout(); + // An odd head count would leave the grid's last row half empty and stretched; a filler keeps + // every box the same width as the ones above it. + if (head_count % 2) + p->m_heads_sizer->AddStretchSpacer(); + + p->m_panel_heads->Layout(); + p->m_panel_printer_content->Layout(); + m_scrolled_sizer->Layout(); if (switch_machine) { p->combo_printer->SetFocus(); diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 4c79e678bcc..b2ed57669ef 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -187,6 +187,8 @@ class Sidebar : public wxPanel void update_dynamic_filament_list(); void update_nozzle_settings(bool switch_machine = false); + void sync_printer_info(); + void refresh_plate_card(); ObjectList* obj_list(); ObjectSettings* obj_settings(); From ad3295038c45369574389b8dfa3e1662e13f0a80 Mon Sep 17 00:00:00 2001 From: physicsG Date: Mon, 24 Aug 2026 12:00:51 +0200 Subject: [PATCH 2/5] fix(profiles): correct a filename case that breaks every Snapmaker preset on Linux (#41) One process profile is on disk as 0.10mm Color Mixing @Snapmaker U1 (0.4 Nozzle).json while Snapmaker.json lists it, and the preset inside it names itself, with a lowercase "nozzle". It is the only one of 198 profile filenames spelled that way; every other file, every manifest entry and every compatible_printers string uses "nozzle". Windows and macOS have case-insensitive filesystems and never notice. On Linux the file is not found, load_vendor_configs_from_json fails on it, and one missing file of 570 takes the whole vendor bundle down with it. No Snapmaker machine preset loads at all: load_from_json: parse .../0.10mm Color Mixing @Snapmaker U1 (0.4 nozzle).json got a parse_error load_vendor_configs_from_json, got error when parse process setting from ... can not find parent for config .../user/default/machine/.json! The printer combo comes up empty, the sidebar draws NO IMAGE with a blank nozzle diameter, and any user preset inheriting a Snapmaker machine is orphaned. A clean install on Linux cannot select a printer. The fix is the filename rather than the manifest: the manifest entry, the preset's own "name" field and its compatible_printers all already agree on lowercase, so the file is the outlier. The vendor version goes to 02.02.55.03 as well. Without it the corrected file only reaches new installs - check_installed_vendor_profiles() reinstalls from resources only when the resource bundle is newer than the one in the datadir, so anyone already carrying 02.02.55.02 would keep the broken set. Verified on Linux against a datadir in the failing state: zero parse errors, zero orphaned parents, all 570 manifest entries resolving, and the printer selectable again. --- resources/profiles/Snapmaker.json | 2 +- ...json => 0.10mm Color Mixing @Snapmaker U1 (0.4 nozzle).json} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename resources/profiles/Snapmaker/process/{0.10mm Color Mixing @Snapmaker U1 (0.4 Nozzle).json => 0.10mm Color Mixing @Snapmaker U1 (0.4 nozzle).json} (100%) diff --git a/resources/profiles/Snapmaker.json b/resources/profiles/Snapmaker.json index 2ded4236485..f954b80efef 100644 --- a/resources/profiles/Snapmaker.json +++ b/resources/profiles/Snapmaker.json @@ -1,6 +1,6 @@ { "name": "Snapmaker", - "version": "02.02.55.02", + "version": "02.02.55.03", "force_update": "0", "description": "Snapmaker configurations", "machine_model_list": [ diff --git a/resources/profiles/Snapmaker/process/0.10mm Color Mixing @Snapmaker U1 (0.4 Nozzle).json b/resources/profiles/Snapmaker/process/0.10mm Color Mixing @Snapmaker U1 (0.4 nozzle).json similarity index 100% rename from resources/profiles/Snapmaker/process/0.10mm Color Mixing @Snapmaker U1 (0.4 Nozzle).json rename to resources/profiles/Snapmaker/process/0.10mm Color Mixing @Snapmaker U1 (0.4 nozzle).json From 30635451cd14b48468b77b86c97237d2b7d02c5b Mon Sep 17 00:00:00 2001 From: physicsG Date: Tue, 1 Sep 2026 21:24:37 +0200 Subject: [PATCH 3/5] The U1 Device page, rebuilt as HTML/JS (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconstructs the U1 **Device page** — the webview Orca shows for a connected printer — as plain HTML/JS, alongside the shipped Flutter bundle. `PrinterWebView` has an **Original / Rebuilt** switcher and the choice persists in `u1_reconstructed_ui`, so nothing is replaced: both are there and either can be shown. Cut from `develop/add-multiace-support`, 0 behind, so it merges clean. ## What is in it | | | |---|---| | `resources/web/device_page/` | The reconstructed Device page: two destinations (Device control, Storage), five panels, one directory per panel | | `resources/web/shared/` | Bridge client, protocol constants, state store, a simulated printer, the fault catalogue | | `resources/web/print_processing/` | The print-processing popup, same treatment | | `docs/u1-webui/` | The reverse engineering it was built from, and 19 extractors that regenerate every data file | | `src/slic3r/` | The host side: answer every page request, survive a printer that is not there | `js/registry.js` is the table of contents — destinations and their panels — and everything inside `.content` is built from it, so `index.html` is 42 lines of shell. A panel is handed its own commands and nothing else, which makes reaching for another panel's command a `TypeError` rather than a quiet dependency. ## Where to start reading [`docs/u1-webui/STATUS.md`](docs/u1-webui/STATUS.md) — what is proven against hardware, what is not, and what to pick up next. It is deliberately explicit about the second: the **Not verified** section names what was built against the simulator only, because that proves the client and the simulation agree and not that the printer does. --- CLAUDE.md | 199 + docs/ace-mmu/16-ace-visuals.md | 115 +- docs/ace-mmu/ace-visual-standard.html | 357 +- docs/u1-webui/00-shared/01-shared-models.md | 150 + docs/u1-webui/00-shared/02-build-badge.md | 81 + docs/u1-webui/00-shared/03-serving-in-orca.md | 99 + .../01-architecture/01-layer-model.md | 99 + .../01-architecture/02-bundle-inventory.md | 90 + .../01-architecture/03-entry-points.md | 95 + .../01-architecture/04-routes-detail.md | 119 + docs/u1-webui/02-device-page/01-overview.md | 93 + .../02-connection-state-machine.md | 176 + .../02-device-page/03-strings-and-controls.md | 75 + .../04-implementation-report.md | 175 + .../02-device-page/05-visual-reference.md | 212 + docs/u1-webui/02-device-page/06-connection.md | 311 + docs/u1-webui/02-device-page/07-parity.md | 146 + .../08-function-gap-analysis.md | 2038 ++ .../u1-webui/02-device-page/09-restructure.md | 435 + .../02-device-page/10-multiace-filament.md | 375 + .../02-device-page/11-multiace-handover.md | 1225 + .../camera-layout-ace-mockup.html | 2400 ++ .../02-device-page/multiace-actions.html | 3179 ++ .../02-device-page/multiace-cabinet.html | 2406 ++ .../multiace-f2-iterations.html | 1177 + .../multiace-filament-mockup.html | 790 + .../02-device-page/multiace-modes.html | 1432 + .../multiace-toolhead-card.html | 1273 + .../02-device-page/panel-popover-mockup.html | 1353 + .../screenshots/01-device-page-printing.png | Bin 0 -> 272806 bytes .../screenshots/02-toolheads.png | Bin 0 -> 50227 bytes .../screenshots/03-bed-and-chamber.png | Bin 0 -> 71586 bytes .../screenshots/04-sswcp-trace.png | Bin 0 -> 47152 bytes .../02-device-page/screenshots/05-paused.png | Bin 0 -> 42778 bytes .../screenshots/06-fault-banner.png | Bin 0 -> 15878 bytes .../screenshots/07-control-roundtrip.png | Bin 0 -> 73609 bytes .../screenshots/08-selftest.png | Bin 0 -> 295043 bytes .../screenshots/interaction-dialog.png | Bin 0 -> 72957 bytes .../screenshots/interaction-menu.png | Bin 0 -> 66363 bytes .../screenshots/original-filament.png | Bin 0 -> 33496 bytes .../screenshots/original-full.png | Bin 0 -> 108926 bytes .../screenshots/original-reference.png | Bin 0 -> 47146 bytes .../screenshots/reconstruction-filament.png | Bin 0 -> 32023 bytes .../screenshots/reconstruction-full.png | Bin 0 -> 101987 bytes .../screenshots/reconstruction-matched.png | Bin 0 -> 55363 bytes .../screenshots/reconstruction-with-badge.png | Bin 0 -> 95587 bytes .../03-print-processing/01-overview.md | 114 + .../03-print-processing/02-lifecycle.md | 139 + .../03-print-processing/03-implementation.md | 96 + .../screenshots/reconstruction-path4.png | Bin 0 -> 53226 bytes .../screenshots/reconstruction-path5.png | Bin 0 -> 30787 bytes docs/u1-webui/04-bridge-wcp/01-envelope.md | 159 + .../04-bridge-wcp/02-command-catalogue.md | 259 + .../04-bridge-wcp/03-command-parameters.md | 162 + .../05-printer-protocol/01-jsonrpc-methods.md | 119 + .../02-jsonrpc-reference.md | 91 + .../05-printer-protocol/03-mqtt-transport.md | 100 + .../05-printer-protocol/04-state-model.md | 135 + .../05-printer-protocol/05-cloud-api.md | 221 + .../05-printer-protocol/06-mqtt-topics.md | 241 + .../06-errors/01-live-error-catalogue.md | 83 + .../06-errors/02-legacy-error-asset.md | 132 + docs/u1-webui/README.md | 98 + docs/u1-webui/STATUS.md | 1162 + docs/u1-webui/consolidation-report.html | 306 + docs/u1-webui/data/ace-macros.json | 98 + .../data/ace-mode-switch-20260901.json | 1126 + docs/u1-webui/data/activity-codes.json | 57 + docs/u1-webui/data/bridge-methods.json | 886 + docs/u1-webui/data/bundle-strings.txt | 29517 ++++++++++++++++ docs/u1-webui/data/dart-classes.json | 2620 ++ docs/u1-webui/data/dart-enums.json | 10270 ++++++ docs/u1-webui/data/entry-points.json | 85 + docs/u1-webui/data/error-catalog.json | 5304 +++ docs/u1-webui/data/error-codes.json | 1080 + docs/u1-webui/data/field-vocabulary.txt | 600 + docs/u1-webui/data/hardware-shapes.json | 219 + docs/u1-webui/data/jsonrpc-methods.json | 330 + docs/u1-webui/data/sswcp-commands.json | 1230 + docs/u1-webui/data/state-model.json | 131 + docs/u1-webui/data/wcp-commands.json | 1186 + docs/u1-webui/data/wire-enums.json | 108 + docs/u1-webui/reconstructed/enums.dart | 2465 ++ docs/u1-webui/report.html | 445 + .../screenshots/device-control-old.png | Bin 0 -> 21659 bytes docs/u1-webui/screenshots/device-control.png | Bin 0 -> 47146 bytes .../screenshots/device-login-gate.png | Bin 0 -> 44216 bytes docs/u1-webui/screenshots/device-picker.png | Bin 0 -> 50272 bytes docs/u1-webui/screenshots/preupload.png | Bin 0 -> 23127 bytes .../screenshots/preuploadandprint.png | Bin 0 -> 32873 bytes docs/u1-webui/tools/.gitignore | 2 + docs/u1-webui/tools/README.md | 73 + docs/u1-webui/tools/_common.py | 45 + docs/u1-webui/tools/ace_macros.py | 71 + docs/u1-webui/tools/bake_mockup.py | 292 + docs/u1-webui/tools/check_coverage.py | 448 + docs/u1-webui/tools/check_mockup.py | 2102 ++ docs/u1-webui/tools/extract_activity.py | 63 + docs/u1-webui/tools/extract_bridge_methods.py | 158 + docs/u1-webui/tools/extract_dart_classes.py | 49 + docs/u1-webui/tools/extract_enums.py | 153 + docs/u1-webui/tools/extract_error_catalog.py | 75 + docs/u1-webui/tools/extract_errors.py | 108 + docs/u1-webui/tools/extract_routes.py | 97 + docs/u1-webui/tools/extract_rpc.py | 66 + docs/u1-webui/tools/extract_state_model.py | 64 + docs/u1-webui/tools/extract_strings.py | 25 + docs/u1-webui/tools/extract_wcp_commands.py | 121 + docs/u1-webui/tools/extract_wire_enums.py | 58 + docs/u1-webui/tools/gen_activity_module.py | 101 + docs/u1-webui/tools/gen_command_catalogue.py | 120 + docs/u1-webui/tools/gen_command_reference.py | 90 + docs/u1-webui/tools/gen_error_module.py | 97 + docs/u1-webui/tools/gen_jsonrpc_reference.py | 78 + docs/u1-webui/tools/gen_legacy_error_doc.py | 90 + docs/u1-webui/tools/harness/README.md | 131 + docs/u1-webui/tools/harness/cdp.py | 140 + docs/u1-webui/tools/harness/cloud-stub.js | 53 + docs/u1-webui/tools/harness/harness_server.py | 28 + docs/u1-webui/tools/harness/inject-state.js | 141 + docs/u1-webui/tools/harness/shoot.py | 50 + docs/u1-webui/tools/harness/wcp-bridge.js | 73 + docs/u1-webui/tools/map_sswcp.py | 96 + docs/u1-webui/tools/mqtt_min.py | 204 + docs/u1-webui/tools/run_all.py | 77 + docs/u1-webui/tools/u1_bridge.py | 701 + docs/u1-webui/tools/u1_probe.py | 371 + docs/u1-webui/tools/u1_topics.py | 255 + resources/web/device_page/README.md | 168 + resources/web/device_page/css/device.css | 2016 ++ .../web/device_page/icons/chevronRight.svg | 3 + resources/web/device_page/icons/delete.svg | 6 + .../device_page/icons/deviceActionHome.svg | 4 + .../web/device_page/icons/deviceControl.svg | 7 + .../device_page/icons/deviceNotConnected.webp | Bin 0 -> 2746 bytes resources/web/device_page/icons/empty-box.png | Bin 0 -> 76112 bytes .../web/device_page/icons/exclamationMark.svg | 5 + .../device_page/icons/extruderBackground.svg | 27 + resources/web/device_page/icons/help.svg | 4 + .../icons/iconAuxiliaryCooling.svg | 12 + resources/web/device_page/icons/iconEdit.svg | 4 + .../web/device_page/icons/iconExtruder1.svg | 4 + .../web/device_page/icons/iconExtruder2.svg | 4 + .../web/device_page/icons/iconExtruder3.svg | 4 + .../web/device_page/icons/iconExtruder4.svg | 4 + .../device_page/icons/iconExtruderHead.svg | 3 + resources/web/device_page/icons/iconFan.svg | 3 + .../device_page/icons/iconFilamentCheck.svg | 4 + .../device_page/icons/iconFilamentEdit.svg | 4 + resources/web/device_page/icons/iconFile.svg | 4 + resources/web/device_page/icons/iconHome.svg | 4 + .../icons/iconHotBedTemperature.svg | 9 + resources/web/device_page/icons/iconLed.svg | 4 + resources/web/device_page/icons/iconLog.svg | 5 + .../web/device_page/icons/iconMainCooling.svg | 22 + .../device_page/icons/iconModelFileFolder.svg | 4 + .../web/device_page/icons/iconNozzle.svg | 5 + .../web/device_page/icons/iconPurifier.svg | 13 + resources/web/device_page/icons/iconScan.svg | 7 + resources/web/device_page/icons/iconSpeed.svg | 5 + .../web/device_page/icons/iconTimeLapse.svg | 5 + .../icons/keyboardArrowDropDown.svg | 3 + .../web/device_page/icons/liveCamera.svg | 5 + .../web/device_page/icons/printHistory.svg | 5 + .../icons/printPreferenceArrow.svg | 5 + resources/web/device_page/icons/refresh.svg | 3 + resources/web/device_page/icons/settings.svg | 5 + resources/web/device_page/icons/videoCall.svg | 4 + .../web/device_page/icons/videoImgPlay.svg | 4 + resources/web/device_page/icons/videoPlay.svg | 4 + resources/web/device_page/index.html | 42 + resources/web/device_page/js/app.js | 397 + .../web/device_page/js/core/connection.js | 350 + resources/web/device_page/js/core/diag.js | 37 + resources/web/device_page/js/core/dom.js | 26 + resources/web/device_page/js/core/mock.js | 20 + resources/web/device_page/js/core/overlay.js | 336 + resources/web/device_page/js/core/pending.js | 157 + resources/web/device_page/js/core/render.js | 138 + resources/web/device_page/js/core/session.js | 410 + resources/web/device_page/js/core/store.js | 144 + resources/web/device_page/js/core/thumbs.js | 30 + resources/web/device_page/js/page-commands.js | 79 + resources/web/device_page/js/registry.js | 81 + resources/web/device_page/js/shell.js | 319 + .../device-control/camera/camera-commands.js | 332 + .../device-control/camera/camera-panel.js | 36 + .../device-control/camera/camera-view.js | 325 + .../control/control-commands.js | 307 + .../device-control/control/control-panel.js | 49 + .../device-control/control/control-view.js | 730 + .../filament/filament-commands.js | 650 + .../device-control/filament/filament-panel.js | 61 + .../device-control/filament/filament-view.js | 1791 + .../device-control/task/task-commands.js | 77 + .../views/device-control/task/task-panel.js | 44 + .../js/views/device-control/task/task-view.js | 181 + .../js/views/fault/fault-commands.js | 36 + .../device_page/js/views/fault/fault-panel.js | 25 + .../device_page/js/views/fault/fault-view.js | 63 + .../views/storage/storage/storage-commands.js | 380 + .../js/views/storage/storage/storage-panel.js | 57 + .../js/views/storage/storage/storage-view.js | 237 + resources/web/device_page/js/widgets/art.js | 36 + .../web/device_page/js/widgets/format.js | 25 + .../device_page/js/widgets/rail-commands.js | 227 + resources/web/device_page/js/widgets/rail.js | 55 + resources/web/device_page/js/widgets/trace.js | 30 + .../device_page/tests/capture_screenshots.py | 89 + resources/web/print_processing/README.md | 57 + .../web/print_processing/css/preprint.css | 214 + resources/web/print_processing/index.html | 47 + resources/web/print_processing/js/app.js | 215 + resources/web/print_processing/js/mock.js | 88 + resources/web/print_processing/js/ui.js | 188 + resources/web/shared/README.md | 38 + resources/web/shared/build-stamp.json | 8 + resources/web/shared/css/base.css | 84 + resources/web/shared/js/activity.js | 118 + resources/web/shared/js/buildinfo.js | 145 + resources/web/shared/js/errors.js | 1837 + resources/web/shared/js/mockhost.js | 1169 + resources/web/shared/js/multiACE.js | 1379 + resources/web/shared/js/protocol.js | 725 + resources/web/shared/js/sswcp.js | 232 + resources/web/shared/js/state.js | 549 + resources/web/shared/stamp_build.py | 54 + resources/web/shared/tests/check_app_serve.py | 179 + .../web/shared/tests/conformance_test.py | 1175 + resources/web/shared/tests/drive/README.md | 52 + resources/web/shared/tests/drive/ace-modes.js | 239 + resources/web/shared/tests/drive/ace-panel.js | 602 + resources/web/shared/tests/drive/ace-real.js | 231 + .../web/shared/tests/drive/ace-verbs-real.js | 275 + resources/web/shared/tests/drive/ace-verbs.js | 303 + .../web/shared/tests/drive/camera-real.js | 75 + resources/web/shared/tests/drive/camera.js | 113 + resources/web/shared/tests/drive/dom-dump.js | 24 + .../web/shared/tests/drive/homing-real.js | 85 + resources/web/shared/tests/drive/homing.js | 88 + resources/web/shared/tests/drive/job-band.js | 90 + .../web/shared/tests/drive/no-printer.js | 28 + .../web/shared/tests/drive/popover-live.js | 62 + .../web/shared/tests/drive/storage-paging.js | 108 + .../web/shared/tests/drive/storage-real.js | 69 + resources/web/shared/tests/drive/task-card.js | 40 + resources/web/shared/tests/run_selftest.py | 70 + resources/web/shared/tests/run_webkit.py | 761 + resources/web/shared/tests/selftest.html | 152 + resources/web/shared/tests/unit_jsc.py | 1035 + src/libslic3r/AceMmuState.hpp | 322 + src/libslic3r/AceMmuTopology.hpp | 104 + src/libslic3r/AppConfig.cpp | 6 + src/slic3r/CMakeLists.txt | 8 + src/slic3r/GUI/AceAssignPopup.cpp | 256 + src/slic3r/GUI/AceAssignPopup.hpp | 58 + src/slic3r/GUI/AceBadge.cpp | 141 + src/slic3r/GUI/AceBadge.hpp | 69 + src/slic3r/GUI/AceMmuProvider.cpp | 173 + src/slic3r/GUI/AceMmuProvider.hpp | 88 + src/slic3r/GUI/GUI_App.cpp | 63 +- src/slic3r/GUI/GUI_App.hpp | 23 + src/slic3r/GUI/HttpServer.cpp | 64 +- src/slic3r/GUI/Plater.cpp | 735 +- src/slic3r/GUI/Plater.hpp | 9 + src/slic3r/GUI/PrinterWebView.cpp | 66 +- src/slic3r/GUI/PrinterWebView.hpp | 15 + src/slic3r/GUI/SMAccountPersist.cpp | 59 + src/slic3r/GUI/SMAccountPersist.hpp | 33 + src/slic3r/GUI/SSWCP.cpp | 31 +- src/slic3r/GUI/SSWCP.hpp | 1 + src/slic3r/GUI/WebPreprintDialog.cpp | 7 +- src/slic3r/GUI/WebSMUserLoginDialog.cpp | 2 + .../GUI/filamentsync/FilamentColorMapBox.cpp | 8 +- .../filamentsync/FilamentColorMapBoxGroup.cpp | 74 +- .../filamentsync/FilamentColorMapBoxGroup.hpp | 3 +- src/slic3r/GUI/filamentsync/FilamentData.hpp | 12 + .../filamentsync/FilamentSyncAlgorithm.cpp | 17 +- .../filamentsync/MachineFilamentPicker.cpp | 26 +- .../filamentsync/SyncFilamentColorDialog.cpp | 44 +- .../filamentsync/SyncFilamentColorDialog.hpp | 6 +- src/slic3r/Utils/MQTT.cpp | 59 +- src/slic3r/Utils/MoonRaker.cpp | 27 + src/slic3r/Utils/MoonRaker.hpp | 2 + src/slic3r/Utils/PrintHost.hpp | 1 + tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_ace_mmu_topology.cpp | 194 + 287 files changed, 115458 insertions(+), 187 deletions(-) create mode 100644 docs/u1-webui/00-shared/01-shared-models.md create mode 100644 docs/u1-webui/00-shared/02-build-badge.md create mode 100644 docs/u1-webui/00-shared/03-serving-in-orca.md create mode 100644 docs/u1-webui/01-architecture/01-layer-model.md create mode 100644 docs/u1-webui/01-architecture/02-bundle-inventory.md create mode 100644 docs/u1-webui/01-architecture/03-entry-points.md create mode 100644 docs/u1-webui/01-architecture/04-routes-detail.md create mode 100644 docs/u1-webui/02-device-page/01-overview.md create mode 100644 docs/u1-webui/02-device-page/02-connection-state-machine.md create mode 100644 docs/u1-webui/02-device-page/03-strings-and-controls.md create mode 100644 docs/u1-webui/02-device-page/04-implementation-report.md create mode 100644 docs/u1-webui/02-device-page/05-visual-reference.md create mode 100644 docs/u1-webui/02-device-page/06-connection.md create mode 100644 docs/u1-webui/02-device-page/07-parity.md create mode 100644 docs/u1-webui/02-device-page/08-function-gap-analysis.md create mode 100644 docs/u1-webui/02-device-page/09-restructure.md create mode 100644 docs/u1-webui/02-device-page/10-multiace-filament.md create mode 100644 docs/u1-webui/02-device-page/11-multiace-handover.md create mode 100644 docs/u1-webui/02-device-page/camera-layout-ace-mockup.html create mode 100644 docs/u1-webui/02-device-page/multiace-actions.html create mode 100644 docs/u1-webui/02-device-page/multiace-cabinet.html create mode 100644 docs/u1-webui/02-device-page/multiace-f2-iterations.html create mode 100644 docs/u1-webui/02-device-page/multiace-filament-mockup.html create mode 100644 docs/u1-webui/02-device-page/multiace-modes.html create mode 100644 docs/u1-webui/02-device-page/multiace-toolhead-card.html create mode 100644 docs/u1-webui/02-device-page/panel-popover-mockup.html create mode 100644 docs/u1-webui/02-device-page/screenshots/01-device-page-printing.png create mode 100644 docs/u1-webui/02-device-page/screenshots/02-toolheads.png create mode 100644 docs/u1-webui/02-device-page/screenshots/03-bed-and-chamber.png create mode 100644 docs/u1-webui/02-device-page/screenshots/04-sswcp-trace.png create mode 100644 docs/u1-webui/02-device-page/screenshots/05-paused.png create mode 100644 docs/u1-webui/02-device-page/screenshots/06-fault-banner.png create mode 100644 docs/u1-webui/02-device-page/screenshots/07-control-roundtrip.png create mode 100644 docs/u1-webui/02-device-page/screenshots/08-selftest.png create mode 100644 docs/u1-webui/02-device-page/screenshots/interaction-dialog.png create mode 100644 docs/u1-webui/02-device-page/screenshots/interaction-menu.png create mode 100644 docs/u1-webui/02-device-page/screenshots/original-filament.png create mode 100644 docs/u1-webui/02-device-page/screenshots/original-full.png create mode 100644 docs/u1-webui/02-device-page/screenshots/original-reference.png create mode 100644 docs/u1-webui/02-device-page/screenshots/reconstruction-filament.png create mode 100644 docs/u1-webui/02-device-page/screenshots/reconstruction-full.png create mode 100644 docs/u1-webui/02-device-page/screenshots/reconstruction-matched.png create mode 100644 docs/u1-webui/02-device-page/screenshots/reconstruction-with-badge.png create mode 100644 docs/u1-webui/03-print-processing/01-overview.md create mode 100644 docs/u1-webui/03-print-processing/02-lifecycle.md create mode 100644 docs/u1-webui/03-print-processing/03-implementation.md create mode 100644 docs/u1-webui/03-print-processing/screenshots/reconstruction-path4.png create mode 100644 docs/u1-webui/03-print-processing/screenshots/reconstruction-path5.png create mode 100644 docs/u1-webui/04-bridge-wcp/01-envelope.md create mode 100644 docs/u1-webui/04-bridge-wcp/02-command-catalogue.md create mode 100644 docs/u1-webui/04-bridge-wcp/03-command-parameters.md create mode 100644 docs/u1-webui/05-printer-protocol/01-jsonrpc-methods.md create mode 100644 docs/u1-webui/05-printer-protocol/02-jsonrpc-reference.md create mode 100644 docs/u1-webui/05-printer-protocol/03-mqtt-transport.md create mode 100644 docs/u1-webui/05-printer-protocol/04-state-model.md create mode 100644 docs/u1-webui/05-printer-protocol/05-cloud-api.md create mode 100644 docs/u1-webui/05-printer-protocol/06-mqtt-topics.md create mode 100644 docs/u1-webui/06-errors/01-live-error-catalogue.md create mode 100644 docs/u1-webui/06-errors/02-legacy-error-asset.md create mode 100644 docs/u1-webui/README.md create mode 100644 docs/u1-webui/STATUS.md create mode 100644 docs/u1-webui/consolidation-report.html create mode 100644 docs/u1-webui/data/ace-macros.json create mode 100644 docs/u1-webui/data/ace-mode-switch-20260901.json create mode 100644 docs/u1-webui/data/activity-codes.json create mode 100644 docs/u1-webui/data/bridge-methods.json create mode 100644 docs/u1-webui/data/bundle-strings.txt create mode 100644 docs/u1-webui/data/dart-classes.json create mode 100644 docs/u1-webui/data/dart-enums.json create mode 100644 docs/u1-webui/data/entry-points.json create mode 100644 docs/u1-webui/data/error-catalog.json create mode 100644 docs/u1-webui/data/error-codes.json create mode 100644 docs/u1-webui/data/field-vocabulary.txt create mode 100644 docs/u1-webui/data/hardware-shapes.json create mode 100644 docs/u1-webui/data/jsonrpc-methods.json create mode 100644 docs/u1-webui/data/sswcp-commands.json create mode 100644 docs/u1-webui/data/state-model.json create mode 100644 docs/u1-webui/data/wcp-commands.json create mode 100644 docs/u1-webui/data/wire-enums.json create mode 100644 docs/u1-webui/reconstructed/enums.dart create mode 100644 docs/u1-webui/report.html create mode 100644 docs/u1-webui/screenshots/device-control-old.png create mode 100644 docs/u1-webui/screenshots/device-control.png create mode 100644 docs/u1-webui/screenshots/device-login-gate.png create mode 100644 docs/u1-webui/screenshots/device-picker.png create mode 100644 docs/u1-webui/screenshots/preupload.png create mode 100644 docs/u1-webui/screenshots/preuploadandprint.png create mode 100644 docs/u1-webui/tools/.gitignore create mode 100644 docs/u1-webui/tools/README.md create mode 100644 docs/u1-webui/tools/_common.py create mode 100644 docs/u1-webui/tools/ace_macros.py create mode 100755 docs/u1-webui/tools/bake_mockup.py create mode 100644 docs/u1-webui/tools/check_coverage.py create mode 100755 docs/u1-webui/tools/check_mockup.py create mode 100644 docs/u1-webui/tools/extract_activity.py create mode 100644 docs/u1-webui/tools/extract_bridge_methods.py create mode 100644 docs/u1-webui/tools/extract_dart_classes.py create mode 100644 docs/u1-webui/tools/extract_enums.py create mode 100644 docs/u1-webui/tools/extract_error_catalog.py create mode 100644 docs/u1-webui/tools/extract_errors.py create mode 100644 docs/u1-webui/tools/extract_routes.py create mode 100644 docs/u1-webui/tools/extract_rpc.py create mode 100644 docs/u1-webui/tools/extract_state_model.py create mode 100644 docs/u1-webui/tools/extract_strings.py create mode 100644 docs/u1-webui/tools/extract_wcp_commands.py create mode 100644 docs/u1-webui/tools/extract_wire_enums.py create mode 100644 docs/u1-webui/tools/gen_activity_module.py create mode 100644 docs/u1-webui/tools/gen_command_catalogue.py create mode 100644 docs/u1-webui/tools/gen_command_reference.py create mode 100644 docs/u1-webui/tools/gen_error_module.py create mode 100644 docs/u1-webui/tools/gen_jsonrpc_reference.py create mode 100644 docs/u1-webui/tools/gen_legacy_error_doc.py create mode 100644 docs/u1-webui/tools/harness/README.md create mode 100644 docs/u1-webui/tools/harness/cdp.py create mode 100644 docs/u1-webui/tools/harness/cloud-stub.js create mode 100644 docs/u1-webui/tools/harness/harness_server.py create mode 100644 docs/u1-webui/tools/harness/inject-state.js create mode 100644 docs/u1-webui/tools/harness/shoot.py create mode 100644 docs/u1-webui/tools/harness/wcp-bridge.js create mode 100644 docs/u1-webui/tools/map_sswcp.py create mode 100644 docs/u1-webui/tools/mqtt_min.py create mode 100644 docs/u1-webui/tools/run_all.py create mode 100644 docs/u1-webui/tools/u1_bridge.py create mode 100755 docs/u1-webui/tools/u1_probe.py create mode 100755 docs/u1-webui/tools/u1_topics.py create mode 100644 resources/web/device_page/README.md create mode 100644 resources/web/device_page/css/device.css create mode 100644 resources/web/device_page/icons/chevronRight.svg create mode 100644 resources/web/device_page/icons/delete.svg create mode 100644 resources/web/device_page/icons/deviceActionHome.svg create mode 100644 resources/web/device_page/icons/deviceControl.svg create mode 100644 resources/web/device_page/icons/deviceNotConnected.webp create mode 100644 resources/web/device_page/icons/empty-box.png create mode 100644 resources/web/device_page/icons/exclamationMark.svg create mode 100644 resources/web/device_page/icons/extruderBackground.svg create mode 100644 resources/web/device_page/icons/help.svg create mode 100644 resources/web/device_page/icons/iconAuxiliaryCooling.svg create mode 100644 resources/web/device_page/icons/iconEdit.svg create mode 100644 resources/web/device_page/icons/iconExtruder1.svg create mode 100644 resources/web/device_page/icons/iconExtruder2.svg create mode 100644 resources/web/device_page/icons/iconExtruder3.svg create mode 100644 resources/web/device_page/icons/iconExtruder4.svg create mode 100644 resources/web/device_page/icons/iconExtruderHead.svg create mode 100644 resources/web/device_page/icons/iconFan.svg create mode 100644 resources/web/device_page/icons/iconFilamentCheck.svg create mode 100644 resources/web/device_page/icons/iconFilamentEdit.svg create mode 100644 resources/web/device_page/icons/iconFile.svg create mode 100644 resources/web/device_page/icons/iconHome.svg create mode 100644 resources/web/device_page/icons/iconHotBedTemperature.svg create mode 100644 resources/web/device_page/icons/iconLed.svg create mode 100644 resources/web/device_page/icons/iconLog.svg create mode 100644 resources/web/device_page/icons/iconMainCooling.svg create mode 100644 resources/web/device_page/icons/iconModelFileFolder.svg create mode 100644 resources/web/device_page/icons/iconNozzle.svg create mode 100644 resources/web/device_page/icons/iconPurifier.svg create mode 100644 resources/web/device_page/icons/iconScan.svg create mode 100644 resources/web/device_page/icons/iconSpeed.svg create mode 100644 resources/web/device_page/icons/iconTimeLapse.svg create mode 100644 resources/web/device_page/icons/keyboardArrowDropDown.svg create mode 100644 resources/web/device_page/icons/liveCamera.svg create mode 100644 resources/web/device_page/icons/printHistory.svg create mode 100644 resources/web/device_page/icons/printPreferenceArrow.svg create mode 100644 resources/web/device_page/icons/refresh.svg create mode 100644 resources/web/device_page/icons/settings.svg create mode 100644 resources/web/device_page/icons/videoCall.svg create mode 100644 resources/web/device_page/icons/videoImgPlay.svg create mode 100644 resources/web/device_page/icons/videoPlay.svg create mode 100644 resources/web/device_page/index.html create mode 100644 resources/web/device_page/js/app.js create mode 100644 resources/web/device_page/js/core/connection.js create mode 100644 resources/web/device_page/js/core/diag.js create mode 100644 resources/web/device_page/js/core/dom.js create mode 100644 resources/web/device_page/js/core/mock.js create mode 100644 resources/web/device_page/js/core/overlay.js create mode 100644 resources/web/device_page/js/core/pending.js create mode 100644 resources/web/device_page/js/core/render.js create mode 100644 resources/web/device_page/js/core/session.js create mode 100644 resources/web/device_page/js/core/store.js create mode 100644 resources/web/device_page/js/core/thumbs.js create mode 100644 resources/web/device_page/js/page-commands.js create mode 100644 resources/web/device_page/js/registry.js create mode 100644 resources/web/device_page/js/shell.js create mode 100644 resources/web/device_page/js/views/device-control/camera/camera-commands.js create mode 100644 resources/web/device_page/js/views/device-control/camera/camera-panel.js create mode 100644 resources/web/device_page/js/views/device-control/camera/camera-view.js create mode 100644 resources/web/device_page/js/views/device-control/control/control-commands.js create mode 100644 resources/web/device_page/js/views/device-control/control/control-panel.js create mode 100644 resources/web/device_page/js/views/device-control/control/control-view.js create mode 100644 resources/web/device_page/js/views/device-control/filament/filament-commands.js create mode 100644 resources/web/device_page/js/views/device-control/filament/filament-panel.js create mode 100644 resources/web/device_page/js/views/device-control/filament/filament-view.js create mode 100644 resources/web/device_page/js/views/device-control/task/task-commands.js create mode 100644 resources/web/device_page/js/views/device-control/task/task-panel.js create mode 100644 resources/web/device_page/js/views/device-control/task/task-view.js create mode 100644 resources/web/device_page/js/views/fault/fault-commands.js create mode 100644 resources/web/device_page/js/views/fault/fault-panel.js create mode 100644 resources/web/device_page/js/views/fault/fault-view.js create mode 100644 resources/web/device_page/js/views/storage/storage/storage-commands.js create mode 100644 resources/web/device_page/js/views/storage/storage/storage-panel.js create mode 100644 resources/web/device_page/js/views/storage/storage/storage-view.js create mode 100644 resources/web/device_page/js/widgets/art.js create mode 100644 resources/web/device_page/js/widgets/format.js create mode 100644 resources/web/device_page/js/widgets/rail-commands.js create mode 100644 resources/web/device_page/js/widgets/rail.js create mode 100644 resources/web/device_page/js/widgets/trace.js create mode 100644 resources/web/device_page/tests/capture_screenshots.py create mode 100644 resources/web/print_processing/README.md create mode 100644 resources/web/print_processing/css/preprint.css create mode 100644 resources/web/print_processing/index.html create mode 100644 resources/web/print_processing/js/app.js create mode 100644 resources/web/print_processing/js/mock.js create mode 100644 resources/web/print_processing/js/ui.js create mode 100644 resources/web/shared/README.md create mode 100644 resources/web/shared/build-stamp.json create mode 100644 resources/web/shared/css/base.css create mode 100644 resources/web/shared/js/activity.js create mode 100644 resources/web/shared/js/buildinfo.js create mode 100644 resources/web/shared/js/errors.js create mode 100644 resources/web/shared/js/mockhost.js create mode 100644 resources/web/shared/js/multiACE.js create mode 100644 resources/web/shared/js/protocol.js create mode 100644 resources/web/shared/js/sswcp.js create mode 100644 resources/web/shared/js/state.js create mode 100644 resources/web/shared/stamp_build.py create mode 100644 resources/web/shared/tests/check_app_serve.py create mode 100644 resources/web/shared/tests/conformance_test.py create mode 100644 resources/web/shared/tests/drive/README.md create mode 100644 resources/web/shared/tests/drive/ace-modes.js create mode 100644 resources/web/shared/tests/drive/ace-panel.js create mode 100644 resources/web/shared/tests/drive/ace-real.js create mode 100644 resources/web/shared/tests/drive/ace-verbs-real.js create mode 100644 resources/web/shared/tests/drive/ace-verbs.js create mode 100644 resources/web/shared/tests/drive/camera-real.js create mode 100644 resources/web/shared/tests/drive/camera.js create mode 100644 resources/web/shared/tests/drive/dom-dump.js create mode 100644 resources/web/shared/tests/drive/homing-real.js create mode 100644 resources/web/shared/tests/drive/homing.js create mode 100644 resources/web/shared/tests/drive/job-band.js create mode 100644 resources/web/shared/tests/drive/no-printer.js create mode 100644 resources/web/shared/tests/drive/popover-live.js create mode 100644 resources/web/shared/tests/drive/storage-paging.js create mode 100644 resources/web/shared/tests/drive/storage-real.js create mode 100644 resources/web/shared/tests/drive/task-card.js create mode 100644 resources/web/shared/tests/run_selftest.py create mode 100644 resources/web/shared/tests/run_webkit.py create mode 100644 resources/web/shared/tests/selftest.html create mode 100644 resources/web/shared/tests/unit_jsc.py create mode 100644 src/libslic3r/AceMmuState.hpp create mode 100644 src/libslic3r/AceMmuTopology.hpp create mode 100644 src/slic3r/GUI/AceAssignPopup.cpp create mode 100644 src/slic3r/GUI/AceAssignPopup.hpp create mode 100644 src/slic3r/GUI/AceBadge.cpp create mode 100644 src/slic3r/GUI/AceBadge.hpp create mode 100644 src/slic3r/GUI/AceMmuProvider.cpp create mode 100644 src/slic3r/GUI/AceMmuProvider.hpp create mode 100644 src/slic3r/GUI/SMAccountPersist.cpp create mode 100644 src/slic3r/GUI/SMAccountPersist.hpp create mode 100644 tests/libslic3r/test_ace_mmu_topology.cpp diff --git a/CLAUDE.md b/CLAUDE.md index d7fa733073c..707e3009c4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,205 @@ 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`. + +**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). + +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, and the no-printer branch. See its README - they were re-written from scratch + every time before. +- `--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. + +**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/16-ace-visuals.md b/docs/ace-mmu/16-ace-visuals.md index 6c4d847c600..99d8449c31a 100644 --- a/docs/ace-mmu/16-ace-visuals.md +++ b/docs/ace-mmu/16-ace-visuals.md @@ -21,25 +21,113 @@ spool, an ACE never does. | Form | Size | Where | Function | |------|------|-------|----------| -| **Badge** | 44×26 fill | a head box | `ace_badge()` | -| **Glyph** | 44×26 line, stroke 1.6 | a popover row, a label | `ace_glyph()` | -| **Glyph, square** | 24×24 line, stroke 1.6 | a tab, a menu, a `ScalableButton` | `ace_glyph_square()` | - -**Badge** — hood, four bays, base drawn *over* them; the base is slightly wider than the -hood, which is what makes it read as a cabinet rather than a bar chart. Bays are 5×14 -capsules at x 6/15/24/33 — **padding 4 = gap 4**, matching the proportions of Bambu's -own icon. Colour and emptiness are all that survive at this size, so the badge carries -colour only; an empty bay is white against the grey hood, with no outline. Trust and -staleness live wherever the badge is a control. - -**Glyph** — the badge's own silhouette in line: one stepped path, hood shoulders on top, -base stepping out at the bottom, bays filled. +| **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: @@ -103,6 +191,7 @@ prerequisite for the column meaning anything. | 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 diff --git a/docs/ace-mmu/ace-visual-standard.html b/docs/ace-mmu/ace-visual-standard.html index d0320afd531..9239b818d30 100644 --- a/docs/ace-mmu/ace-visual-standard.html +++ b/docs/ace-mmu/ace-visual-standard.html @@ -214,8 +214,83 @@ .flag{display:inline-block;font-size:10px;font-weight:800;letter-spacing:.06em;text-transform:uppercase; color:var(--warn);background:var(--warn-soft);border-radius:4px;padding:1px 6px;vertical-align:2px;} @media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;}} + +/* ================= the Device page's own objects ========================== + Everything below is device.css's geometry, copied value for value, because the + Device page draws these and this sheet is meant to be where they come from. A + number here that disagrees with `resources/web/device_page/css/device.css` is a + bug in one of the two. */ +.dp { --disc:36px; --pillw:58px; --pillh:19px; --bay-gap:6px; + /* the seam runs through the MIDDLE of the roll: 5 px of padding + half a disc */ + --seam: calc(5px + var(--disc) / 2); } +.dp-top { width: fit-content; padding: 5px 16px; border-radius: 9px; } +.dp-cab { background: linear-gradient(180deg,#EEEEEE 0 var(--seam),#CECECE var(--seam) 100%); } +.dp-feed { background: linear-gradient(180deg,#FFFFFF 0 var(--seam),#1F1F1F var(--seam) 100%); + box-shadow: inset 0 0 0 1px #D9DDE3; } +.dp-bays { display: flex; gap: 10px; align-items: flex-start; } +.dp-bay { width: calc(var(--pillw) + 4px); flex: 0 0 calc(var(--pillw) + 4px); } +.dp-disc { + display: grid; place-items: center; margin: 0 auto; + width: var(--disc); height: var(--disc); border-radius: 50%; + font: 500 12px/1 ui-monospace, monospace; color: #333; + box-shadow: 0 0 0 3px #FFFFFF; +} +.dp-chip { + display: block; margin: var(--bay-gap) auto 0; + width: var(--pillw); height: var(--pillh); line-height: var(--pillh); + border-radius: calc(var(--pillh) / 2); + background: #6E6E6E; color: #fff; + font-size: 12.5px; text-align: center; +} +/* The head: the shipped artwork at a stated scale, and one marker centred on its BODY - + extruderBackground.svg draws that body y=17.4..127.6 of its 64x140, so (32, 72.5). */ +.dp-head { position: relative; --s: 1; width: calc(64px * var(--s)); height: calc(140px * var(--s)); } +.dp-head svg { display: block; width: 100%; height: 100%; } +.dp-sensor { + position: absolute; box-sizing: content-box; + left: calc(32px * var(--s) - 7.5px); top: calc(72.5px * var(--s) - 7.5px); + width: 11px; height: 11px; border-radius: 50%; border: 2px solid #fff; + background: #2e9e5b; +} +/* Caps line up, drawings sit on one baseline. `align-items: flex-end` did the second + and broke the first: four specs of four different heights staggered their captions. */ +.dp-row { display: flex; align-items: flex-start; gap: 26px; flex-wrap: wrap; } +.dp-stage { display: flex; align-items: flex-end; } + +
@@ -243,6 +318,8 @@

The ACE visual standard

+ +
@@ -252,6 +329,7 @@

The ACE visual standard

+
@@ -308,7 +386,7 @@

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 - five candidates, from the faithful copy to the ones that lean on what this fork already draws. + 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 @@ -321,13 +399,26 @@

Five ways to draw it — all 44×26

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.

+ 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. Four ways to treat the bays inside it:

+ 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 @@ -424,7 +515,73 @@

Four spool objects in a light grey box

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 @@ -535,7 +692,7 @@

Every surface that draws an ACE

(function(){ "use strict"; - var S = { state:"solid", unit:"ok", hum:"raw", rem:"spoolman", badge:"a", line:"o2", sq:"s4" }; + var S = { state:"solid", unit:"ok", hum:"raw", rem:"spoolman", badge:"g", line:"o5", sq:"s4" }; // Slot 3 is the one the deck drives; the rest are your printer's real spools, // read from 192.168.2.242 (four Kingroon/Generic PETG). @@ -631,13 +788,68 @@

Every surface that draws an ACE

return o.join("") + ''; } - var BADGES = { a:badgeA, b:badgeB, c:badgeC, d:badgeD, e:badgeE }; + // F · Cabinet, flush — A with its two halves the SAME width. + // + // A's hood is inset 2 either side and its base runs the full 44, which is what makes + // it read as furniture at 44x26. The Device page's own cabinet does not do that: it is + // ONE rounded rectangle with a hard colour stop through it (`.ace-cab-top`, radius 9, + // #EEEEEE over #CECECE), so its sides are parallel. A badge that is a picture of the + // thing in the card should have the card's silhouette, which is this one. + function badgeF(cols, z, off){ + var hood = off ? "var(--ams-block-bk)" : "var(--ace-hood,#e8e8e8)"; + var base = off ? "var(--ams-disable)" : "var(--ace-base,#cfcfcf)"; + var o = [svgOpen(z)]; + o.push(''); + for (var i = 0; i < 4; i++) + o.push(''); + // the lower half, rounded at the bottom only - the colour stop, not a second box + o.push(''); + return o.join("") + ''; + } + + // G · Cabinet, spools over — F with the bays drawn LAST and one margin all round. + // + // Three changes from F, and they are one idea. F keeps A's habit of drawing the base + // OVER the bays, which crops their lower 2.5 px; here the bays go on top, so a spool is + // a whole spool. That leaves the margins visibly unequal — 6 px at the sides against + // 3.5 top — so they are made ONE number, and the standard already has that number: + // padding 4 == gap 4, Bambu's own proportion, which A states and F quietly broke by + // widening the body without moving the bays. + // + // The badge stays 44x26 and the body stays 24 tall, so the margin comes out of the + // spools: they grow from 5x13 to 6x16 and the drawing gets its air back at the edges + // instead. Positions are integers on purpose — x 4/14/24/34, pitch 10. + var GBAY = { x:[4, 14, 24, 34], y:6, w:6, h:16, rx:3 }; + function badgeG(cols, z, off){ + var hood = off ? "var(--ams-block-bk)" : "var(--ace-hood,#e8e8e8)"; + var base = off ? "var(--ams-disable)" : "var(--ace-base,#cfcfcf)"; + var o = [svgOpen(z)]; + o.push(''); + o.push(''); + // last, so the colour is not cropped by the lower half + for (var i = 0; i < 4; i++) + o.push(''); + return o.join("") + ''; + } + + var BADGES = { a:badgeA, b:badgeB, c:badgeC, d:badgeD, e:badgeE, f:badgeF, g:badgeG }; var BADGE_META = { - a:["Cabinet", "CHOSEN. Bambu's icon, distilled: hood, bays, base drawn over them."], + a:["Cabinet", "Bambu's icon, distilled: hood, bays, base drawn over them. The " + + "starting point, and what G is a correction of."], b:["Outlined", "Same shape in line. Colours carry; nothing competes with them."], c:["Tray", "No chassis at all — AMSPreview's ground with capsules. Least invented."], d:["Bays", "The ACE seen head-on: four bays in one body. Boldest colour."], - e:["Spool faces", "Spool ends, as the U1 + multiACE page already draws them."] + e:["Spool faces", "Spool ends, as the U1 + multiACE page already draws them."], + f:["Cabinet, flush", "A with PARALLEL sides \u2014 one rounded rectangle split by a " + + "colour stop, which is the silhouette the Device page's own cabinet has."], + g:["Cabinet, spools over", "CHOSEN. F with the spools drawn LAST, so none is cropped, and one " + + "margin all round: 4 px, the gap's own number. The spools take the width back " + + "\u2014 6\u00d716 rather than 5\u00d713."] }; function badgeSVG(cols, z, parts, off){ if (S.badge === "a") return badgeA(cols, z, off, parts); @@ -653,6 +865,7 @@

Every surface that draws an ACE

var o = ['

Every surface that draws an ACE

// "o4" draws the cabinet alone — no bays. return o.join("") + ''; } + /* O5 — G's outline. The four above all wear CAB, which is A's stepped silhouette; if + the badge stops stepping then its outlined twin has to stop too, or the pair say two + different things about one object. Same body as G, inset by half a stroke so the 1.6 + does not clip the viewBox, and G's own bays filled. */ + function glyphFlushSVG(z){ + z = z || 1; + var o = [''; + } var LINE_META = { o1:["Outlined bays", "Every part in line. Lightest, but 5 px bays outlined at 1.3 get thin."], - o2:["Solid bays", "CHOSEN. Chassis in line, bays filled. Closest to Bambu's own popup rows."], + o2:["Solid bays", "Chassis in line, bays filled. Closest to Bambu's own popup rows, " + + "and the treatment O5 carries onto G's outline."], o3:["Bay ticks", "Bays as four strokes. Simplest to draw, reads at any size."], - o4:["Cabinet only", "No bays — for when the glyph means \u201Can ACE\u201D, not a particular one."] + o4:["Cabinet only", "No bays — for when the glyph means \u201Can ACE\u201D, not a particular one."], + o5:["Flush, solid bays", "CHOSEN. O2's treatment on G's outline: no step, one margin all " + + "round. The twin of G, for when the badge stops stepping."] }; /* ---- the square twin: for icon slots (a tab, a menu row, a toolbar button), @@ -760,7 +992,7 @@

Every surface that draws an ACE

function badgeOptsHTML(){ var c = badgeCols(); - return ["a","b","c","d","e"].map(function(k){ + return ["a","b","c","d","e","f","g"].map(function(k){ var meta = BADGE_META[k], fn = BADGES[k]; return ' + + + +
+
+
+
+ Case
+
+ USB
+
+ Screen
+
No fourth camera
+
+
+ 14.0 fps + case · 1920×1080 +
+
+ ⚙ Direct snapshot + ▦ Grid + + 14.0 fps + +
+
+
+ +
+
+
Camera
+
+ + Case
1920×1080
+ + USB
1280×720
+ + Screen
480×320
+
+
+
+
Quality
+
+ + Sharp + snapshot · direct · 14 fps · 1920×1080 + in use + + Smooth + H.264 · 30 fps + no H.264 in this engine +
+
+
+ +
+
+
Views
+
SingleSplitGrid + PiP
+
+
+
Cameras
+
+ + Case
1920×1080
+ + USB
1280×720
+ + Screen
480×320
+
+
+
+
Transport
+
+ + Auto + the best this computer can decode + direct snapshot + + Monitor file + camera.start_monitor · 0.5 fps + + Direct snapshot + /webcam/snapshot.jpg · 14 fps + + H.264 stream + /webcam/stream.h264 · 30 fps + no codec + + WebRTC + /webcam/webrtc · 30 fps + no WebRTC +
+
+
+
Frames per second
+
151015
+
The printer's own target_fps is 15, so the + scale stops there.
Grid of three at 15 ≈ 4.7 MB/s; unfocused tiles drop + to 1 fps, which brings it to 3.5.
+
+
+ +

S2 open, over a 2×2 grid. The popover opens on the gear and closes on + the next click outside, which is already writtencore/overlay.js + does anchored popovers for the Control panel's tiles.

+ + +
S2, and it earns it twice over now that there is a grid. It is +the only one of the three that lets you watch the picture change as you change the setting +— which is the whole reason to have the setting, and doubly so when the setting is +how many pictures. S1 costs the live view at exactly the wrong moment; S3 buries +a persistent choice behind a hover, and nothing else on this page does that. Keep the +in-picture overlay chips from S3 though: an fps readout and the camera name are +honest and cheap, and in grid mode each tile needs its own name anyway.
+ +

What a grid costs

+

Each tile is its own poll — there is no multiplexed stream to share — so a +grid multiplies both bandwidth and JPEG decoding. The frame sizes are measured, not +estimated.

+ +
+ + + + + + + + + + +
viewtilesbytes per frameat 15 fpswith the focus rule
V1 single (case)1226 KB3.4 MB/s3.4 MB/s
V2 split (case + usb)2313 KB4.7 MB/s3.5 MB/s
V3 grid (case + usb + screen)3314 KB4.7 MB/s3.5 MB/s
V4 PiP (case + usb)2313 KB4.7 MB/s3.5 MB/s
+ +
The focus rule is what makes a grid affordable: the tile you are +pointing at polls at the chosen rate, the rest at 1 fps. That is one line of policy +and it turns 4.7 MB/s into 3.5, because the case camera's 226 KB frame dominates +everything else on the page — the USB camera is 87 KB and the touchscreen is +710 bytes. In V4 the inset is by definition the unfocused one, which is why PiP is +the cheapest way to watch two things.
+ +
Asking for more than 15 fps gets nothing. The webcam entries +report target_fps: 15 and target_fps_idle: 5, and the 14.0 fps +chained in the engine is that cap, not a client limit. So the fps control tops out at 15 +and the missing frames are the printer's, not the page's — worth saying in the UI rather +than letting someone chase a 30 that does not exist.
+ +
Two view-layout details worth deciding now. +
· V3's fourth cell. Three live views and a 2×2 grid leaves one empty. Drawing +"No fourth camera" in it is better than a 3-up layout that reflows the moment someone +plugs in a second USB camera — and the extended firmware supports exactly that. +
· The touchscreen is not a camera and should not be in the same list unlabelled. +It is a 480×320 PNG of the machine's own UI; as a tile it is genuinely useful (it shows +what the printer thinks it is doing) and as a "camera" it is a category error. Offer it +last and call it Screen.
+ + + +

And what the list inside it says

+

A mode here is two things at once — a transport and a picture quality — and +they do not always move together. Naming a mode after the transport is honest and +unreadable; naming it after the quality is readable and lies when the transport is the +reason the option is greyed. Three ways to resolve that:

+ +
+ +
+
M-AName the transport + chosen
+
+
+ + Auto + the best this computer can decode + direct snapshot + + Monitor file + camera.start_monitor · 0.5 fps + + Direct snapshot + /webcam/snapshot.jpg · 14 fps + + H.264 stream + /webcam/stream.h264 · 30 fps + no codec + + WebRTC + /webcam/webrtc · 30 fps + no WebRTC +
+
+
Every greyed row explains itself, and a bug report quotes the + transport by name. Asks the user to know what H.264 is.
+
+ +
+
M-BName the quality
+
+
+ + Auto + best this computer can show + Sharp + + Sharpfull resolution, ~14 fps + + Smooth30 fps video + not on Linux + + Saverone frame a second +
+
+
Reads instantly and Auto is a real answer. "Not on Linux" is a + worse explanation than "no codec" when someone has to act on it.
+
+ +
+
M-CQuality, transport on ask
+
+
+
Quality
+
AutoSharpSaver
+
+
+
Frame rate limit
+
151530
+
▸ Transport  direct snapshot
+ H.264 and WebRTC need a codec this engine does not have.
+
+
+
Two controls instead of one list, and the fps cap is a setting + people genuinely want on a laptop. The transport becomes a fact you can look up + rather than a choice you must make.
+
+ +
+ +
M-A it is — the transport is the name. It costs a person one +new word and buys three things that matter more than the word did: a greyed row explains +itself in the same vocabulary as the reason, a bug report quotes something searchable, and +nobody has to decide what "Smooth" means when it is unavailable. The audience for a +printer's device panel already knows what a stream is.

+ +

Two things carried over from the versions not chosen. M-C's frames-per-second +control stays, as its own setting rather than folded into the mode — it is orthogonal +to transport, it is the lever that makes a grid affordable, and the popover above shows +them as separate groups. And M-B's Auto stays as the default selection, sitting +above the four named transports rather than replacing them: it reports which one it +chose, so the naming is still doing its work.

Auto has to be a probe, not a guess. The three tests are one line +each and all three ran cleanly here: +MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E"'), +typeof RTCPeerConnection !== 'undefined', and — for the printer's half — a +GET :7125/server/webcams/list that returns at least one entry with a +snapshot_url. Stock firmware fails that last one and Auto falls back to the +monitor file, which is the behaviour the page has today. The extended firmware is +detected, never configured.
+ +
Two open questions this design does not settle, both needing +a machine that is not this one. +
· Is the stock mode still needed at all? Every U1 without the extended firmware +needs it, and none was available to test against — /webcam/snapshot.jpg on +stock firmware has not been tried and may well 404. Keep the stock path until that is +measured. +
· Does WebView2 do WebRTC against this printer's signalling? The table above +says expected for H.264 on Windows and macOS on the strength of those engines +shipping the codec, and unverified for WebRTC. Neither has been run. The Auto +probe makes both safe to be wrong about — it will simply not choose them.
+ +

03The ACE, on a page that already knows how to talk to it

+

This machine has one ACE 2 Pro, firmware +V1.1.26, at 38 % RH and 31 °C, feeding Toolhead 4 while +toolheads 1–3 run off the stock feeders. The mode is head. All four of its +slots hold PETG. That paragraph is the whole design brief, and every word of it came out +of the printer a few minutes ago.

+ +

The finding that decides what can be built

+

Two ways in, and they do not carry the same data. This was worth an hour +because getting it wrong would have meant designing a panel that cannot be filled.

+ +
+ + + + + + + + + + + + + + + + + + + + +
ace as a Klipper object, over the page's own MQTT session/multiace/api/state, the FastAPI service
Reachable from the page?yes sw_GetMachineState {objects:{ace:null}} + — 277 ms, measured through run_webkit.py --real. + No new bridge command.no nginx :80 sends no CORS header; + fetch fails Load failed. Measured in the engine.
Topologyall of it mode, + device_count, ace_head, + head_ace {0:0,1:1,2:2,3:0}, + head_feeder {0,1,2 true, 3 false}same
Unit healthall of it model, firmware, connected, temp 31, + humidity 38, dryer_status, auto_drysame
Slot occupancyyes gate_status: [1,1,1,1]yes per-slot state
Slot identity — what filament is in each bayabsent every raw slot reads + {material:"", brand:"", color:[0,0,0], rfid:0, status:"unknown"}present PETG · Kingroon · #83AFFF · + source:"override"
Identity of what is loadedyes head_source[3] = {ace_index:0, slot:2, + type:"PETG", subtype:"Basic", color:"632C2C", brand:"Generic"}same
+ +
The raw Klipper object does not carry per-slot filament +identity, and no amount of reshaping will produce it. multiACE keeps slot overrides in +its own store and merges them in _parse_state(); the Klipper object is what +the hardware reports, and the hardware reports nothing — rfid: 0 on all four, +because these spools have no tags and were typed in. Document 02 called this a +"replicate the reshaping logic" trade-off. It is not: the data is not there to reshape. +

Checked and ruled out on the way: Moonraker's database has no +multiace namespace, and while multiace_web is a +registered Moonraker agent, server/extensions/request answered empty for +every method name guessed at it. That last one is the only lead left and it is unfinished +— it was guesswork, not a read of multiACE's source.
+ +
So the panel has two honest tiers, and the first is free. +
· Tier 1, today, no C++ at all: which units exist and are they well, humidity +and temperature, the dryer, the mode, which head is fed by which unit, whether each bay is +occupied, and the full identity of the filament currently loaded in each toolhead. +That is a genuinely useful panel and it needs one existing command. +
· Tier 2, needs Orca: naming the filament sitting in an unloaded bay. +That needs someone to proxy /multiace/api/state past CORS — a small +sw_ command, or a Moonraker agent call if that lead pans out. +

Tier 1 draws an occupied-but-unidentified bay as occupied-but-unidentified, which +is true, rather than blank, which is not.
+ +
And a level is unknown even in tier 2. +spool_mode is "spoolman" and spool_binding is +{}nothing is bound on this machine, so no bay has a weight behind +it. Document 16 treats the unbound bay as the exception; here it is every bay. Draw the +unknown level as the default state and the measured one as the bonus, not the reverse.
+ + + +

Where it goes

+

Layout L1 hands the Filament panel a 590 px column with real height in +it — which is the reason to settle part 01 before this one. All three versions below are +drawn at that width.

+ + + + + +
+ + + +
+ +
+ +
+
+
Filament + 1 ACE unit + +
+
+ +
ACE mode + NormalMulti + Head + + SET_ACE_MODE MODE=head
+ +

Toolheads — each one picks its own source

+
+
+
Toolhead 1
+
+
+ PLA + Jayo
+
+
+
Toolhead 2
+
+
+ PLA + Forshape
+
+
+
Toolhead 3
+
+
+ + nothing detected
+
+
+
Toolhead 4
+
+
+ PETG + ACE 1 · bay 3
+
+
+
+ +
+ +
+ +
+
+ + ACE 1 + ACE 2 Pro · V1.1.26 · ready + + feed assist off + 💧 38%31 °C + +
+
+
+
+
PETGKingroonbay 1
+
+
PETGKingroonbay 2
+
+
PETGGeneric→ Toolhead 4
+
+
PETGKingroonbay 4
+
+
+
Temperature + 455565 + 70 °C
+
Duration + 246 + 12 h
+
Automatic + OffAbove 45% RH
+ + ACE_DRY ACE=0 TEMP=55 DURATION=4  ·  stop with + ACED__DRY_STOP  ·  automatic is ACE_SET_AUTO_DRY +
+
+
+
+

Drawn from the live payload at 830 px — the width part 01's L3 + gives it. Three heads on stock feeders, the fourth wired to bay 3, and the wire + says so. Every tube is hatched because spool_binding is empty: the + level is unknown and the drawing does not pretend otherwise.

+
+ +
+
+
+
Filament +
+
+
+
+
PLAJayo
+
+
PLAForshape
+
+
+
PETGBasic
+
+
+
+
+
Filament system + 1 unit +
+
+
ACE mode + NormalMulti + Head
+
+
+ + ACE 1 + → Toolhead 4 + + 💧 38%31 °C + +
+
+
PETG
+
PETG
+
PETG
+
PETG
+
+
+
+
+
+

Two panels, and the wire between them cannot be drawn — which + is exactly the information a person opens this to find.

+
+ +
+
+
+
Device control
+
Filament
+
Storage
+
+
+
ACE mode + NormalMulti + Head + + 1 of 4 units connected
+

Toolheads

+
+
PLAT1
+
PLAT2
+
T3
+
PETGT4
+
+
+
+
+
+ + ACE 1 + 💧 38%31 °C + +
+
+
PETG
+
PETG
+
PETG
+
PETG
+
+
+
+
+

Room for four units and a real feed diagram — and a click away from + the camera, on a page whose two destinations were built so the printer session survives + the switch.

+
+ +
+ +
P1 it is — and L3 is what makes it work. 830 px is the +width four toolhead cards need to each carry a source switch, a spool and two buttons; +572 px of height is what lets the unit box sit under them with the feed wires drawn +between. At L1's 590 the cards would be 137 px each and the switch would have to go +somewhere else. +

P2 is rejected on one specific ground: the wire between a bay and the head +it feeds is the single most useful thing on this panel, and P2 puts a panel seam across +it. P3 is not rejected, it is deferred — see A2 below, where two units already +crowd the space. Build P1, keep the unit box a component, promote it to a destination when +a third unit appears. That is the move Storage already made, and the reason +views/storage/storage/ is shaped the way it is.
+ +

The source switch, and where it comes from

+

Bambu puts a two-way tab pair above each AMS group — the cabinet, or the +single external spool — and gives the selected one a green border. The U1 has the same +question and a third answer, because multiACE distinguishes a stock side feeder from a +hand-fed bypass. So the pair becomes a three-way switch on every toolhead card, +and each of the three is a documented macro.

+ +
+ + + + + + + + + + + + + +
sourcewhat it meanswhat it sendsread back from
FeederThe head's own stock side feeder. The ACE never touches it.ACE_SET_HEAD_FEEDER HEAD=n ENABLE=1head_feeder[n]
ACEWired to exactly one unit, and can load only that unit's bays.ACE_SET_HEAD_FEEDER HEAD=n ENABLE=0
ACE_SET_HEAD_ACE HEAD=n ACE=a
head_ace[n], with head_feeder false
ManualHand-fed / TPU bypass. No ACE feed, retract, assist or RFID; the head sensor + stays live.ACE_SET_HEAD_MANUAL HEAD=n ENABLE=1head_manual[n]
+ +
head_ace is not the answer to "what feeds this +head", and reading it as one draws a lie. 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. It is the wiring that +would apply if those heads were ACE-fed; all three are on stock feeders. The source has to +be resolved in order: head_manual first, then +head_feeder, and head_ace only for what is left. +

This is the same bug class STATUS.md spends a section on — a field +that answers a different question than the one being asked, exactly like +toolhead.extruder naming a parked head. It is worth writing down before it is +written into the panel rather than after.
+ +
The switch is a Head-mode control. Both macros say "head +mode only" in their own help. In Normal mode nothing is ACE-fed; in Multi mode one unit +serves every head in turn. So the mode row sits above the toolhead cards — it +governs them — and in the other two modes the switch is disabled with the reason +showing, never hidden. A control that vanishes teaches nobody why.
+ +

The other states it has to draw

+

Four, and only the first was on the machine today. The rest are drawn from +the payload's own vocabulary rather than imagined.

+ + + + + +
+ + + + +
+ +
+
Filament +
+
ACE mode + NormalMulti + Head + 1 unit · head mode
+
+
Toolhead 1
+
+
PLA + feeder
+
Toolhead 2
+
+
PLA + feeder
+
Toolhead 3
+
+
+ feeder
+
Toolhead 4
+
+
PETG + ACE 1 · bay 3
+
+
+
+
+ ACE 1ACE 2 Pro · ready + 💧 38%31 °C +
+
PETGKingroonbay 1
PETGKingroonbay 2
PETGGeneric→ T4
PETGKingroonbay 4
+

As measured — head mode, one unit, three stock feeders.

+ +
Filament +
+
ACE mode + NormalMulti + Head + 2 units · head mode
+
+
Toolhead 1
+
+
PETG + ACE 1 · bay 1
+
Toolhead 2
+
+
PETG + ACE 1 · bay 4
+
Toolhead 3
+
+
PLA + ACE 2 · bay 2
+
Toolhead 4
+
+
TPU + hand-fed
+
+
+ + + +
+
+ ACE 1ACE 2 Pro · feeds T1, T2 + 💧 38%31 °C +
+
PETGKingroon→ T1
PETGKingroonbay 2
PETGGenericbay 3
PETGKingroon→ T2
+
+ ACE 2ACE Pro · feeds T3 + 💧 52%29 °C +
+
PLAGeneric→ T3
PLAGenericbay 2
 bay 3
 bay 4
+

Two units. Heads 1 and 2 share ACE 1; head 3 has ACE 2; head 4 is hand-fed TPU. One unit may feed several headshead_ace is a map from head to unit and says nothing about the reverse.

+ +
Filament +
+
ACE mode + NormalMulti + Head + + the ACE feeds nothing in this mode
+
+
Toolhead 1
+
+
PLA + feeder
+
Toolhead 2
+
+
PLA + feeder
+
Toolhead 3
+
+
+ feeder
+
Toolhead 4
+
+
PETG + feeder
+
+
+
+ ACE 1connected, not in use + 💧 38%31 °C +
+
PETGKingroonbay 1
PETGKingroonbay 2
PETGGenericbay 3
PETGKingroonbay 4
+

Normal mode. The per-head switch is disabled, not hiddenACE_SET_HEAD_FEEDER and ACE_SET_HEAD_ACE are documented "head mode only", so the control has to say why it is inert. The unit is still drawn, because it is still there and still drying.

+ +
Filament +
+
ACE mode + NormalMulti + Head + + drying — loads refused until it stops
+
+
Toolhead 1
+
+
PLA + feeder
+
Toolhead 2
+
+
PLA + feeder
+
Toolhead 3
+
+
+ feeder
+
Toolhead 4
+
+
PETG + ACE 1 · bay 3
+
+
+
+
+ ACE 1ACE 2 Pro · drying + + ◈ 55 °C2 h 41 m left + 💧 31%54 °C +
+
PETGKingroonbay 1
PETGKingroonbay 2
PETGGeneric→ T4
PETGKingroonbay 4
+

Drying. dryer_status gives target, duration and remaining, so the pill can count down; humidity is falling and the internal temperature is the dryer's, not the room's.

+
+ +
A2 is the one that argues for P3 eventually. Two unit boxes plus +four toolhead cards is 640 px of panel, which fits the 572 L3 gives it only just. +A third unit does not fit, and ace_device_count goes to 8. The unit box is +therefore a component from the first commit, and promoting it into a destination is a +move, not a rewrite.
+ +

What the panel can actually do, not just show

+

Doc 09 listed "multiACE control endpoints (writes)" as the main +blocker and left the dryer, the slot editor and the mode switch UI-only. Read out of +printer.gcode.help on the machine, that blocker is gone: every one of them +is a plain G-code macro with documented arguments, and the page already owns +sw_SendGCodes.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
controlwhere it liveswhat it sendsneeds
Source switch — feeder / ACE / manualeach toolhead cardACE_SET_HEAD_FEEDER · ACE_SET_HEAD_ACE · ACE_SET_HEAD_MANUALhave it
ACE modethe row above the cardsSET_ACE_MODE MODE=normal|multi|head [HEAD=n]have it
Load a headtoolhead cardACE_LOAD_HEAD HEAD=0 [ACE=0] [SLOT=0]have it
Unload a headtoolhead cardACE_UNLOAD_HEAD HEAD=0 [RETRACT_LENGTH=] [KEEP_HEAT=]have it
Unload everythingpanel header overflowACE_UNLOAD_ALL_HEADS · ACE_UNLOAD_ALL_CANCELhave it
Swap a head to another baytoolhead card, and each bayACE_SWAP_HEAD HEAD=0 ACE=1 [SLOT=0]have it
Dry, with temperature and durationthe unit's Dry sheetACE_DRY ACE=0 [TEMP=] [DURATION=] · ACED__DRY_STOPhave it
Dry automatically above a humiditythe same sheetACE_SET_AUTO_DRYhave it
Switch the active unitunit header, when there are twoACE_SWITCH TARGET=0 [AUTOLOAD=1]have it
Flush length for the next swappanel overflowACE_SET_PURGE LENGTH=<mm> | RESET=1have it
Confirm before load/unloadpanel overflowACE_SET_CONFIRM_COMMANDS ENABLE=0|1have it
Spoolman URL and auto-syncpanel overflowACE_SET_SPOOLMAN URL= AUTO=0|1have it
Clear the head→bay bookkeepingpanel overflowACE_CLEAR_HEADS [HEAD=0]have it
Name the filament in an unloaded baythe bay's own sheet— no macro; multiACE's own override storetier 2, needs Orca
Bowden calibrationACE_CALIBRATION_START / _FEED / _MARK / _RETURN …a wizard of its own
Background swap while printingACE_BG_SWAP · ACE_BG_UNLOAD · ACE_BG_MOVEexperimental — see below
+ +
Everything in "panel overflow" is a settings list, not a row of +buttons. Six of these are preferences a person sets once — flush length, confirmation, +Spoolman, auto-dry defaults. They belong behind the header's ?/overflow the +way the camera's belong behind its gear, and putting them on the face of the panel would +bury the four things that change daily.
+ +
Every one of these is slow, and an instant ok is +indistinguishable from success. That trap is already written up in +STATUS.mdPARK_EXTRUDER against a head that is not there +returns immediately and successfully — and the ACE is worse, because +ACE_BG_UNLOAD's own help says ~3 min. So the same rules apply +without amendment: never await the request, confirm against machine state +(swap_in_progress, last_swap_result, +head_source, dryer_status.status), and drive it through +core/pending.js. ace.event_seq and +ace.swap_phase exist and look purpose-built for exactly this — neither has +been watched through a swap yet.
+ +
One thing to be careful about offering. Everything marked +[EXPERIMENTAL] in the macro help — ACE_BG_SWAP, +ACE_BG_UNLOAD, ACE_BG_MOVE — carries real preconditions in its +own help text ("requires head mode, 1:1 wiring, an OPEN dock below the head (purges +~60 mm!)"). A panel that surfaces them without those preconditions is a panel that +purges filament onto a bed. Leave them out of v1 and say so, the way +check_coverage.py requires.
+ + +

04The job card is 304 px of a 150 px idea

+

Part 01 pinned Printing Task at 150 and clipped it; sizing it to its card +made it fit at 304. Both numbers were wrong about the same thing — the card is +a stack when the panel is a band. It is 766 px wide and 304 tall, and it spends +that on a 152 px thumbnail, a 40 px grey percentage, a row for the machine's own +name, and a row of its own for two buttons.

+ +
The machine's name is already in the rail. +.job-dev renders it at 17 px, 24 px tall with its badge, directly +below a rail entry that says the same thing. That row is not compression — it is a +duplicate, and it goes whichever version wins.
+ + + + + + + + + + +
+ + + + +
+ +
+ +
Printing Task + 306 px
+
+
printingU1 G
+
+
+
+
MC_Center_Rack_Key-wall_LVL_02_PETG_1h1m.gcode
+
64%
+
98/153— 0h 22m
+
+
+
+ +
+

The card as it is. The two things taking the room are the + 152 px thumbnail and the buttons' own row — together with the machine-name row + they are 210 of the 304.

+
+ +
Printing Task + 149 px
+
+
+
+
MC_Center_Rack_Key-wall_LVL_02_PETG_1h1m.gcode + + 48min16s24.03 g
+
64% + + Layer 98/153— 22 min
+
+
Printing
+
+
+

Bambu's shape. Three moves and nothing else: the thumbnail anchors + the block instead of sitting above the bar, the metadata joins the name's line, and + the buttons ride the bar's line — which is the single biggest saving, because a + 6 px bar and a 30 px button on separate rows cost 70 together and 30 side by + side.

+
+ +
Printing Task + 120 px
+
+
+
+
MC_Center_Rack_Key-wall_LVL_02_PETG_1h1m.gcode + 64% + 48min16s24.03 g
+
+
Layer 98/153 + — 22 minPrinting
+
+
+

The percentage joins the name's line and the numbers move below the + bar. The percentage stops being the thing you see first, which is the cost — and + the bar already says the same thing without reading.

+
+ +
Printing Task + 64 px
+
+
+ + MC_Center_Rack_Key-wall_LVL_02_PETG_1h1m.gcode + 64% + + 98/153— 22 min + +
+
+

One row. It works at 766 px and it is the version that + breaks first: below about 640 the filename has no room left to be a filename, and + under L3 the main column is 446 at a 1600 window.

+
+ +
+ +

What the height actually buys

+

This is the part worth doing before choosing, and it is not what it looks +like. The camera above takes whatever the card gives up — but only turns it into picture +when the camera is height-bound, and at 1920 it is not: 766 px wide at 16:9 +is 431 tall, and everything above that is letterbox.

+ +
+ + + + + + + + + + + + + + + + + +
versioncardpanelat 1920 · camera 766 wideat 2560 · camera 1406 wide
camerawhat it gainedframewhat it gained
T0 today306346574frame 766×431, 51 px bars949×534all picture
T1 band149189731frame unchanged, 130 px bars1228×691+157 px of picture
T2 tighter120160760frame unchanged, 144 px bars1280×720+186
T3 one line64104816frame unchanged, 172 px bars1380×776+242
+ +
These are the drawn heights, not the intended ones. The table +first read 150 / 106 / 66 from arithmetic; the sheet's own checks measure what is on +screen and T2 came out 14 px taller than claimed. Small, and the point of measuring +is that nobody had to notice.
+ +
At 1920 a shorter card buys black, not picture. The camera is +width-bound there — it cannot use the height, so every pixel the card gives up becomes +letterbox. T3 saves 242 px and turns all 242 into bars. So "room efficiency" is +not the argument for this change at the common window size, and saying otherwise would +be selling it on a benefit it does not have.
+ +
Two arguments that do survive. +
· At 2560 it is all picture. Past about 2000 the camera becomes height-bound +and every pixel the card releases is a pixel of frame — T1 is +157, T3 is +242. +
· The band is the better card at any size. Reading it is one left-to-right +sweep instead of four stacked blocks, the metadata sits where the eye already is, and the +buttons are next to the bar they act on rather than centred under it. That is worth doing +whether or not anything else gets the space.
+ +
Built as T1, with one change: the status word went to the panel +header. The Finished line under the bar was a fourth row for one word, and a +panel header already exists to say what a panel is - saying what it is doing in +the same line costs nothing. That, plus dropping the machine-name row the rail duplicates, +took the drawn card to 128 px rather than T1's 149. The camera above went +576 → 752.
+ +
T1 was the recommendation. It is the shape in the screenshot, +it holds the filename at 446 px (the 1600 case, which is where L3 is weakest), and it +keeps a percentage big enough to read across a room — which is what a device panel is +for. T2 is the fallback if the panel ever has to sit somewhere shorter; it costs +the large percentage and nothing else. T3 is kept as the record: it is genuinely +the most efficient and it stops being a filename below about 640 px.
+ +
One thing to check before building any of them. The card +rebuilds on a signature and patches the numbers, so a control that moves between rows must +keep its class names or `renderTask` patches nodes that are no longer there. And the idle +state is the same card — one card at every state, zeroed rather than replaced, is +a decision conformance_test.py holds — so whichever row the buttons land on +has to hold an idle machine's single "choose a file" button without collapsing.
+ +

05Order to build in

+

Everything above is decided. Steps 1-4 are built, driven against the +printer and committed; the rest is the multiACE work, deferred. The column on the +right is the one that matters: only step 8 touches C++.

+ +
+ + + + + + + + + + + + + + + + + + + + + +
#steptouchesrebuild?
1 doneLayout L3 + H1. Two flex columns in place of the 2×2 + grid, the 830 rail, .panel-body's min-height released so + Control sizes to its cluster, Task sized to its card, the camera filling its column. Also + the breakpoint from 1420 to 1600. Nothing else can be judged until the panels are + their real size, and no panel's own code changes.device.cssno
2 doneCamera: direct snapshot, and the Auto probe. Enumerate + over :7125, filter to entries with a snapshot_url, poll with + <img>. The 28× improvement, and the only camera work with no + platform question in it.camera-*.js · protocol.jsno
3 doneCamera: the settings popover. Camera list, transport + list with reasons, fps cap. core/overlay.js already does anchored + popovers.camera-*.jsno
4 doneCamera: the view grid. One / split / 2×2 / PiP, one + poller per tile, and the focus rule that keeps it under 4 MB/s.camera-*.jsno
5ACE tier 1, read-only. One + sw_GetMachineState {objects:{ace:null}} folded into the state stream, the + toolhead cards, the unit box, the feed wires. Source resolved + manual → feeder → ace, in that order.filament-*.js · protocol.jsno
6ACE writes. Source switch, load/unload/swap, the dry + sheet — each pending-until-confirmed against swap_phase, + head_source and dryer_status, never against its own + request.filament-commands.jsno
7ACE settings overflow — flush length, confirmation, + Spoolman, auto-dry defaults.filament-*.jsno
8ACE tier 2 — a proxy for + /multiace/api/state past CORS, so an unloaded bay can be named and a bound + bay can show grams.SSWCP.{hpp,cpp}yes
9Camera: H.264 on Windows and macOS, once the probe has + been run there. Nothing to do on Linux.camera-*.jsno
+ +
Steps 1–7 need no C++ and no rebuild. Between them: the layout, +a camera 28× faster than today with a settings panel and a four-up grid, and an ACE panel +that reads the machine and drives it. All on the page's existing command surface, all live +on reload.
+ +
Two things to fix in the page's own checks while doing it, or +they will go green over a broken panel the way they have before. +
· check_coverage.py reads CMD. references out of the command +module a panel is handed. Every ACE control goes through one command — +sw_SendGCodes — so coverage will count the whole ACE surface as one +reachable command and be satisfied. The G-code names need their own list, checked the +same way, or this panel is exactly the "a panel with no button counted every command" +hole reopened. +
· A DOM dump before and after step 1. The layout change moves every panel in the +grid, and a ReferenceError has already once left this page with a column +missing while all 17 browser checks passed.
+ +

06How to re-run any of this

+

Nothing above is a claim you have to take on trust. These are the exact +commands, and they are all read-only except where marked.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
claimcommand
Four webcams, two of them realcurl :7125/server/webcams/list
Snapshot sizes and timingscurl -w '%{size_download} %{time_total}' http://IP/webcam/snapshot.jpg
14.0 fps, no H.264, no WebRTC, and the CORS splitrun_webkit.py --drive cam_caps.js
The ACE object over the page's own session, 277 msrun_webkit.py --real --drive ace_probe.js
Raw slots carry no identitycurl ':7125/printer/objects/query?ace'
Full identity exists in the FastAPIcurl http://IP/multiace/api/state
Every ACE macro and its argumentscurl :7125/printer/gcode/help
multiACE is a registered Moonraker agentcurl :7125/server/extensions/list
The printer caps itself at 15 fpscurl :7125/server/webcams/list — target_fps
head_ace names units that do not existcurl ':7125/printer/objects/query?ace' — head_ace vs device_count
This page's own geometry, in Orca's enginepython3 docs/u1-webui/tools/check_mockup.py <this file>
+ +
Orca must be closed for --real — it authenticates +with the same saved clientId and a broker evicts the older holder. The plain +curl lines above do not care.
+ + + + diff --git a/docs/u1-webui/02-device-page/multiace-actions.html b/docs/u1-webui/02-device-page/multiace-actions.html new file mode 100644 index 00000000000..4a69a85a328 --- /dev/null +++ b/docs/u1-webui/02-device-page/multiace-actions.html @@ -0,0 +1,3179 @@ + + +Every Filament, and What Can Be Done To It + + + + + + + +
+

Every filament, and what can be done to it

+

Load, unload, swap and background swap — for a filament in a +bay and for one at a stock feeder, which does not get all four. And the axis +none of the four studies before this one drew: width. The card is exact at +830×456 and brittle either side, so every state here is measured at five panel +widths rather than at one.

+

A live panel · three open questions above it · every state +pre-rendered, so it reads with no script · siblings: +shapes · +arrangements · +the card · +the cabinet

+ + + +

01What is settled coming in

+
+ + + + + + + + + + + + + + +
verbmacroapplies to
LoadACE_LOAD_HEAD HEAD=n [ACE=a] [SLOT=s]a bay, and a feeder headACE_SET_HEAD_FEEDER's own + help says a feeder head “loads/unloads via its stock side feeder”
Unload · Unload and retractACE_UNLOAD_HEAD HEAD=n [RETRACT_LENGTH=mm] [KEEP_HEAT=°C]Any loaded head. On an ACE head it is unload and retract — the + unit can pull the filament all the way back into its bay, which is what + RETRACT_LENGTH is for and what a stock feeder cannot do
SwapACE_SWAP_HEAD HEAD=n ACE=a [SLOT=s]
+ ACE_UNLOAD_HEAD HEAD=n then + ACE_LOAD_HEAD HEAD=n ACE=a SLOT=s
a bay only — it takes a slot. Corrected 2026-08-28, see below
Background swapACE_BG_SWAP HEAD=0-3 SLOT=0-3 [ACE=n] [TEMP=] [ANTI_OOZE=] [QUIET=1] [FORCE=1]a bay on a head declared capable by ACE_BG_SET_HEAD, and on + no other
Background unloadACE_BG_UNLOAD HEAD=0-3 [TEMP=]the same head, the same gate
+
Corrected 2026-08-28 — a swap is not ACE_SWAP_HEAD. +That macro is the print's swap. It opens with an unconditional +G91 / G1 Z2 F600 / G90 to lift the nozzle off the part before it parks and +picks the head, and Klipper refuses a Z move on an unhomed Z — so from a button on an +idle machine at homed_axes: "xy" it answered ok, printed +!! Must home Z axis first and did nothing. ACE_LOAD_HEAD and +ACE_UNLOAD_HEAD contain no Z motion at all, and the pair is what +multiACE's own dashboard (app.js, loadSlot) and HelixScreen +both send for this verb. Everything this study settled about the verb still holds +— where it lives, what it is called, what it is offered on, what it draws while it +runs. Only the line that goes out changed. The record is +round fifteen; the demo below still logs the old +macro.
+ +
The gate is real hardware, not a preference. +ACE_BG_UNLOAD's own help states the requirements: head mode, 1:1 wiring, +an OPEN dock below the head — it purges ~60 mm — and the head stays +docked for the whole ~3 min sequence. Measured on 2026-08-26, +sw_GetMachineState {objects:{ace_bg_swap:null}} answers +{"version":"v0.9","enabled_heads":[],"busy":[],"state":{}}: no head on this +machine is enabled, which is why the disabled case is the one drawn first.
+ +
A verb that is not a thing in this state is not listed. The +first cut of this study showed all five always and greyed whatever did not apply, and that +is right for a verb the machine is refusing — a background swap on a head +nobody has declared, where the reason names a macro you can send — and wrong for a +verb that does not exist here. Loading a head that is already loaded is not a load, it is +a swap; swapping to the bay you are already fed from is a three-minute no-op. So: +not applicable → left out; applicable but refused → listed, greyed, and +naming the macro that would lift the refusal. +
+ + + + + + + +
wherestatewhat it offers
Stock feederemptyLoad, and nothing + else — it has no second bay, so swap and both background verbs are not + operations it has
loadedUnload, and nothing else
An ACE bayhead emptyLoad from that + bay
head loaded, another baySwap and Background swap — + the same gesture, but the head has to be emptied first
head loaded, this bayNo load and no swap. It is already + feeding
Any loaded ACE headUnload and retract, and + Background unload behind the gate
+ +

02The panel, and an edge to pull

+ + + + + + + + + + + + + + + + + + + +

Nothing below is a switch. Drag the panel's right edge and the two widths +that matter find themselves — the ruler marks them where they fall, and the readout says +what the cabinet has left. Click a bay for its sheet and a verb in the sheet to send +it: a swap then runs on the card the way it runs on the machine, in phases, and can be +finished or failed from the printer's own line below. The gate is opened the way the +machine opens it, by sending ACE_BG_SET_HEAD from the verb that is missing +it.

+
+Width +Verbs in +Swap reported +In flight +Sheet +ace_bg_swap +
+
Without script the panel cannot be dragged or clicked, so +every state it can be in is pre-rendered above and picked with the row of labels.
+
+

The panel below is live. Every control on it +does what it does on the page — the menus open, ACE mode opens +its list, ? opens the help, the Dry chip opens the dryer, a bay opens +its sheet, and a verb sent from a sheet runs a swap you can watch. The rows here are the +questions that are still open: where a verb is chosen, where a running swap is +reported, and how wide the panel is — which you can also just drag.

+
Where a verb is chosen
+
Where a running swap is reported
+
Panel width — or drag the edge
+
+
+
+
+
+
+
+
+
body
+
+
+
+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 902 · card cell 427
body455 / 456 · 1 spare

The panel is 902; the cabinet has 97 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 782 · card cell 367
body455 / 456 · 1 spare

The panel is 782; the cabinet has 37 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 708 · card cell 330
body455 / 456 · 1 spare

The panel is 708; the cabinet has 0 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 662 · card cell 307
body455 / 456 · 1 spare

The cabinet is 23 px wider than the card has room for. It does not resize: a bay is flex: 0 0 on .slot's own 62 px, so four of them plus their gaps and shoulders is 310 px at every width there is. Past 655 what gives is the drawing, and it gives in silence.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 560 · card cell 256
body455 / 456 · 1 spare

The cabinet is 74 px wider than the card has room for. It does not resize: a bay is flex: 0 0 on .slot's own 62 px, so four of them plus their gaps and shoulders is 310 px at every width there is. Past 655 what gives is the drawing, and it gives in silence.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
Pick a bay, then:
panel 830 · card cell 391
body480 / 456 · 24 over

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit · swapping
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
A1Toolhead 4
Home
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Step 1 of 6: Home. The words are the U1's own channel_state — this one is unload_homing — so the bar is DETERMINATE: it is which of a known list the printer is in, not a percentage invented from a phase word. It is said in four places, because any one of them can be off screen: the bay it is leaving, the head it is going to, the line in the gutter beside that head, and the panel header.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit · swapping
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
A1Toolhead 4
Home
panel 830 · card cell 391
body480 / 456 · 24 over

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Step 1 of 6: Home. The words are the U1's own channel_state — this one is unload_homing — so the bar is DETERMINATE: it is which of a known list the printer is in, not a percentage invented from a phase word. It is said in four places, because any one of them can be off screen: the bay it is leaving, the head it is going to, the line in the gutter beside that head, and the panel header.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit · swapping
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
A1Toolhead 4
Home
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Step 1 of 6: Home. The words are the U1's own channel_state — this one is unload_homing — so the bar is DETERMINATE: it is which of a known list the printer is in, not a percentage invented from a phase word. It is said in four places, because any one of them can be off screen: the bay it is leaving, the head it is going to, the line in the gutter beside that head, and the panel header.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
unload_fail
Home
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

It stopped in unload_fail, on Home. A failure is a firmware state and is shown as one; translating it into friendlier prose would be this page's opinion of what happened. It stays until something else happens, because a failure that clears itself is a failure nobody saw — and the head still holds what it held before.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

No head is background-capable. enabled_heads is [] — measured — so both background verbs are drawn unavailable, and each names ACE_BG_SET_HEAD rather than explaining itself. A control that offers a ~60 mm purge into a closed dock is the one mistake on this panel that costs filament and a bed.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C

A1

Toolhead 4
A1
PETG BasicKingroon · KR-PETG-83AFFF
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

Toolhead 4 is background-capable. enabled_heads is [3] because ACE_BG_SET_HEAD was sent from the sheet, which is how a head is declared on a machine too. The measured machine enables none, so this state is reached rather than shipped.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C

A3

Toolhead 4
A3
PETGGeneric
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

Toolhead 4 is background-capable. enabled_heads is [3] because ACE_BG_SET_HEAD was sent from the sheet, which is how a head is declared on a machine too. The measured machine enables none, so this state is reached rather than shipped.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C

Stock feeder

Toolhead 1
PLA BasicJayo · JY-PLA-F44336
panel 830 · card cell 391
body455 / 456 · 1 spare

The panel is 830; the cabinet has 61 px to spare. Drag the edge left until it has none — that is 708, and it is where the box stops sitting in the card and starts eating its padding. Keep going to 655 and the fourth bay begins to be cut: not squeezed and not scrolled, because .ace-card is overflow: hidden. Both numbers are measured by this page, at load, by walking its own width.

Nothing is in flight. Click a bay, then Swap, and watch it: six steps for a swap, five for a load, four for an unload — and a live nozzle reading on the one that heats. Nothing on the card moves to make room, because the line lives in the gutter beside the toolhead, which is the only space this card has spare.

Toolhead 4 is background-capable. enabled_heads is [3] because ACE_BG_SET_HEAD was sent from the sheet, which is how a head is declared on a machine too. The measured machine enables none, so this state is reached rather than shipped.

+
+
+

+ + + + +

03What the width actually does

+

Drag it and two widths turn up. Neither was in the deck of five this study started +with, and the second was got wrong by arithmetic before it was measured: a bay is not +cut when the box is wider than the card, it is cut when the overflow has eaten the +16 px shoulder and the card's own 10 px of padding and border. The page +finds both itself, at boot, by walking its own width until each thing stops fitting — +so the marks on the ruler move if the drawing ever does.

+
+ + + + + + + + + + + + + +
panelcardroom inside itcabinetwhat happens
90242740731097 px spare
830391371310The Device page's own --col-w: 61 px spare. Every number in + the four studies before this one
78236734731037 px spare
708330310310Exactly none. The floor: below this the cabinet is eating the card's + own padding
66230728731023 px past the room. Nothing is cut yet — the shoulder and the card's + padding pay for it
655303283310The second floor, and the one arithmetic got wrong. Below this the fourth + bay starts being cut
560256236310The fourth bay is cut to 14 px of 62. .ace-card is + overflow: hidden, so it is not squeezed and it does not scroll: it is + gone, in silence
+

The cabinet does not resize, and that is deliberate. A bay is +.slot's own 36 px disc over its 58×19 name pill and +flex: 0 0, so four bays plus three 10 px gaps plus 16 px of +shoulder either side is 310 px at every width above — measured, not +computed. Shrinking it would mean a second set of proportions for the same object, which +is the thing docs/ace-mmu/16-ace-visuals.md exists to prevent.

+
The card is not the first thing to run out, and that is luck rather +than design. .control-grid is a fixed 758 px cluster and is already +clipped at a 1100 px window (#control-body: 770/722), while the ACE card +survives to about 1010. So today the Control panel breaks first. Nothing holds that in +place: the moment a bay grows a hit target the order changes, which is the reason this +study sweeps the width instead of asserting one.
+ +

04Where a verb should live

+

Four placements, all live above, and the answer is not one of them — it is a +split, and the split falls exactly where the macros do.

+
+ + + + + + +
foragainst
The bay sheetThe only placement with room for the macro line and + for a reason per verb. It is also the dialog the page already opens when a bay + is clickedA click to get there
The card menuAdds nothing to the drawingA verb that takes a SLOT= has to ask which bay afterwards, so the + bay is chosen twice
The toolheadEvery one of these macros addresses + HEAD=n, so the head is what they are about. It is also the one + target that is the same size at every panel width, where a bay is 62 px and + shrinkingTwo of the verbs take a SLOT= and a head is not a + slot, so the bays have to come to the sheet — which they do, one button each, + saying what clicking them would do in this state
A row under the boxOne click from the drawing; the row is there + anyway while a swap runsCosts height in a body with none spare, and says + nothing until a bay is selected
+

The toolhead is a real answer, and the drawing says so. Under the pointer it +wears the same 1.5 px accent edge a bay does — traced, no fill, drawn as a +backing behind the artwork so nothing moves — because two things you can click on +one card should not answer differently.

+

All three are live above — switch the row and the panel changes under you. +What the measurement then says: the menu costs a second dialog (the bay still has to come +from somewhere, so it opens a picker), and the row costs 24 px the body has not got.

+

Settled, and it is three of the four rather than one: the toolhead gathers everything that can be done to one head, the bay sheet is the shortcut for the filament you are pointing at, and the card menu keeps the verbs that take no slot. The row is the one that lost, and it lost on measurement. Picking a single winner would have cost the other two, and they cost nothing to have together — they are one list read three ways.

+

The split still falls where the macros do. +ACE_LOAD_HEAD and +ACE_UNLOAD_HEAD address a head, so they have nothing to choose and +belong in the card's where they already are. +ACE_SWAP_HEAD and ACE_BG_SWAP take a SLOT=, so the +bay is the argument — and clicking the argument is a shorter sentence than +picking a verb and then being asked for it. The row never chooses anything; it reports.

+ +

05The busy state, and where its words come from

+

Not swap_phase. The steps are the U1's own +channel_state, which has a documented vocabulary per direction — and +HelixScreen, which drives a U1 touchscreen, +already classifies every one of them. Its Snapmaker backend is the model this takes rather +than a second vocabulary for the same machine.

+
+ + + + + + + + + + + + + +
directionchannel_statestep
unloadunload_prepare, + unload_homingHome
unload_pickingSelect
unload_heating, unload_heat_finishHeat nozzle — with a live reading
unload_doingRetract filament
loadload_prepare, + load_homingHome
load_pickingSelect
load_heatingHeat nozzle — live
load_feeding, load_extrudingFeed filament
load_flushingPurge
either*_finish · *_failthe end of it, or the state it stopped in
+

A swap on an ACE-fed head is ONE bar with two halves — Home, Select, Heat +nozzle, Retract filament, Feed filament, Purge. The load half's own Home / Select / +Heat are deliberately absent: by then the head is mounted and already hot from the unload +half, so they pass straight through. Start one above and watch it.

+
There is no step for the ACE fetching the new bay, and that is a +hardware finding rather than an omission. The design assumed a long blind window +between the halves — the unit spooling the new bay down to the U1's gear with +nothing on channel_state. Measured on a live U1 it is a ~4 second blip in a +~100 second operation, because the bay is already staged at its gate. A permanent row +that is complete before it is read is worse than no row: the bar holds on Retract +filament across it, which is honest.
+

So the bar is determinate, and this study said the opposite before. The earlier +note here read the band moves and does not fill — swap_phase is a +word, not a fraction. That was right about swap_phase and wrong about the +machine: channel_state names which of a known list the printer is in, +so step 4 of 6 is a real quantity and one tick per step is the honest drawing. What +is still not derivable is a percentage within a step, and none is drawn.

+

The heat step reads the nozzle. Heat nozzle 178/240 °C, +because “it is heating” and “it is heating, and here is how far” +are different answers and only the second one tells you whether to wait. HelixScreen +carries one more rule worth taking with it: when the temperature feed freezes, a fixed +225/230 reads as stuck rather than busy, so it swaps the reading +for Working… until the feed comes back.

+

A failure is a state, not a sentence. unload_fail is what the +firmware reports and what the row shows, beside the step it stopped on. Translating it into +friendlier prose would be this page's opinion of what happened.

+
+ + + + + +
wherewhat it says
The bay it is leavingThe accent ring the pointer uses, held rather + than hovered, and the name pill in the same blue
The head it is going toA halo on the sensor mark — the one + thing on the artwork that is already the head's own
The gutter beside that headA3 → Toolhead 4, one + tick per step, and the step's own name
The panel headerswapping, beside the unit count
+ +

06The gate, and why the disabled case is drawn first

+

ACE_BG_SWAP is the one control on this panel that can cost filament and a +bed. Its own help spells out what it needs — head mode, 1:1 wiring, an OPEN dock +below the head, and the head docked for the whole ~3 min sequence — and +ACE_BG_SET_HEAD's help says what declaring a head means physically: +its dock is OPEN below, the cold-pull purges through it.

+

That claim is not in the ace object. All 38 of its top-level keys are +listed in the handover and there is no bg_swap among them — +ace_bg_swap is a Klipper object of its own, and +sw_GetMachineState {objects:{ace_bg_swap:null}} answers with it. Measured +2026-08-26, and asserted in ace-real.js:

+
{"version": "v0.9", "enabled_heads": [], "busy": [], "state": {}}
+

So on the machine this was built against, every background verb is unavailable. +Drawn unavailable, naming ACE_BG_SET_HEAD HEAD=n ENABLE=1 and nothing else +— the copy rule holds here exactly as it does everywhere else on the panel: a macro +name says what would be sent, and a paragraph about open docks belongs in this directory.

+ +

07The feeder's two missing verbs

+

A feeder spool is a filament like any other: it draws as one, carries its own +provenance mark, and ACE_LOAD_HEAD and ACE_UNLOAD_HEAD apply to +it unchanged — ACE_SET_HEAD_FEEDER's help says so in as many words. +What cannot apply is ACE_SWAP_HEAD and ACE_BG_SWAP, because both +take a SLOT= and a stock feeder has no second bay.

+

Greyed with the reason, not hidden. Both were drawn, and the choice is the one +the panel already made for the source selector: disabled with the reason showing, never +hidden — a control that vanishes leaves no way to find out why it is not there. A +feeder card that silently has two fewer verbs than its neighbour reads as a card that is +missing something. Click the spool at Toolhead 1 to see it.

+ +

08Checking it

+

Every number above is asserted in check_mockup.py, which drives this file +in WebKitGTK — the engine Orca renders with — rather than reading its source. +What it holds:

+
    +
  • the body fits 456 at every width the panel can be dragged to, because + .panel-body is overflow: hidden and a body past it is + clipped in silence;
  • +
  • the two named widths are found by dragging rather than typed into the check: + it walks the edge in and asserts where the cabinet runs out of room and where a bay + starts being cut;
  • +
  • a verb sent from a sheet actually runs, and the swap it starts ends in + last_swap_result;
  • +
  • a swap in flight is visible in all four places at once;
  • +
  • every background verb is unavailable while enabled_heads is empty, and + each says ACE_BG_SET_HEAD;
  • +
  • the four cards stay the same height whether one of them is busy or not;
  • +
  • no two marks on the ruler sit on each other, and the axis does not move while the + panel does;
  • +
  • every pre-rendered copy still matches what the renderer produces, so a forgotten + bake_mockup.py is caught rather than shipped.
  • +
+
python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-actions.html
+python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-actions.html
+ +
diff --git a/docs/u1-webui/02-device-page/multiace-cabinet.html b/docs/u1-webui/02-device-page/multiace-cabinet.html new file mode 100644 index 00000000000..1a801e314b3 --- /dev/null +++ b/docs/u1-webui/02-device-page/multiace-cabinet.html @@ -0,0 +1,2406 @@ + + +The ACE Cabinet + + + + + + + +
+

The filament card

+

Every choice is made. This is the card as it stands, in the states it has: at rest, under the pointer, with the dryer running, and with the dryer dialog open — which is what the Dry chip opens, and the only thing that opens it.

+

Live · two open axes · every option pre-rendered, so it +reads with no script · panel 830×456, measured · siblings: +shapes · +arrangements · +the card

+ + + +

01Settled

+
+ + + + + + +
The bay.slot's own 36 px disc over its + 58×19 name pill, 6 px apart
The cabinetTwo halves, #EEEEEE over + #CECECE, seam through the roll, hugging its spools with 16 px of + shoulder
The feederThe same drawing in #FFFFFF over + #1F1F1F — Snapmaker's Automatic Filament Feeder Module, + with the two label strips its front carries. Its badge is the same silhouette in + the same palette, so the tiny logo and the frame are one idea
The unit rowTwo lines in the header: toolhead and source above, + badge · model · humidity · dryer below
The pillAMSHumidity's own: two readings on the + block grey with a 1 px #C2C2C2 divider — what the C++ widget + draws
+ +

02Two things left

+ + + + + +
The card
+
Without script each option is shown with the other at its +recommended setting.
+
+
Pointer (an aid: a pointer cannot be held across a re-render)
+ +
Dryer
+
ACE units
+
Sensor states
+
+
+
+
body
+
+
+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
body455 / 456 · 1 spare

At rest. A bay is a roll and a type chip on the cabinet and nothing else: anything drawn round them permanently would either hide the seam that runs through the roll or fight it. Point at one to see the edge.

The dryer is idle. Click the Dry chip in a unit row to open it: a droplet filled to the reading, the two readings, and four presets plus an empty field for each value ACE_DRY takes.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
body455 / 456 · 1 spare

The pointer is on A1. A 1.5 px accent edge appears round the roll and its chip — the blue the rail already uses for the selected destination — and the card above lists the whole record that bay's tag carries. Nothing is drawn round a bay at rest.

The dryer is idle. Click the Dry chip in a unit row to open it: a droplet filled to the reading, the two readings, and four presets plus an empty field for each value ACE_DRY takes.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C
body455 / 456 · 1 spare

At rest. A bay is a roll and a type chip on the cabinet and nothing else: anything drawn round them permanently would either hide the seam that runs through the roll or fight it. Point at one to see the edge.

The dryer is running. The chip reads 1 h 19 m / 4 h — where you are and how long it was set for. Click it for the dialog, which repeats

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C

Filament drying

ACE A · ACE 2 Pro · V1.1.26
Idle
Humidity38 %
Temperature31 °C
Temperature45 °C55 °C65 °C70 °C
Duration2 h4 h6 h12 h
Automatically, aboveOff45 %55 %65 %
ACE_DRY ACE=A TEMP=55 DURATION=4 · ACE_SET_AUTO_DRY
body455 / 456 · 1 spare

At rest. A bay is a roll and a type chip on the cabinet and nothing else: anything drawn round them permanently would either hide the seam that runs through the roll or fight it. Point at one to see the edge.

The dryer is idle. Click the Dry chip in a unit row to open it: a droplet filled to the reading, the two readings, and four presets plus an empty field for each value ACE_DRY takes.

The dialog is open. It is painted over the panel on its own scrim, so it costs the 456 px body nothing. Close it with ×, with Close, or by clicking the scrim.

+
Filament|1 ACE unit
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro38 %31 °C

Filament drying

ACE A · ACE 2 Pro · V1.1.26
Drying
Humidity38 %
Temperature54 °C
Temperature45 °C55 °C65 °C70 °C
Duration2 h4 h6 h12 h
Automatically, aboveOff45 %55 %65 %
1 h 19 m of 4 h, at 55 °C.
ACED__DRY_STOP
body455 / 456 · 1 spare

At rest. A bay is a roll and a type chip on the cabinet and nothing else: anything drawn round them permanently would either hide the seam that runs through the roll or fight it. Point at one to see the edge.

The dryer is running. The chip reads 1 h 19 m / 4 h — where you are and how long it was set for. Click it for the dialog, which repeats

The dialog is open. It is painted over the panel on its own scrim, so it costs the 456 px body nothing. Close it with ×, with Close, or by clicking the scrim.

+
+
+

+ + + + +
+ +

03Measured

+
+ + + +
white#6E6E6Eoutlined
Feeder chip — body height455455455
+
+ + + + + + +
K1 dropletK2 page dialogK3 sheet
Dryer dialog470×342360×390300×378
panel body while open455455455
+ +
The dialog costs the panel nothing. It is painted over +the panel on its own scrim, so opening it, starting the dryer and switching layouts all +leave the body at 455. That is the only reason a 390 px dialog is affordable in a +456 px panel at all.
+ +

04The dryer

+

Bambu's AMS Dryness Control is the reference. Taken apart, it answers +four questions — how wet is it, is it drying, how hard, and for how long — and it answers +the first one as a quantity, with a droplet filled to the reading, which is most of +why it works. Rebuilt here in the page's own dialog chrome rather than Bambu's.

+ +
+ + + + + + + +
what it is
K1 Droplet470 px. Bambu's shape: the droplet filled to the + reading with Idle / Drying under it, the two numbers below that, and the + settings in a column besideTake this. The droplet is the only part that shows humidity as a + quantity, and the whole dialog exists because of that number
K2 Page dialog360 px — device.css's own width — + readings on a row, then temperature, duration and automatic stackedReads as part of the panel rather than as a guest from another application. + The right answer if the droplet is judged decorative
K3 Sheet300 px, the humidity pill carried straight through + from the cardThe only one small enough to be anchored under the Dry button instead of + centred over the panel
+ +
+ + + + + + + + + + + +
the dialog carriesbecause
Temperature 45 / 55 / 65 / 70 °CThe macro takes oneACE_DRY TEMP=
Duration 2 / 4 / 6 / 12 hSo does the macroACE_DRY DURATION=
Automatically, above Off / 45 / 55 / 65 %Bambu's dialog has not got this. The ACE can start its own dryer above a + humidity, which is the whole reason to watch the numberACE_SET_AUTO_DRY
StartStopOne button, two states, and the macro + under it changes with themACE_DRY → ACED__DRY_STOP
Not a warning bolted on: it is what a running dryer does, and the card's + own source switch goes inert with the dryer named as the reason
+ +

05The chip on a black ground

+

The shipped #6E6E6E pill sits on white in .slot +and on #CECECE in the cabinet. On the feeder's #1F1F1F lower half +it nearly disappears — and the chip is the only thing on the card that says what the +filament is.

+ +
+ + + + + + + +
White#FFFFFF with dark inkTake this. Maximum contrast, and it makes the chip the brightest thing in + the lower half — which is where the eye should land
Shipped grey#6E6E6E, unchangedConsistent with the cabinet's bays at the price of contrast: on black it reads as + a raised label rather than a bright one
OutlinedBlack, with a white edgeQuietest, and the one that needs the most light to read
+ +
The tiny logo is the frame, small. The module badge in the unit +row is the ACE badge's own 44×26 silhouette in #FFFFFF over +#1F1F1F, with two channel marks on its black half. Badge and frame are one +idea at two sizes, and the two badges sit in the same place in the row and differ only in +palette — which is the same trick the two frames play.
+ +

06Checking it

+
python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-cabinet.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-cabinet.html
+

57 checks: the two devices share one seam rule and differ only in palette, +the module badge is that palette at badge size and sits where the ACE's does, each chip +treatment is readable against its own ground, the Dry button opens the dialog, each layout +names its unit and offers all three settings and the macros behind them, the running state +nothing.

+ +
The fault this pass, and it stopped the page dead. The +locked-axis constant was called DRY — which was already the dryer's own +settings object — so the first script block threw +Cannot declare a const variable twice and never defined S or +OPT at all. Every function in the later blocks still existed, because function +declarations hoist, so the page looked almost alive: the checks reported +rendered false and a ReferenceError four frames deep. The message +only appeared by feeding that one block to JavaScriptCore on its own. Worth remembering: +when a mockup renders nothing, parse its scripts separately before reading them.
+ +
+ +
+ +

03Measured

+
+ + + + + + + + + +
S1S2S3S4S5dialogcustom
body height, against 456455455455455455455455
badge17×1717×1717×1717×1717×17470×364 over the panel
+ +
The frame lost its cutouts and gained nothing. The two label +strips flanking the spool were the module's real front, and they read as +asymmetric against a card where the bays, the merge and the toolhead all sit on one +centre line. The channels are the badge's job now, which is the smaller and better place +for them.
+ +

04The badge

+

Square, because a cabinet is wide and a module is not — a 44×26 badge +for the feeder only ever read as a squashed ACE. Every one is drawn on a 24×24 grid +at 17 px in the frame's own two colours, and every one is mirror-symmetric, +which the checks assert by measuring each shape's centre rather than by looking at it.

+ +
+ + + + + + + + + + + +
S1 HalvesWhite over black, roundedTake this. It is the frame at badge size — the same relationship the + drawing on the card has, so the logo and the thing it labels are one idea. + Symmetric left to right; the halves make it deliberately not symmetric top + to bottom, because the frame is not either
S2 ReelA white ring on blackSymmetric on both axes, and it says spool rather than machine — + which is most of what a stock feeder is. The runner-up
S3 ChannelOne white capsule, centredThe module's channel, singular, because a card is one toolhead and one channel. + Closest to the ACE badge's own language, where the bays are capsules too
S4 PlateA white square in a black oneQuietest, most symmetric, least specific — it could be any module
S5 BandWhite through black, edge to edgeReads as a slot at a glance and as a division at second glance, which is either + efficient or ambiguous
+ +

05Custom values

+

Bambu's dialog offers a preset list and a text field. This offers the list +first — 45 / 55 / 65 / 70 °C and +2 / 4 / 6 / 12 h covers nearly every spool — +and a Custom… beside each, because ACE_DRY takes a number and some +spool will not round to the nearest chip.

+ +
    +
  • A real input, with its range beside it. 35–80 °C and 1–24 h, + clamped on the way in rather than rejected on the way out.
  • +
  • The command line updates as you type. ACE_DRY ACE=A TEMP=68 DURATION=4 + is under the dialog before anything is sent, which is the same habit the rest of this + panel keeps: every control names the macro behind it.
  • +
  • Presets takes you back and keeps what you typed. Switching to the list does not + quietly reset the value to the nearest chip.
  • +
  • Automatic still writes a threshold. Off / 45 / 55 / 65 %, and choosing one + puts ACE_SET_AUTO_DRY THRESHOLD= on the line beside the dry command.
  • +
+ +

06Checking it

+
python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-cabinet.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-cabinet.html
+

57 checks: the frame has no cutouts and still shares the cabinet's seam +rule, the chip is #6E6E6E and readable on black, every badge is square and +every shape in it is centred on the vertical axis, the Dry button opens the dialog, +choosing a preset and typing a custom value both change what would be sent, going back to +the presets keeps it, the running state stops rather than starts — and the dryer is +reachable in the pre-rendered copies, so it can be read with no script at all.

+ +
Two silent misses this pass, both the same shape. A caption +block and a rig control were each replaced by matching a string that had since been +reformatted — the replace returned unchanged text and reported success, and the page kept +rendering with the old markup. The second one only surfaced when bake_mockup.py +said the page never reported — does it still have the rig?, which is exactly the +question worth asking. Every replacement in this file now asserts on its result.
+ +
+ +
+ +

03The field

+

A preset or a typed value — never both, and never two rows. The four +chips and one empty field sit in the same group, because it is one choice.

+ +
+ + + + + + + + +
you dothe presetswhat would be sent
Nothing55 °C litTEMP=55
Type 68nothing lit — the preset is no longer what would be + sent, so it stops looking chosen. The field takes the highlight insteadTEMP=68
Clear the field55 °C lit againTEMP=55
Click 45 °C while 68 is typed45 °C lit, and the + field is emptiedTEMP=45
Type 999 and leave the fieldstill nothing litTEMP=80 — clamped to the range, not refused
+ +
    +
  • Temperature and duration are independent. Typing a duration darkens + its preset and leaves the temperature's alone.
  • +
  • The command line under the dialog updates as you type — + ACE_DRY ACE=A TEMP=68 DURATION=4 is visible before anything is sent, + which is the habit the rest of this panel keeps: every control names its macro.
  • +
  • The highlight repaints without a full render, so the field does not lose focus + mid-keystroke. That is the same reason + core/render.js + exists on the real page.
  • +
+ +
The badge is settled: S1, the frame at badge size. White over +black on a 17 px rounded square — the same relationship the drawing on the card has, +so the logo and the thing it labels are one idea. The ACE's badge stays 44×26, +because a cabinet is wide and a module is not.
+ +

04Checking it

+
python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-cabinet.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-cabinet.html
+

55 checks, and eleven of them are this field alone: that it starts empty, +that it sits in the presets' own row, that typing un-lights the preset and clearing +re-lights it, that a preset click empties the field, that an out-of-range value is clamped +rather than refused, that temperature and duration darken only their own preset, and that +the command line follows every one of those.

+ +
Two faults, and the second one is a repeat. Applying the +view axis inside render() meant every keystroke that committed was undone — +type 999, the field re-rendered, and the override reset it to empty. A view is something +you select, so it is applied when it is selected and never during a render. And +then: value is not a reflected attribute, so setting the property left +nothing in the serialised markup and the pre-rendered custom copy arrived with an empty +field. That is exactly the trap selected set on the source selector two +studies ago, and the fix is the same — setAttribute as well as the property.
+ +
+ +
+ +

03The two menus

+

Both open now, in device.css's own +.menu — 214 px minimum, 10 px radius, an 18 px icon and a +10 px gap. They are two different scopes a few pixels apart, which is a real +ambiguity, so each one says which it is on the way open.

+ +
+ + + + + + +
the panel's This printer
Unload every toolheadACE_UNLOAD_ALL_HEADS
Flush length…ACE_SET_PURGE LENGTH=<mm> | RESET=1
Confirm before load and unloadACE_SET_CONFIRM_COMMANDS ENABLE=0|1
Spoolman…ACE_SET_SPOOLMAN URL= AUTO=0|1
Clear the head→bay bookkeepingACE_CLEAR_HEADS [HEAD=n]
+ +
+ + + + + + + + +
a subpanel's Toolhead N
Load, or Reload when something is already inACE_LOAD_HEAD HEAD=n [ACE= SLOT=]
Unload — greyed when the head is emptyACE_UNLOAD_HEAD HEAD=n
Swap to another bay… — only on an ACE-fed head, because a stock feeder has + no other bay to swap toACE_SWAP_HEAD HEAD=n ACE= SLOT=
View this filament, or Edit it — see belowprint_task_config
+ +
Why the split. Six of the ACE's macros are preferences set once — +flush length, confirmations, Spoolman, the head bookkeeping — and the first study put them +behind the overflow on the grounds that on the face of the panel they would bury the four +things that change daily: the source, load, unload, dry. The panel's menu is the machine; +a subpanel's is one toolhead. Naming the scope in the menu's first line is the cheapest +honest fix for two identical glyphs sitting a few pixels apart.
+ +

04Who wrote this filament

+

A spool that identified itself over RFID carries its own vendor, +type, colour and temperatures, and none of that is ours to overwrite. A value typed +in is. So one gets an eye and the other a pencil — the same distinction +Bambu draws on its own slots, and .slot already ships the pencil.

+ +
+ + + + + + + + +
where it came frommarkfrom
The spool's RFID tagEye — read onlyfilament_detect.info[i].MAIN_TYPE ≠ NONE
Typed inPencil — editprint_task_config · /multiace/api/state source: "override"
Occupied, not namedPencil — name itgate_status 1, material ""
Emptyno markgate_status 0
+ +
Corrected 2026-08-28 — the panel does not draw it this way any +more, in either column. +A BAY reads, whatever named it. The row above is a statement about where a name came +from, and that half still holds — but a pencil is an offer to edit, and naming +a bay from this panel was never built (reading is: syncBays() merges the +override store; writing is tier 2b and wants ACE_SPOOL_ASSIGN). Three bays wore +a pencil over nothing. Every bay carries the eye now, and the word still says which +source named it. +A HEAD asks the machine, not the tag. filament_detect.info[i].MAIN_TYPE +≠ NONE answers “is there a tag on the spool”, which is a different +question from “may this be edited”. The printer publishes the second itself as +print_task_config.filament_edit[i] — literally allowed_edit +in its own print_task_config.py. The panel reads that bit and requires +that no tag is present, because the bit is a latch: every write clears +filament_official, so one edit unlocks a tagged spool until the tag is read +again. Measured on 811002511261022618B3, a head carrying a decodable NTAG reported +filament_official: false, filament_edit: true with its sub-type blank where +the tag says Silk — and the panel reads it rather than offering to type +over a record that reverts on the next load. A tagged spool also gets a mark of its own +(.ace-tag), which says which refusal this is. +The record is round sixteen; the demo below still +draws the old pair.
+ +
Every raw ACE slot on the measured machine reads +rfid: 0, so the tagged bay in these drawings is invented to exercise the +state, and its own tooltip says so. The distinction is real — the U1's +filament_detect carries a full tag record per slot — it is just not on +this machine's spools.
+ +

Five places to put it

+
+ + + + + + + + + + + +
body
V1 On the roll455Take this. Bottom-right of the disc, where a status pip goes — the only + placement that sits on the thing it describes, always visible, no + height
V2 In the chip455Mark and material name in one object. Quietest, and it asks a 12 px glyph to + share a 19 px pill
V3 Under the chip489Where .slot itself puts its pencil, at top: 100px of its + 140. The precedent — and the only one that costs height, 33 px more than + the panel has, because a bay grows 17 and there are two rows of them
V4 On hover455Cleanest at rest, and it makes the distinction discoverable only by pointing at + it — which is what left what are these for unanswered once already
V5 Bay corner455Clear of the roll and the chip, so it covers neither. Reads as a badge on + the bay rather than part of it
+ +

05Checking it

+
python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-cabinet.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-cabinet.html
+

86 checks. New this pass: each menu opens, names its own scope and its +macros, is pulled inside the panel rather than off its edge, closes on an outside click and +costs the body nothing; Swap appears only on an ACE-fed head and Unload greys on an empty +one; and in every one of the five placements the tagged bay shows an eye while the typed +ones show a pencil — with V3 asserted as the one that does not fit, so the number +in the prose cannot drift from the number on screen.

+ +
+ +
+ +

03Pointing at a filament

+

A spool that identified itself over RFID knows a great deal about itself, +and almost none of it has ever been on this page. filament_detect.info[i] +carries the vendor, the series, the nozzle range, the bed temperature, the +drying schedule and the SKU — six fields that state.js has +parsed for months and that appeared nowhere but a dialog. A hover is where they fit.

+ +
+ + + + + + + + + + + +
bodysays
H1 Lift455NothingA ring on the roll and a darker chip. Answers which one am I on and not + what is it — which, for a bay whose material is already on its chip, may + be all that is wanted
H2 Chip grows455Material and vendorThe pill widens over its neighbours. No new object, and it covers the bays either + side while it is open
H3 Card above455The whole recordTake this. Six rows for a tagged spool, two for a typed one, and a footer + saying which it is. It covers the cabinet above it — which is the cabinet you are + already looking at
H4 Card beside455The sameNever covers the roll it describes, and flips to the other side for the + right-hand bays so it stays inside the panel. It does cover a neighbour
H5 Row readout455One lineNo floating layer at all. The unit's own row becomes the readout and goes + back to naming the unit when the pointer leaves. Covers nothing, needs no room, + and says one line where the card says six
+ +
None of them costs a pixel — all five measure 455, because +every one is drawn over the card or written into a row that already exists. So this is +decided on what it should say and what it may cover, and on nothing else.
+ +
The card is not padding. A tagged bay shows Bay, Colour, Nozzle +220–260 °C, Bed 70 °C, Drying 65 °C · 8 h and its SKU; a +typed one shows Bay and Colour, and says it can be edited. That difference is the +provenance mark, spelled out — the eye and the pencil promise it and the card delivers it. +Every row is drawn only if the record carries it, because a tag can report a type and hold +NONE in any temperature field.
+ +
Held rather than hovered. --shots writes blank +PNGs here and a pointer cannot be kept still across a re-render, so the rig has a +Hover: held on A1 switch that puts .is-hover on a bay. It sits beside +:hover in every rule, so the state that is baked and asserted is the state a +pointer produces.
+ +

04Checking it

+
python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-cabinet.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-cabinet.html
+

93 checks. New this pass: each treatment shows a card or does not, as it +claims; the card stays inside the panel and flips for a right-hand bay rather than leaving +it; a tagged bay lists all six rows and a typed one two; the row readout says what the +pointer is on and nothing floats while it does; and no treatment costs the body a pixel.

+ +
+ +
+ +

03The rectangle, and the seam

+

One shape holding the roll and its type chip, so a bay reads as an object +rather than as two things that happen to be stacked. It is drawn as a backing +behind the bay and inset negatively, so it costs no layout and every bay keeps its +exact position — all five measure 455.

+ +
The seam runs through the roll, and the roll is behind this +rectangle. That is the one thing the shape has to answer for: a solid fill hides the +division the whole cabinet is built on, and the two halves survive only in the 4 px +gaps between bays. Each option answers it differently, and that is the choice.
+ +
+ + + + + + + + + + + +
the seam
R1 Solid whitehidden behind itThe plainest reading, and the strongest object. It trades the cabinet's own idea + for a cleaner bay
R2 Outlineruns straight throughA 1.5 px white edge and no fill. Keeps both ideas, at the cost of a fainter + object
R3 Translucentreads through, softenedTake this. 55 % white over the cabinet: the rectangle is plainly an + object and the halves plainly continue underneath — which is what the drawing is + actually doing
R4 Two-tonecontinues inside itThe rectangle carries its own seam, a step lighter than the cabinet's. The only + option where the bay and its cabinet are the same idea at two brightnesses
R5 Liftedhidden, plus a shadowA card sitting in the cabinet rather than a hole cut in it. Strongest + object, furthest from the two halves
+ +

04What a running dryer says

+

dryer_status carries a target, a duration and +what is left of it — so elapsed is derivable and every reading below is a real +field. The question is which of them fits on a button in a 391 px card, and what the +dialog adds.

+ +
+ + + + + + + + + + + +
the card's buttonpxthe dialog
G1 Time left2 h 41 m left124Bar. One number — and it never says how long the job was to begin with
G2 Elapsed / total1 h 19 m / 4 h132Take this. Where you are and how long it was set for, in 8 px + more than G1
G3 Per cent33 %70Shortest, and the one reading you cannot act on: it does not say when to come + back
G4 Temp + time55 °C · 2 h 41 m149Elapsed and total at the bar's ends. The most it can say before the button stops + fitting its row
G5 On the droplet2 h 41 m left124No bar: the droplet gains a progress ring and the target sits inside it, so the + thing that shows humidity shows the drying too
+ +
None of the ten costs a pixel. The rectangles are backings and +the readouts are text in a button that was already there, so both axes are decided on what +they say and what they cover, and on nothing else. Whichever is taken, the button reports +while it runs and offers again when it stops, and every dialog variant still says + +

05Checking it

+
python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-cabinet.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-cabinet.html
+

109 checks. New this pass: the five rectangles are five different drawings +and none of them moves a feeder's spool off an ACE bay's height; R2 has no fill and R4 +carries a gradient and R1 has neither, so the seam claim is measured rather than asserted; +each drying readout is the label the table quotes, none overflows its row, the dialog +repeats it over a bar except G5 which puts it on the droplet.

+ +
A check that chased the last thing clicked. An earlier check +presses the 45 °C preset to prove a preset click clears the custom field — and it +stuck, so the drying labels read 45 °C where the table says 55 and two checks +failed on correct behaviour. The fix is not to loosen the expectation but to put the +dryer back to its defaults first: a check that depends on what ran before it is a check +that will fail for the wrong reason.
+ +
+ +
+ +

03Nothing at rest

+

A bay is a roll and a type chip on the cabinet, and nothing else. Anything +drawn round them permanently either hides the seam that runs through the roll or +fights it — which is what the five rectangles were all trying to solve. The shape appears +only under the pointer, where it has a job, and where covering the seam for a moment +costs nothing.

+ +
+ + + + + + + +
at restunder the pointerbody
B1 Accentnothing#0C63E2, 1.5 px, no fill455
B2 Accent + washnothingThe same edge over rgba(12,99,226,.08)455
B3 Soft bluenothing#7FB6E8, 1.5 px, no fill455
+ +
+ + + + +
B1Take this. It is the blue the rail already uses for the + selected destination and the source selector for focus, so nothing new is introduced — + and it carries no fill, so the seam it sits on is still there underneath
B2Warmer, and the bay lifts as well as outlines. The wash does put + a tint over the seam it is covering, which is the thing the rectangles were rejected + for — a great deal less of it, for a moment
B3Quieter against a grey cabinet, and further from + selected, which the accent does mean elsewhere on this page. Take it if the + accent reads as a choice rather than as a pointer
+ +
The edge is a backing, drawn behind the bay and inset +negatively, so it costs no layout: the bays sit in exactly the same places whether it +is there or not, and a feeder's spool stays at an ACE bay's height throughout. All three +measure 455. It comes with the hover card, so pointing at a bay both marks it and says +what is in it.
+ +

04The dryer reports elapsed of total

+

Settled: the card's button reads 1 h 19 m / 4 h while it runs +— where you are and how long it was set for, in 8 px more than 2 h 41 m +left would have taken. The dialog says the same under its readings, and the button +offers again the moment it stops.

+ +

05Checking it

+
python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-cabinet.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-cabinet.html
+

97 checks. New this pass: a bay wears no box-shadow and no background at +rest — asserted, not assumed; each edge appears only under the pointer, is blue by its +own channel values, and moves nothing; B1 carries no fill where B2 does; and the dryer's +button reads the elapsed-of-total label the prose quotes.

+ +
The fault this pass, and it is the fourth of its kind. +Replacing the rig's two control groups also removed the two lines of the click handler +that read them, so the Pointer switch existed, looked normal, and set nothing. The +checks caught it one line after asserting the at-rest state — because the next thing they +did was hold the pointer and there was nothing to hold. Every one of these has been a +string replace whose surroundings had moved; the answer each time was to assert on the +result rather than to trust the report.
+ +
+ +
+ +

03Settled

+

Nothing on this card is an option any more. What the rig still holds is +machine state — how many units, whether the dryer is running, what the sensors say — +and one aid, because a pointer cannot be held still across a re-render.

+ +
+ + + + + + + + + + + +
The frameFour toolhead cards in a 2×2, each with its own + header: name and source above, unit below. Everything on the card's centre line
The bay.slot's own — a 36 px disc over its + 58×19 #6E6E6E name pill, 6 px apart. Nothing drawn round it at + rest
The cabinetTwo halves in #EEEEEE over + #CECECE, the seam through the roll, hugging its four spools with + 16 px of shoulder
The feederThe same drawing in #FFFFFF over + #1F1F1F — Snapmaker's own module — with its badge the frame at badge + size, square
The headBelow at 0.50, sensor dot centred on the artwork's body, + four states from four real fields
The tubeManifold below the cabinet, drawn behind it, vertical into + the inlet
The markEye or pencil on the roll, by where the filament's + identity came from
The pointerA 1.5 px #0C63E2 edge round the roll + and its chip, no fill, and the record card above
The dryerA chip that offers Dry and reports + 1 h 19 m / 4 h. Clicking it opens the dialog, and that is the only + thing that opens it
The menusTwo , two scopes, each naming its own
+ +
The dryer popup is not a state this study sets any more. It +was, while the layout was being chosen — a rig switch that forced it open so the choice +could be seen. Now that it is settled, the only way to it is the way the panel offers: +click the Dry chip. It opens on the chip, closes on the cross, on Close, and on the +scrim, and it is painted over the panel on its own scrim so it costs the 456 px body +nothing. That change is worth making the moment a choice is settled: a rig control that +outlives its decision is a way to reach a state that no user has.
+ +
+ + + + + +
the card has five states, and they are all pre-renderedbody
At rest455
Pointer on a bay — the accent edge and the record card455
Dryer running — the chip reads elapsed of total455
Dryer dialog, idle455
+ +

04Checking it

+
python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-cabinet.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-cabinet.html
+

94 checks, and the ones added this pass are about behaviour rather than +choice: that the rig holds four machine-state controls and no design ones; that a bay wears +no shadow and no background at rest; that the accent edge appears under the pointer, carries +no fill and moves nothing; and that the dialog is closed until the chip is clicked, +opens on it, closes on the cross and on the scrim, still opens while the dryer runs, offers +Stop rather than Start, and returns the chip to Dry when Stop is pressed. The baked +copies are driven the same way — the dialog states are baked by clicking the chip, not by +setting a flag.

+ +
+ +
+ +

05Two things taken back out

+ +

The running dialog is the same dialog

+

It had grown an amber droplet, an amber state word, an +amber Stop and a progress bar — four departures at once, so it read as a +different dialog rather than as the same one in another state.

+ +
+ + + + + +
idlerunning
Width496496
Droplet fill#7FB6E8#7FB6E8
State lineIdleDrying · 1 h 19 m / 4 h, same style
ButtonStart, btn primaryStop, btn primary
Extra blocksnoneone plain line: Drying to 55 °C. +
+ +
The droplet is the humidity, whatever the dryer is doing. That +is the one that mattered most: colouring it amber while drying made the reading you came +for change meaning at exactly the moment you came for it. The progress moved into the +state line, which was already there — so running adds one line and nothing else.
+ +

No metadata popup under the pointer

+

Pointing at a bay now gives the accent edge and nothing more. The card that +listed the tag's whole record covered the cabinet above it, appeared on a movement rather +than a decision, and was a second way to read something the bay's own sheet will hold +anyway.

+ +
The record is not lost. A tagged bay's title carries all of it — +A1: PETG Basic · Kingroon · nozzle 220–260 °C · bed 70 °C · dry 65 °C for 8 h · +KR-PETG-83AFFF — from the spool tag, read only — and that is where it stays until +the bay's own sheet is built, which is where it belongs. The eye and the pencil still say +which kind it is without opening anything.
+ +

06Checking it

+

99 checks. The two added this pass are the ones that would have caught +these: running is asserted to be the same dialog — same width, same droplet fill, +same state class, same button class, same field count, and no bar, ring or second colour +anywhere — and no metadata layer exists under the pointer, while the bay's title is +asserted to still carry the nozzle range, the SKU and the provenance.

+ +
Both of these were mine, and both were additions. The amber +and the card each arrived as an option in a comparison, where standing out is the whole +point of a variant — and neither was re-examined once the comparison was over. Worth +carrying: an option that wins a comparison has not thereby earned a place in the +design, and the moment to ask is when the axis closes.
+ +
+ +
+ +

07Two corrections

+ +

The state line, not the droplet

+

The droplet was already byte-identical between idle and running — same +path, same #7FB6E8 fill, same level. What moved was the line under it: +Drying · 1 h 19 m / 4 h is 158 px of text in a 158 px +column, so it wrapped to two lines and grew the block by 18 px. That, and nothing +else, is what made the running dialog look like a different one.

+ +
+ + + + + + +
idlerunning
State lineIdle — 18 pxDrying — 18 px
Under it1 h 19 m of 4 h, at 55 °C.
Dropletidenticalidentical
+ +
The check now asserts the state line is one word. A text +length is not something a screenshot argues about and not something prose can promise — +it either wraps at that column width or it does not, and the only way to know is to +measure it.
+ +

A running dryer does not refuse loads

+

That claim has been in this record since the first study's drying state, +where it was written as prose in a caption. It was then carried into a second study, a +third, a dialog, a tooltip and a dozen checks — and it is not true. +/printer/gcode/help says nothing of the kind and it was never seen on the +machine.

+ +
Worth knowing how it spread. It was never measured; it was +asserted once, in a sentence that read like a consequence, and every document after that +quoted the caption as though the caption were the source. Nothing in the tooling could +have caught it — the checks faithfully asserted that the panel said it. It is gone +from all five studies now, and the check that used to require the sentence requires its +absence instead.
+ +
+ +
+ +

08The rectangle in the droplet

+

The running droplet drew its fill as a plain rectangle, straight through +the outline. The droplet is one drawing — the same path, filled to the reading and +clipped to its own outline — so both states use the same code and only one of them was +wrong. That is the shape of an id collision, and it was.

+ +
+ + + + + + + + + +
what
The fill is a <rect> with + clip-path: url(#dropclip)Correct on its own, and the only thing keeping it inside the droplet
This file bakes copies of itselfTwo of the five states hold a dialog, so the document ended up with two + clipPath id="dropclip"
A clip id resolves document-wideNot within its own <svg>. Both referenced the first
The first sits in a display:none blockA clipPath in a hidden subtree clips nothing — so the second droplet's fill was + never clipped, and a rect is what a rect looks like
+ +
Two fixes, and the second is the one that lasts. The droplet +now numbers its clip per instance, and bake_mockup.py gives every baked copy +its own namespace on the way in, so a live id and a baked one can never meet. And +check_mockup.py asserts what no drawing check would have caught: no two +elements in the file share an id, every clip-path resolves to something +that exists, and it resolves to something inside its own copy.
+ +
Worth generalising. Baking copies of a live document is what +makes these studies readable without script, and it silently duplicates every id in them. +Anything resolved by id — clipPath, mask, gradients, +filter, <use href>, aria-labelledby — is a +collision waiting for a second copy. The check above is cheap and belongs in any mockup +that bakes itself.
+ +
diff --git a/docs/u1-webui/02-device-page/multiace-f2-iterations.html b/docs/u1-webui/02-device-page/multiace-f2-iterations.html new file mode 100644 index 00000000000..9251f03619f --- /dev/null +++ b/docs/u1-webui/02-device-page/multiace-f2-iterations.html @@ -0,0 +1,1177 @@ + + +A Subpanel Per Toolhead + + + + + + + +
+ +

A subpanel per toolhead

+

F2 was rejected on height in the first study. The idea underneath it — +every toolhead gets its own panel, and its own header chooses what feeds it — is +worth more than the one arrangement it was tried in. Four arrangements here, three of +which fit, and all of them running: pick a source and the subpanel changes, press +Sync and the machine fills it in.

+

Live · four arrangements · source, mode, unit count and +Sync are all real controls · 16 pre-rendered copies so it reads with no +script at all · panel 830×456, the measured budget · sibling: +the seven-shape study

+ + + +

01What the subpanel is

+

One card per toolhead. Its header says which toolhead it is and +chooses its source; its body is whatever that source turns out to be. Nothing +else moves.

+ +
+ + + + + + + + +
in the headerwhat it iswhat it writes
Toolhead NThe name Printer Settings already uses. Not + Nozzle N, not T1
The source selectorDefault feeder · ACE 1…4 · Manual. One list, one + choice, and the whole point of the arrangement: the question what feeds this + toolhead is answered in the place that toolhead already ownsACE_SET_HEAD_FEEDER · ACE_SET_HEAD_ACE · + ACE_SET_HEAD_MANUAL
A shared tagPresent only when another toolhead picked the same + unit. Without it, four copies of one cabinet read as four cabinets
… overflowLoad, unload, swap, and the bay editorACE_LOAD_HEAD · ACE_UNLOAD_HEAD · ACE_SWAP_HEAD
+ +
The selector replaces the three-icon switch. The first study gave +each head a feeder / ACE / manual triple, which is +102 px wide and cannot name which ACE. With four units the real question has +six answers, so it is a list, not a row of icons — and it drops to 24 px, which +is what lets a subpanel have a header at all. It is drawn as +.pref-pill, the header control shell.js already has.
+ +

02Four arrangements

+

The subpanel is the same in all four. What differs is how much of the +64×140 toolhead each one draws, and that is the entire height argument: the +artwork is 140 px and there are four of them.

+ + + + + + + + + +
Arrangement + + + + +ACE units + + + + +
+
Reading this without script. Every arrangement below is pre-rendered by the real renderer and switched with the picker above, so the shapes and the measured heights are all here. What needs script is the part that changes state: picking a source in a subpanel header, cycling the ACE mode, pressing Sync, and the command log. Open the file in a browser for those.
+
+
Arrangement + + + + + +
+
ACE units connected + + + + + +
+
ACE mode + + + + +
+
Known filament + + + +
+ + Every control here is a real one. Changing a source in a + subpanel writes to the same state and logs the macro it would send. +
+ +
+
+
body
+
+
+
Filament|no ACE unit
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
Nothing detected
Toolhead 4
Nothing detected
body417 / 456 · 39 spare
+
Filament|no ACE unit
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
Nothing detected
Toolhead 4
Nothing detected
body427 / 456 · 29 spare
+
Filament|no ACE unit
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
Nothing detected
Toolhead 4
Nothing detected
body421 / 456 · 35 spare
+
Filament|no ACE unit
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
Nothing detected
Toolhead 4
Nothing detected
body561 / 456 · 105 over
+
Filament|1 ACE unit
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
Nothing detected
Toolhead 4
38 %|31 °C
body417 / 456 · 39 spare
+
Filament|1 ACE unit
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
Nothing detected
Toolhead 438 %
body427 / 456 · 29 spare
+
Filament|1 ACE unit
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
Nothing detected
Toolhead 438 %
body421 / 456 · 35 spare
+
Filament|1 ACE unit
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
Nothing detected
Toolhead 438 %
body561 / 456 · 105 over
+
Filament|2 ACE units · 8 bays
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 3
52 %|29 °C
Toolhead 4
38 %|31 °C
body417 / 456 · 39 spare
+
Filament|2 ACE units · 8 bays
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 352 %
Toolhead 438 %
body427 / 456 · 29 spare
+
Filament|2 ACE units · 8 bays
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 352 %
Toolhead 438 %
body421 / 456 · 35 spare
+
Filament|2 ACE units · 8 bays
Toolhead 1
PLA Jayo
Toolhead 2
PLA Forshape
Toolhead 352 %
Toolhead 438 %
body561 / 456 · 105 over
+
Filament|4 ACE units · 16 bays
Toolhead 1
38 %|31 °C
Toolhead 2
52 %|29 °C
Toolhead 3
44 %|30 °C
Toolhead 4
61 %|28 °C
body417 / 456 · 39 spare
+
Filament|4 ACE units · 16 bays
Toolhead 138 %
Toolhead 252 %
Toolhead 344 %
Toolhead 461 %
body427 / 456 · 29 spare
+
Filament|4 ACE units · 16 bays
Toolhead 138 %
Toolhead 252 %
Toolhead 344 %
Toolhead 461 %
body421 / 456 · 35 spare
+
Filament|4 ACE units · 16 bays
Toolhead 138 %
Toolhead 252 %
Toolhead 344 %
Toolhead 461 %
body561 / 456 · 105 over
+
+
+

+ + + + + + +

Measured, at every unit count

+

Drive the rig above and these are the numbers it reports. Three of the four +fit; the fourth is the arrangement the first study drew, kept so the difference is visible +rather than asserted.

+ +
+ + + + + + + + + + + + +
arrangementbodythe toolheadthe wire
I1 Bands4170.5 — 32×70Sideways, then down into the inlet39 spare
I2 Side by side4271.0 — 64×140Sideways, then down into the inlet29 spare
I3 Half-scale head4210.5 — 32×70Straight down, Bambu's own order35 spare
I4 Full-scale head5611.0, twice per columnStraight down105 over
+ +
None of those numbers moves. 417 / 427 / 421 / 561 are the +measurements at zero, one, two and four ACE units, and at every combination of +sources 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 toolhead. On a body that is overflow: hidden that is not a nicety; +it is the difference between a layout and a layout that clips on Tuesday.
+ +
And it never folds. Four units is 16 bays, and I1 draws +all sixteen in the same 417 px, because each subpanel shows only its own +source. This is the thing the first study could not do: F4 — sources above, one bus, +four heads below — had to collapse to a rack at three units, because it draws every +unit whether or not the toolhead you are looking at can reach it. Head-major pays for +sharing; unit-major pays for counting. Four units is the common shape of a four-head +machine, and sharing is the exception, so head-major is paying in the cheaper +currency.
+ +

03The duplication objection, revisited

+

F1 and F2 were rejected in the first study partly because a shared unit is +drawn once per toolhead that uses it, "with as many humidity readings as copies, all of +which have to agree." Building it running is what shows that argument to be half right.

+ +
    +
  • The half that was wrong. Four copies of ACE A are four views of one state + object. They cannot disagree, any more than two labels bound to the same variable can. + That was an objection to drawing, made about a still picture, and it does not + survive contact with a renderer.
  • +
  • The half that stands. Someone looking at four cabinets can reasonably think + there are four. That is a real risk and it is answered where it happens: the card + takes a dashed edge, and the unit's own humidity pill takes a + with the whole sentence in its title. Try it — set two toolheads + to ACE A.
  • +
  • The cost that is left is space, and it is bounded. Four copies of four bays is + four subpanels of the same height as one. It costs nothing vertically. What it costs + is the chance to see the machine as one object, which is what F4 is for.
  • +
+ +

04What Sync actually does

+

Press Sync info in the panel header, then set Known filament +to Not synced and press it again. The difference between those two states is not a +demonstration; it is the boundary the first study measured.

+ +
+ + + + + + + + + + + + + +
before SyncSync readsfrom
Which unit feeds which toolheadWhatever the page was last toldEvery source selector is sethead_manual → head_feeder → head_ace, in that order
Whether a bay is occupiedalready knowngate_status [1,1,1,1]
What filament is in an unloaded baynot knowable — drawn as a solid neutral disc + with ?, never as the empty checkerboardMaterial, colour and brand fill in/multiace/api/state — CORS-refused today, so this is the + tier 2 half
Humidity, temperature, dryeralready knownthe ace Klipper object
+ +
Unsynced is the honest state, not a mocked-up one. The page +can see topology and occupancy over its own MQTT session in 277 ms and it cannot see +slot identity at all — every raw slot reads +{material:"", brand:"", rfid:0}. So a bay that is full but unnamed has to be +drawn, and it must not borrow the shipped page's checkerboard, which is that page's +word for empty. It gets a solid neutral disc and a ? instead. Sync is +the tier 1 / tier 2 boundary turned into a button.
+ +

05What this changes

+ +
+ + + + + + + + + +
the first study saidafter building it
F2 is rejected on height: 686 against 456Stands for that arrangement, and only that one. I4 is it, and it measures + 561 even after the subpanel's own economies. Move the toolhead beside the bays + (I2) or halve it (I3) or turn the header ninety degrees (I1) and it fits with + 29–39 px to spare
A shared unit is drawn once per head, with readings that have to agreeHalf wrong. One state, many views, no possible disagreement. What is left + is the reading risk, answered by a dashed edge and a ⇄ in the pill
The source is a three-icon switch, 102 px wideReplaced. With four units the question has six answers, so it is a list. + 24 px, names which ACE, and it is shell.js's existing + pill control. This is the change that makes a subpanel header + possible at all
F4 folds to a rack at three unitsStill true, and now it is a real difference. I1 draws four units and 16 bays + in the same 417 px it draws none, because a subpanel shows only its own + source
+ +
Build I1. It fits with the most room, it is the only shape whose +height is provably independent of both the unit count and the wiring, it never folds, and +each subpanel gets 498 px of body — enough that a toolhead, its source, four +bays and their names sit on one line with nothing wrapped. I2 is the alternative worth +keeping: it is the only arrangement that draws the extruder at full scale and still +fits, and if the artwork is judged to be carrying weight at 1.0 that is the one to take. +

Keep F4 as a second view, not as 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 drawing already exists in +the sibling study.
+ +

06Reading it without script, and checking it

+

This file was first shipped rendering everything from its state +object, and it arrived empty: the viewer stripped its script tags, and +<noscript> never fired — because a removed tag is not the +same as scripting being disabled, and only the second of those makes a +noscript block render. It is the same lesson the two static siblings were +written under, re-learnt the expensive way.

+ +
    +
  • Sixteen pre-rendered copies. Every arrangement at every unit count is written + into this file as static markup, switched with radio inputs and the sibling + combinator — the mechanism the siblings use. Script sets .js-on, + which swaps them for the live panel.
  • +
  • They are output, so they go stale. + bake_mockup.py re-renders them; check_mockup.py fails if any + copy no longer matches what the renderer produces, so a forgotten bake is caught + rather than shipped.
  • +
  • No script-looking string outside a real script tag. A regex sanitiser strips + from the first one it sees to the next closing tag. One such string, in a CSS comment, + swallowed all sixteen copies the first time they were baked. Both tools check it.
  • +
+ +
# after ANY change to this file's JavaScript or to the panel CSS
+python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-f2-iterations.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-f2-iterations.html
+ +

The checks drive the rig rather than read the markup: every arrangement at +every unit count, every source combination, the wire endpoints against the bay and the +inlet they claim, that no subpanel header overflows its own width at any of them, and then +the whole no-script path with .js-on removed. Four found real faults while +this was being built — a band header that grew a third row once four toolheads +shared a unit and took it 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 <option selected> that survived in the DOM and +vanished from the serialised copy, because selected is not a reflected +attribute.

+ +
diff --git a/docs/u1-webui/02-device-page/multiace-filament-mockup.html b/docs/u1-webui/02-device-page/multiace-filament-mockup.html new file mode 100644 index 00000000000..8fd096f91ae --- /dev/null +++ b/docs/u1-webui/02-device-page/multiace-filament-mockup.html @@ -0,0 +1,790 @@ + + +Four Toolheads, Four ACEs + + + + + + + + +
+ +

Four toolheads, four ACEs

+

Seven ways to draw the Filament panel once more than one ACE unit can feed +a toolhead in place of its stock feeder — six new ones and the row that ships today, +kept as the baseline. Bambu Studio's AMS display is the reference for the shape; the +Snapmaker Device page supplies every part it is drawn out of.

+

Panel width 830 (--col-w) · toolhead +64×140 (extruderBackground.svg) · live payload from +811002511261022618B3, 2026‑08‑25 · seven shapes, five states, one recommended

+ +
+ + + + + + + + + + + + + +
decisiontakenwhy, in one line
ShapeF4 · the busSources above, one routing bus, the four heads in one row beneath it
OrientationACE above the headThe shipped artwork's inlet is its top edge and its nozzle points down
Head rowone row of fourA toolhead is 140 px tall; a second row of them costs the whole budget
A baythe same disc and pill as a headA bay and a head are two ends of one tube, so they are one drawing
Bay addressA1…D4Bambu's, adopted whole: two characters a wire, a card and a tooltip can share
Scalingone unit open, the rest one lineFour open unit boxes is 556 px against a ~190 px allowance
+ +

01Two fixed sizes decide most of this

+

The panel does not get to choose how wide it is or how big a toolhead is. +Both numbers already exist, both were measured rather than picked, and between them they +rule out three of the six shapes before any of them is judged on how it reads.

+ +
+ + + + + + + + + + + + + +
fixedvalueset byconsequence
Panel width830--col-w in device.css; the side column is + flex: 0 0 var(--col-w)788 px of body content, at every window size at or above 1600
Panel height~572What the side column has left after Control's clusterA body that overflows is clipped in silence.panel-body + is overflow: hidden
A toolhead64×140icons/extruderBackground.svg, drawn by .slotFour of them in a row is 140 px. Four in a column is 560
A bay64×61The same 36 px disc at (15,29) and 58×19 name pill at (3,71), + without the extruder bodyFour bays plus gaps is ~300 px, so two units fit across and three do not
+ +
The artwork says which way up the picture goes. Two stubs run +from y=15 to y=0 at x=26 and x=38 — +that is where filament enters — and the nozzle is a triangle at +y=133..138 pointing down. So 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. This is the one thing the earlier +study's P1 got backwards, and it was invisible until the real artwork was used +instead of a placeholder rectangle.
+ +
A bay is drawn as a head. The shipped 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 58×19 #6E6E6E pill holding +the material name. An ACE bay is the same statement about the same substance, so it is the +same drawing minus the extruder body — not Orca's AMSLib tube, which is +the standard for the C++ surfaces in +16‑ace‑visuals.md and would be a +fifth language on a page that already has one.
+ +

02What the machine actually reports

+

Every drawing below is filled from one payload, read off the printer on +2026‑08‑25. Where a second or fourth unit appears it is labelled as invented, +because only one exists.

+ +
+ + + + + + + + + + + + + + + + + + + +
fieldvaluewhat the panel does with it
modeheadThe per-head source switch is live. In normal and multi + it is disabled with the reason showing, never hidden
device_count1One unit box
head_feeder{0,1,2 true, 3 false}Toolheads 1–3 on their stock feeders
head_ace{0:0, 1:1, 2:2, 3:0}Not the answer to "what feeds this head". 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 the remainder
head_source[3]{ace_index:0, slot:2, PETG, Generic, 632C2C}Toolhead 4 is loaded from A3. This is what makes one wire coloured
gate_status[1,1,1,1]All four bays occupied
slot identityabsentEvery raw slot reads {material:"", brand:"", rfid:0}. A bay that is + occupied but unidentified is drawn as occupied but unidentified
humidity / temp38 % / 31 °COne .pref-pill, not three chips
spool_binding{}Nothing is bound, so no bay has a level. The disc is a colour, not a gauge + — which is also why this panel does not borrow AMSLib's + fill-from-the-bottom tube
+ +

The three sources, and the macro behind each

+
+ + + + + + + + +
sourcewhat it meanswhat the switch sendsread back from
FeederThe head's own stock side feeder. The ACE never touches itACE_SET_HEAD_FEEDER HEAD=n ENABLE=1head_feeder[n]
ACEWired to exactly one unit, and can load only that unit's baysACE_SET_HEAD_FEEDER HEAD=n ENABLE=0
ACE_SET_HEAD_ACE HEAD=n ACE=a
head_ace[n], with head_feeder false
ManualHand-fed / TPU bypass. No ACE feed, retract, assist or RFIDACE_SET_HEAD_MANUAL HEAD=n ENABLE=1head_manual[n]
+ +
One unit may feed several heads. +ACE_SET_HEAD_ACE binds a head to one ACE and says nothing about the reverse, +so ticking ACE 1 on a second head is legal. That single fact is what separates the +six designs below into two families: the ones that draw a unit once, and the ones +that draw it once per head it feeds. With one unit and four heads — the +cheapest ACE setup anyone will own — the second family draws the same cabinet four +times, with four humidity readings that all have to agree.
+ + + + +

03Seven shapes, drawn at the machine's own state

+

One ACE 2 Pro in head mode feeding Toolhead 4 from bay A3, +three heads on their stock feeders, Toolhead 3 empty. Every version is 830 px +wide and built from the same five parts, so what differs between them is shape and +nothing else. At one unit they all work. Section 04 is where the choice is made.

+ + + + + + + + +
+ + + + + + + +
+
+
Filament|

One slot per toolhead. Type and colour come from print_task_config; nothing on the panel knows an ACE exists.

body272 / 456 · 184 spare

What ships today. Four slots, the measured 174 px pitch, and nothing on the panel that knows an ACE exists. Every version below is this row plus something above or below it — which is why the row itself is never the thing being argued about.

+
Filament|1 ACE unit
Toolhead 1
Jayo
Toolhead 2
Forshape
Toolhead 3
empty
Toolhead 4 · ACE A
38 %31 °C
A3
body403 / 456 · 53 spare

Bambu's group, once per toolhead, with the lane flexing to its source: 1 for a stock feeder and 4 for an ACE. It reads beautifully at this state — and it has two faults, both visible above. The source switch is 102 px wide and a feeder lane is 92 px inside, so it is drawn at 88 % to fit. And the moment a second head is wired to ACE A, the same cabinet is drawn twice, with two humidity readings that have to agree.

+
Filament|1 ACE unit, drawn twice
Toolhead 1
Jayo
Toolhead 2
Forshape
Toolhead 3
ACE A — already drawn at Toolhead 4.
Loaded from A1. Open that group to change its bays.
A1
Toolhead 4
38 %31 °C
A3
body686 / 456 · 230 over

Bambu's two-group frame taken literally and wrapped 2×2. Each group is 388 px, which is almost exactly the width Bambu draws one AMS group at, so the bays are right. The height is what fails. Two rows of a 140 px toolhead plus its group is over 700 px against a panel that has about 456 to give. The bottom-left group also shows the only honest answer to sharing in this family: draw the unit once and reference it everywhere else — which is an admission that the layout cannot say the thing it is for. This verdict was later narrowed: it holds for this arrangement and not for the idea. See multiace-f2-iterations.html, where three rearrangements of the same subpanel fit with 29–39 px to spare and none of them folds at four units.

+
Filament|1 ACE unit
feeder
feeder
feeder
A3
ACE AACE 2 Pro · ready38 %31 °C
body391 / 456 · 65 spare

The shape the earlier study chose (its P1), redrawn in the shipped artwork — which is what exposes the objection. The wire leaves bay A3 and climbs into Toolhead 4 from below, entering at the nozzle. extruderBackground.svg draws its inlet as two stubs at the top edge and its nozzle pointing down; the picture and the machine disagree. Everything else about it is sound, and F4 is this drawing turned over.

+
Filament|1 ACE unit
ACE AACE 2 Pro · ready38 %31 °C
feeder
feeder
feeder
A3
body395 / 456 · 61 spare

The recommendation. Sources above, Bambu's own merge bar under the bays with its grey nub, and the four toolheads in one row beneath — each wire landing on the inlet the artwork already draws. Three bays drop to the bar in grey because they are available; A3 is drawn in its own colour because that is where the PETG in Toolhead 4 came from. 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.

+
Filament|1 ACE unit · no toolhead artwork
1Toolhead 1 · PLAfeeder
2Toolhead 2 · PLAfeeder
3Toolhead 3 · —feeder
4Toolhead 4 · PETGA3
ACE AACE 2 Pro · ready38 %31 °C
body282 / 456 · 174 spare

Heads down a vertical trunk, sources beside it. It scales without bound — a fifth unit is another box in the right-hand column — and no wire ever crosses another. It costs the toolhead. Four 140 px extruders stacked is 560 px before anything else, so the artwork is gone and a head is a 30 px disc in a row. That is a different page's design language, adopted to solve a layout problem.

+
Filament|1 ACE unit
1Toolhead 1PLAJayo← feeder
2Toolhead 2PLAForshape← feeder
3Toolhead 3nothing detected← feeder
4Toolhead 4PETGGeneric← A3

Sources

ACE AACE 2 Pro38 %31 °Cfeeds T4
body312 / 456 · 144 spare

No diagram at all: four toolhead rows, then the units as a rack beneath. It is the smallest, it scales to eight units, every control is reachable, and it survives the narrow single-column layout below 1600 px where none of the others do. The trade is the whole point of the panel: the connection is written rather than drawn. Worth keeping as the narrow-window form of F4, not as its replacement.

+
+ +

04Where the choice is actually made

+

At one unit every shape works. What separates them is 456 px — +the measured height of .filament-body at 1920×1080, taken off +the running page with run_webkit.py --size 1920x1080, not estimated. +A body past that is clipped in silence, because .panel-body is +overflow: hidden.

+ +
+ + + + + + + + + + + + +
partheightwhere the budget goes
The four toolheads, one row195140 of artwork, then the source switch and the bay it is loaded from
One open unit box130A header of 26, four 61 px bays, 24 of padding
One collapsed unit row42Badge, name, four swatches, humidity, which heads it feeds
The bus36Bambu's merge bar and the legs off it
Body padding3416 top, 18 bottom
Available456Measured, at 1920×1080. A taller window gives more — Filament is the + grows panel in its column
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
versionbodyfits 456a shared unitthe 140 px toolheadfour unitsverdict
F0 today272yeskeptSays nothing about an ACE
F1 four lanes403yesdrawn once per headkeptbays fall to 38 pxRejected. Duplication, and a 102 px switch in a 92 px lane
F2 two by two686230 overreferencedkept, twice per columnnoRejected on height. Two rows of toolhead is the whole budget
F3 heads over units391yesoncekeptnoRejected. Filament climbs into the nozzle
F4 the bus39561 spareoncekeptfolds to F6Chosen
F5 the spine282yesoncegiven upyesRejected. Solves a layout problem by leaving the design language
F6 the ledger312yesoncea 30 px discyesKept as F4's dense form, not as a rival
+ +
F4 and F6 are one design at two densities. That is the +finding, and it is what stops the panel having to choose between drawing the routing and +surviving four units. One or two units get the diagram; three or more get the rack, with +any single unit expandable back into the picture. The rule is the same one the camera +already follows — one thing in focus, the rest summarised — and the same one +Storage followed when it became a destination of its own.
+ +

F4 at every unit count

+

Same design, five machine states. Four of them change only how many units +there are and which is open; the fifth is the dryer running, which changes what the +controls will accept.

+ + + + + + +
+ + + + + +
+
Filament|1 ACE unit
ACE AACE 2 Pro · ready38 %31 °C
feeder
feeder
feeder
A3
body395 / 456 · 61 spare

One unit — what the machine reports. 395 px of the 456 the panel has. Three bays drop to Bambu's merge bar in grey because they are available; A3 is in its own colour because that is what is in Toolhead 4. Toolheads 1–3 have no wire, which is true — their spools are at the head and the ACE never touches them.

+
Filament|No ACE unit
feeder
feeder
feeder
feeder
body229 / 456 · 227 spare

No ACE. The unit zone and the bus are absent entirely, so the panel is today's row plus a source switch per head — and the switch is drawn disabled with its reason rather than hidden, because a control that vanishes teaches nobody why. This is also the degradation path: if the ACE object stops answering, the panel loses the diagram and keeps the toolheads.

+
Filament|2 ACE units
ACE BACE Pro52 %29 °Cfeeds T3
ACE AACE 2 Pro · feeds T1, T438 %31 °C
ACE A · empty
hand-fed
B2
A3
body445 / 456 · 11 spare

Two units, and one of them shared. ACE A is wired to Toolhead 1 and Toolhead 4; only Toolhead 4 has filament in it, so only that leg is coloured and the other is grey — wiring with nothing through it. Toolhead 2 is on the hand-fed bypass and has no wire at all. ACE B is collapsed to one line and therefore draws no wire; Toolhead 3 still says B2, so nothing is lost but the picture.

+
Filament|4 ACE units · 16 bays
ACE AACE 2 Pro38 %31 °Cfeeds T1
ACE BACE Pro52 %29 °Cfeeds T2
ACE CACE 2 Pro44 %30 °Cfeeds T3
ACE DACE 2 Pro61 %28 °Cfeeds T4
A3
B1
C2
D1
body435 / 456 · 21 spare

Four units, and the diagram does not fit. A unit box measures 130 px, so four of them plus their margins is 550 — and with the bus (36) and the toolheads (195) the body would be 815 against 456. So the panel folds to the rack, which is F6, kept for exactly this. Every unit is one line of 42 px, every toolhead names its bay, and opening a unit swaps its line back for the box. Same move Storage already made: a component that outgrows its panel gets promoted rather than crushed.

+
Filament|drying
ACE AACE 2 Pro · drying55 °C · 2 h 41 m left31 %54 °C
feeder
feeder
feeder
A3
body395 / 456 · 61 spare

The dryer running. dryer_status carries target, duration and remaining, so the pill counts down; humidity is falling and the 54 °C is the dryer's, not the room's. The source switches are shown inert here with the reason on them — the same rule the ACE-mode row follows, and for the same reason. The routing is untouched: what is in Toolhead 4 is still what is in Toolhead 4.

+
+

05Every control, and where F4 puts it

+

Read out of printer.gcode.help on the machine. Each of these +is a plain G-code macro with documented arguments, and the page already owns +sw_SendGCodes — so what used to be listed as the blocker is not one.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
controlwhere it lives in F4what it sends
Source: feeder / ACE / manualThe switch under each toolheadACE_SET_HEAD_FEEDER · ACE_SET_HEAD_ACE · ACE_SET_HEAD_MANUAL
ACE modeThe header pill — pill, the existing header kindSET_ACE_MODE MODE=normal|multi|head [HEAD=n]
Load a headThe toolhead's own sheet, opened by clicking itACE_LOAD_HEAD HEAD=0 [ACE=0] [SLOT=0]
Unload a headThe same sheetACE_UNLOAD_HEAD HEAD=0 [RETRACT_LENGTH=] [KEEP_HEAT=]
Swap a head to another bayThe same sheet, and each bay's sheetACE_SWAP_HEAD HEAD=0 ACE=1 [SLOT=0]
Dry, with temperature and durationThe unit's Dry buttonACE_DRY ACE=0 [TEMP=] [DURATION=] · ACED__DRY_STOP
Dry automatically above a humidityThe same sheetACE_SET_AUTO_DRY
Switch the active unitA collapsed unit's row, when there are twoACE_SWITCH TARGET=0 [AUTOLOAD=1]
Unload everythingThe header's overflow. Six of these are set + once; on the face of the panel they would bury the four things that change dailyACE_UNLOAD_ALL_HEADS · ACE_UNLOAD_ALL_CANCEL
Flush length for the next swapACE_SET_PURGE LENGTH=<mm> | RESET=1
Confirm before load / unloadACE_SET_CONFIRM_COMMANDS ENABLE=0|1
Spoolman URL and auto-syncACE_SET_SPOOLMAN URL= AUTO=0|1
Clear the head→bay bookkeepingACE_CLEAR_HEADS [HEAD=0]
Name the filament in an unloaded bayThe bay's own sheet — tier 2No macro. multiACE's own override store, behind CORS
Bowden calibrationnot in v1ACE_CALIBRATION_START / _FEED / _MARK / _RETURN …
Background swap while printingnot in v1ACE_BG_SWAP · ACE_BG_UNLOAD · ACE_BG_MOVE
+ +
Leave the experimental macros out, and say so. Everything +marked [EXPERIMENTAL] in the macro help carries preconditions in its own +help text — requires head mode, 1:1 wiring, an OPEN dock below the head (purges +~60 mm!). A panel that offers them without enforcing those is a panel that purges +filament onto a bed. They belong in check_coverage.py's +EXCLUDED list with that reason attached, which is how this page keeps +"not built" visible instead of silent.
+ +
Every one of these is slow, and an instant ok is +indistinguishable from success. ACE_BG_UNLOAD's own help says +~3 min. So the rules the page already has apply without amendment: never await the +request, confirm against machine state (swap_in_progress, +last_swap_result, head_source, +dryer_status.status), and drive it through +core/pending.js. +ace.event_seq and ace.swap_phase look purpose-built for exactly +this and neither has been watched through a swap yet.
+ +

06What can be built now, and what cannot

+

Two ways into the ACE, and they do not carry the same data. The first is +free; the second needs a small piece of C++ that does not exist yet.

+ +
+ + + + + + + + + + + + +
ace, a Klipper object over the page's own MQTT session/multiace/api/state, the FastAPI service
Reachable from the pageyes sw_GetMachineState {objects:{ace:null}} + — 277 ms, measured through run_webkit.py --real. No new + bridge commandno nginx :80 sends no CORS header; + fetch fails Load failed
Topology, unit health, occupancyall of itsame
Identity of what is loadedyes head_source[n]same
Identity of what is in an unloaded bayabsent every raw slot reads + {material:"", brand:"", rfid:0}present from multiACE's override store
+ +
The panel is drawn for the first tier and degrades honestly into it. +An occupied bay whose filament is not named is drawn as occupied and not named +— the disc keeps its checkerboard-free grey and the name pill reads / +— rather than blank, which would be a lie in the other direction. Tier 2 is one +sw_ command that proxies /multiace/api/state past CORS, and it +changes nothing about the layout: it fills in pills that already exist.
+ +

07Build order, and how to check any of it

+

Each step is useful on its own and none of them needs the one after it.

+ +
+ + + + + + + +
#stepneeds
1The head row grows a source switch, reading + head_manualhead_feederhead_ace + in that order, and the ACE-mode pill in the header. No unit box, no wires. This + is state N0, and it is worth shipping alone: it is the whole of the panel's + behaviour with no ACE attached
2The unit box — badge, humidity and temperature pill, four + bays drawn as heads. Read from sw_GetMachineState {objects:{ace:null}}. + State N1 without its wires1
3The bus — Bambu's merge bar, the grey stubs, the coloured + leg from head_source. This is the part that has to be checked + geometrically rather than by eye2
4The collapsed row and the rack — two units, then the fold + to F6 at three. States N2 and N42
5The sheets — load, unload, swap, dry, and the header + overflow's settings list2
6Tier 2 — an sw_ proxy for + /multiace/api/state, so an unloaded bay can be named2
+ +

How the drawings above were checked

+

--shots writes blank PNGs under WSL — EGL finds no +driver — so a screenshot proves nothing here. Every claim this document makes about +a size or a position was asked of the running document instead.

+ +
# the drawings: 830px wide, every body against the 456 it is allowed, both themes
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-filament-mockup.html
+
+# where 456 came from - the real panel, in Orca's own engine, at the window that matters
+python3 resources/web/shared/tests/run_webkit.py --size 1920x1080
+
+# and the page itself, once any of this is built
+python3 resources/web/shared/tests/run_webkit.py --size 1920x1080 --drive resources/web/shared/tests/drive/dom.js
+python3 resources/web/shared/tests/run_webkit.py --real --device-ip 192.0.2.1
+ +
The wires are checked by arithmetic, not by looking. Each +coloured path is asked for its endpoints with getPointAtLength and +getScreenCTM, and those are compared against the centre of the bay it claims +to leave and the toolhead it claims to enter. When this was first drawn the coordinates +were 2 px out, because the panel body is 790 px of content and not the 788 the +arithmetic assumed — the card has no border. Nothing on screen would have shown +that, and the check does.
+ +
diff --git a/docs/u1-webui/02-device-page/multiace-modes.html b/docs/u1-webui/02-device-page/multiace-modes.html new file mode 100644 index 00000000000..f3d6a08fccc --- /dev/null +++ b/docs/u1-webui/02-device-page/multiace-modes.html @@ -0,0 +1,1432 @@ + + +Three Modes, One Cabinet + + + + + + + +
+

Three modes, one cabinet

+

multiACE's normal, head and multi are +not display preferences: a mode says how many toolheads the ACE drives, and it +changes what the same four bays physically are. Every study before this one was drawn in +head mode. This one switches a live printer to multi and back, +draws the panel in all three, and designs the switch itself — the one control here +whose success arrives as an error.

+

measured on 811002511261022618B3 · 2026-09-01 · +head ↔ multi switched live and restored byte-identical · +normal never observed and labelled so · siblings: +the verbs · +the cabinet · +the card · +arrangements · +shapes

+ + + +

01What a mode is, off the machine rather than off a hunch

+

multiACE's own config comment is the clearest statement anywhere: +“'head' mode = exactly ONE head is ACE-driven (fixed to ACE 0, e.g. via a +4→1 combiner so that ACE's slots all feed one head); every OTHER head uses its stock +side feeder. … 0 ACE heads = normal, 1 = head, all = multi. The +plugin's original shape is multi — its README installs into multi mode, with a +splitter on every head merging one tube from each unit.

+ +
+ + + + + + + + + + + + +
normalheadmulti
heads the ACE drivesnoneone (default toolhead 4)all four
a unit's four bays areparked storage — the dryer still worksfour filaments for one head — a hubone filament for each head — four parallel lanes, bay s → head s
ace_heads measured[] by definition[3][0, 1, 2, 3]
head_feederignoredthe answer for who is on a stock feederignored — left byte-identical across the switch
head_aceignoredthe per-head wiring (ACE_SET_HEAD_ACE)ignored — also left stale
which unit feeds head hhead_ace[h]the active unit (ACE_SWITCH TARGET=n); recorded per load in head_source
which bay feeds head hany of the unit's four — a choicebay h, alwaysSLOT defaults to HEAD
load verbAUTO_FEEDING (the U1's own)ACE_LOAD_HEAD HEAD ACE SLOTACE_LOAD_HEAD HEAD — the slot is implied
swap isbetween the unit's baysbetween units, at the same bay index — with one unit there is nothing to swap to
background verbsnogated by enabled_headsrefused by the plugin itself: ace_bg_swap answers “v0 requires head mode (1:1 ACE per head)”
switch into itreboot, all heads unloaded firstlivelive
+ +

The switch, run on the live printer

+

SET_ACE_MODE MODE=multi and back, over Moonraker's HTTP, machine idle, +filament loaded in three heads. The needs_unload guard in the macro fires +only on a normal transition, so the live pair went through with +filament in place. Between head and multi, exactly two keys +of the 38 changed: mode and ace_heads. Everything else +— head_feeder, head_ace, head_source, +active_device, gate_status, the slots — is left as +written, just ignored. The console said, verbatim:

+
SET_ACE_MODE MODE=multi
+// Switching to MULTI mode...
+[multiACE] Switched to MULTI mode. No reboot needed.
+
Entering multi repopulated print_task_config for all +four heads from the bays. filament_type went from +["","","","PETG"] to ["PETG","PETG","PETG","PETG"] and +filament_exist from [T,T,F,T] to all true — bay s +pushed onto head s, including a head whose extruder is empty and two heads whose +filament physically came from stock feeders. In multi mode the U1's own display model +mirrors the bays, and the machine believes the mode's claim instantly. Entering +head mode wipes it back (round nineteen's wipe), which restored the baseline exactly.
+
The mode is a claim about the tubes, and nothing verifies it. +This machine is plumbed for head mode — a 4→1 combiner into toolhead 4 — +and in multi mode it reported head_source[3] = {slot: 1}, carried across the +switch: a bay feeding a head it is not lane-wired to, which multi's own rule says cannot +happen. The panel must draw what the machine records, not what the mode promises.
+

Also read off the machine and its source on the way in: SET_ACE_MODE +accepts a fourth token single and treats it as multi; +ACE_SET_HEAD_FEEDER and ACE_SET_HEAD_ACE are documented +“head mode only”, while ACE_SET_HEAD_MANUAL works in every mode; +and ace_head (singular) is the one ACE-driven head when there is exactly one, +else the boot default — in multi it still reads 3 and means nothing.

+ +

02The panel, in all three

+

The live drawing. head is the settled design and is the reference; +multi was drawn two ways and is settled as cards (lanes stays switchable as +the picture that explains the mode); normal is a panel with no ACE verbs and one +remaining ACE fact worth a control — the dryer, settled as the strip. The +machine strip below is the printer's side of the conversation: whether filament is loaded +decides whether a normal transition is refused, and only a restart completes +one.

+ +
+ + + + + + + + + + + + + + + + +
+Mode +Multi +Two units +At 560 +The switch +
+
+
+
body— / 456
+

+
+
The printer + + + +
+
+
830 px
Filament
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro44 %31 °C
body451 / 456 · 5 spare

head · the settled design, as the reference · toolhead 4 on the cabinet, fed from A2; the other three on stock feeders · width 830

+
830 px
Filament
ACE A · ACE 2 Pro44 %31 °C
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
body437 / 456 · 19 spare

multi · cards · 1 unit · SETTLED as the drawing (2026-09-01) — the selector names the lane SET, and the fed ring says which bay is feeding · width 830

+
830 px
Filament
ACE A · ACE 2 Pro44 %31 °C
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
body271 / 456 · 185 spare

multi · lanes · 1 unit · kept as the picture that explains the mode; the panel draws cards · width 830

+
830 px
Filament
ACE A · ACE 2 Pro44 %31 °C
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
body271 / 456 · 185 spare

multi · lanes · as measured · 1 unit · head_source[3]={slot:1} — the core crosses lanes; toolheads 1–2 hold feeder filament the mode no longer accounts for (sensor at, source unrecorded) · width 830

+
WRONG IN THIS MODEdrawn from the measured multi payload through today’s headSource()
830 px
Filament
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro44 %31 °C
body451 / 456 · 5 spare

multi · as the shipped model reads it · 1 unit · headSource() resolving head_feeder in a mode where the machine ignores it: three feeder cards on a machine whose every head is ACE-driven · width 830

+
830 px
Filament
ACE A · ACE 2 Pro44 %31 °C
ACE B · ACE Pro52 %29 °C
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
body271 / 456 · 185 spare

multi · lanes · 2 units · kept as the picture that explains the mode; the panel draws cards · width 830

+
830 px
Filament
ACE A · ACE 2 Pro44 %31 °C
ACE B · ACE Pro52 %29 °C
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
body437 / 456 · 19 spare

multi · cards · 2 units · SETTLED as the drawing (2026-09-01) — the selector names the lane SET, and the fed ring says which bay is feeding · width 830

+
560 px
Filament
ACE A · ACE 2 Pro44 %31 °C
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
body271 / 456 · 185 spare

multi · lanes · 1 unit · kept as the picture that explains the mode; the panel draws cards · width 560

+
560 px
Filament
ACE A · ACE 2 Pro44 %31 °C
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
body437 / 456 · 19 spare

multi · cards · 1 unit · SETTLED as the drawing (2026-09-01) — the selector names the lane SET, and the fed ring says which bay is feeding · width 560

+
830 px
Filament
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
ACE A · ACE 2 Pro44 %31 °C
body443 / 456 · 13 spare

normal · strip · unmeasured: this mode has never been observed on hardware · SETTLED (2026-09-01) — the cabinet feeds nothing; the strip keeps Dry reachable · width 830

+
830 px
Filament
Toolhead 1
Toolhead 2
Toolhead 3
Toolhead 4
body409 / 456 · 47 spare

normal · quiet · unmeasured: this mode has never been observed on hardware · rejected — the pill is the only ACE evidence, and Dry lives nowhere · width 830

+
830 px
Filament
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro44 %31 °C
body451 / 456 · 5 spare

head · the settled design, as the reference · toolhead 4 on the cabinet, fed from A2; the other three on stock feeders · width 830

+
830 px
Filament
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro44 %31 °C

Head mode

One toolhead is on the cabinet; the other three keep their stock feeders.

SET_ACE_MODE MODE=head HEAD=3
body451 / 456 · 5 spare

head · the settled design, as the reference · toolhead 4 on the cabinet, fed from A2; the other three on stock feeders · width 830

+
830 px
Filament
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro44 %31 °C

The printer refused

// Cannot switch mode! Filament still loaded in: E0, E1, E3
// Please unload all toolheads first, then try again.
body451 / 456 · 5 spare

head · the settled design, as the reference · toolhead 4 on the cabinet, fed from A2; the other three on stock feeders · width 830

+
830 px
Filament
Toolhead 1
Feeder · channel 1
Toolhead 2
Feeder · channel 2
Toolhead 3
Feeder · channel 1
Toolhead 4
ACE A · ACE 2 Pro44 %31 °C
body451 / 456 · 5 spare

head · the settled design, as the reference · toolhead 4 on the cabinet, fed from A2; the other three on stock feeders · pending: ace.mode=head vs saved ace__mode=normal — the pill draws the disagreement · width 830

+
+
+ +

03Multi: the cabinet stops belonging to one card

+

In head mode the cabinet is one head's rack of four choices. In multi it +is four lanes: bay s is plumbed to head s, a load on head s +can only come from bay s, and offering any other pairing asks the ACE to push +filament somewhere it is not routed — which the machine will not refuse. +Two honest drawings of that, both above:

+ + + + + +
lanescards
what it isthe cabinet drawn once, four tubes fanning to four heads in a row under it — the topology as a picturethe settled 2×2 frame kept; each card holds its own lane's bay, one spool wide, and the unit row becomes a shared band above the grid
what it says wellone physical cabinet, four owners; a cross-lane feed (the measured state) is visibly a crossingcontinuity — the panel keeps one shape across modes, and a per-head card survives at widths where four side-by-side lanes crowd
what it costsa second layout to maintain; the per-head header and selector lose their cardthe one cabinet is drawn as four fragments; the shared unit band is the only place saying they are one machine
+
Settled, 2026-09-01: multi draws as cards. Lanes is the nicer +picture with one unit; with more, per-head cards win — and one drawing has to serve +both, so cards it is. Lanes stays in this study as the picture that explains the mode, +not as anything the panel builds. And the header stops naming one bay of a set: +with two units, “Bay A1” sat over a card drawing A1 and B1 — both +are plumbed to the head through its splitter — so the selector's option names the +whole lane (Bay A1 · B1, eliding past two units), and the fed +ring on a bay says which one is actually feeding.
+
A bay is drawn once, on the card of the head it is plumbed to. +In the measured state — toolhead 4 fed from bay A2 — the cards drawing +puts A2 on toolhead 2's card marked feeding Toolhead 4, and +toolhead 4 carries a chip naming what is in it and where it came from. Duplicating +the bay onto the fed head's card would draw one spool twice, which is the reading risk +the handover already rejected.
+
“As the shipped model reads it” is the bug, drawn. +headSource() tests head_feeder first in every mode, and that map +is only consulted by the machine in head mode. Fed the real multi payload it resolves +three stock-feeder cards and one four-bay cabinet — the head-mode picture — +on a machine that considers all four heads ACE-driven. The verbs are wrong the same way: +aceVerbs() offers every bay to every card with an explicit +SLOT=.
+

With two units, multi is the README's own picture: a splitter on every head +merges lane s of each unit, the active unit (ACE_SWITCH) is +the set in play, and a swap becomes between units at the same index — the +one place the panel would surface active_device, which head mode has no use +for. The selector stays two entries — the lane is not a choice, only hand-feeding +is — and choosing the other unit's bay is a verb (a load), not a wiring.

+ +

04Normal: no verbs, one fact

+

All four heads are stock-feeder or hand-fed, and every ACE verb is out of +scope — but the cabinet is still attached, still reports humidity, and can still +dry spools. unmeasured normal has never been observed on +hardware; the ace object is assumed to keep reporting the unit there.

+ + + + +
stripquiet
the drawingfour feeder cards, plus the unit band below the grid: badge, name, humidity, Dryfour feeder cards and nothing else; the mode pill is the only ACE evidence
the argumentdrying storage is what an idle cabinet is for; a control that vanishes leaves no way to find out whya mode whose point is “the ACE is out of the picture” should look like it
+
Settled, 2026-09-01: strip. The dryer stays controllable while +the cabinet feeds nothing, which is most of what an idle cabinet is for.
+ +

05The switch, which is the thing to design first

+

Because it is the only control on the panel whose success looks +exactly like a failure. The three outcomes, all drawn above under The switch:

+
    +
  • Live (multi ↔ head): applies at once. The console's own line +is the confirmation — “[multiACE] Switched to MULTI mode. No reboot +needed.” — and the pill follows ace.mode.
  • +
  • Restart pending (normal ↔ anything): the macro swaps Klipper +extras, then deliberately fails the RPC (gcmd.error) and raises exception +6666 saying “Switched to NORMAL mode. Please reboot!”. +ace.mode keeps reporting the old mode until the power cycle; the saved +ace__mode flips immediately. The pending state is that disagreement +— save_variables is a Klipper object the page can read in the same +sw_GetMachineState call — so the pill can say +head → normal · restart to finish off two fields it already has, and +the banner shows the exception's own message.
  • +
  • Refused: with filament loaded, a normal transition answers ok, +changes nothing, and explains only on the console, as // notes — +captured verbatim off this machine: “Cannot switch mode! Filament still loaded +in: E0, E1, E3” · “Please unload all toolheads first, then try +again.” The dialog shows the machine's own sentences. (They are notes, not +!! errors, so lastPrinterError() needs the note channel too.)
  • +
+

Choosing Head also asks which headSET_ACE_MODE MODE=head +HEAD=n writes head_feeder[h] = (h ≠ n) for all four; without it the +mode keeps whatever wiring was there. The panel has never sent HEAD=, which +is fine as a default and a gap for anyone whose combiner moves. Same-mode selection is +answered by the machine too: “Already in HEAD mode (re-syncing)...”

+
Entering head mode wipes every feeder head's display identity +and unlatches filament_official — a mode behaviour, measured in +round nineteen and again today (the wipe is what restored the baseline). The panel +already survives it by falling back to the spool's tag; the switch flow does not need to +say it, and the record does.
+ +

06What follows in code, once a drawing wins

+
+ + + + + + + +
wherechange
headSource()ask ace_heads whether a head is ACE-driven, then resolve which per mode: head → head_ace + head_source.slot; multi → the active unit, lane = head index, head_source overriding when a feed is recorded. head_manual stays a separate read — a manual head merely vanishes from ace_heads.
aceVerbs()multi: only the lane bay loads; swap exists only across units; both background verbs are absent because the plugin refuses them outside head mode in its own words.
the source selectoroptions per mode — head: feeder / ACE / hand-fed; multi: lane / hand-fed; normal: feeder / hand-fed. ACE_SET_HEAD_MANUAL is the one setter that is legal everywhere, so the selector never fully disables again.
the mode pilla list with three outcomes: live, restart-pending (drawn from ace.mode vs saved ace__mode), refused (read off the console's note lines). Head offers the head picker.
mockhost.jsgrows the multi payload captured today, so the simulator holds two modes instead of one.
unit_jsc.pythe mode rules as pure logic: given each payload, which head is on which unit and bay, and what a Load would send.
+

multiace-modes.html · checks: +python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-modes.html +· bake: python3 docs/u1-webui/tools/bake_mockup.py same file

+
+ + diff --git a/docs/u1-webui/02-device-page/multiace-toolhead-card.html b/docs/u1-webui/02-device-page/multiace-toolhead-card.html new file mode 100644 index 00000000000..115661b6dad --- /dev/null +++ b/docs/u1-webui/02-device-page/multiace-toolhead-card.html @@ -0,0 +1,1273 @@ + + +The Toolhead Card + + + + + + + +
+ +

The toolhead card

+

The header is settled. This is what goes under it: how a filament is drawn, +what box an ACE's four sit in, how the tube reaches the head — and what the head says now +that it no longer carries the filament's identity.

+

Live · five option axes, mixable · every option also +pre-rendered, so it reads with no script · panel 830×456, measured +· siblings: the seven shapes · +the four arrangements

+ + + +

01One decision drives the rest

+

The toolhead stops carrying filament. No colour, no material name, +no pencil on the artwork. Filament belongs to the source, and a toolhead is a +machine part that either has filament in it or does not.

+ +
+ + + + + + + + + +
what the head used to drawwhere it goeswhy
A 36 px disc in the filament's colourThe bay, or the feeder cardThe colour was drawn twice, and the second copy was the one that could go stale. + With four bays visible the head's copy adds nothing the tube does not already say
A #6E6E6E pill with the material nameThe sameSame reason, and it was the taller half of the head's content
A pencil, opening the material editorThe bay's own sheetEditing a filament from the head meant editing a bay you were not looking at
A sensor markerThe head does have something of its own to say, and the printer reports it + properly — see below
+ +
The sensor is not a binary. +filament_feed left|rightextruder0..3 reports four +positions along the path plus a fault: filament_in_ace, +filament_in_toolhead, filament_at_extruder and +channel_error (with channel_state naming the channel's own +state machine). So a marker can say at the extruder, in the tube, +nothing, or feed error — four readings that are already parsed in +state.js's +feedChannels() and were, until now, shown nowhere except a dialog.
+ +

02Five axes, mixable

+

Each of these is independent, so a decision on one does not commit the +others. The height under the drawing is the constraint every combination has to answer to: +456 px, measured off .filament-body at 1920×1080, and a body +past it is clipped in silence.

+ + + + + + + + + + + + + + + + + + + +
+ Frame + + + + + Filament + + + + + + Box + + + + + + Tube + + + + + + Marker + + + +
+
Without script each option is shown with the others at their +recommended setting. Mixing two at once, changing the unit count or the source, and the +command log all need script.
+ +
+
Frame + + + + +
+
Filament + + + + + +
+
Box + + + + + +
+
Tube + + + + + +
+
Sensor marker + + + + +
+
ACE units + + + + + +
+
Sensor states + + + +
+
+ +
+
+
body
+
+
+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body595 / 456 · 139 over

Frame + · BELOW-F — The same arrangement with the artwork at 1.0. This is the I4 route, and the number under the drawing is the answer to whether it is available.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body417 / 456 · 39 spare

Frame + · BESIDE-F — The head at full scale, beside the box rather than under it. The tube runs sideways and down into the inlet instead of straight down — less like Bambu's diagram, and the only way to keep 64×140 in a 2×2.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body431 / 456 · 25 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S1 — The shipped idiom, unchanged: a 32 px disc over a #6E6E6E name pill, the address in the disc. It is what .slot already draws, so it costs nothing to build and nothing to learn.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body443 / 456 · 13 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S2 — A reel seen face-on — neutral flange, the filament wound as a coloured annulus, the address in the hub. It is unmistakably filament rather than a colour, and nothing about it implies a level, which matters because spool_binding is {} and there is no level to imply.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S4 — Orca's AMSLib well — an inset well with the colour blocked inside and hatched, because the amount is unknown and a flat block reads as full. Honest, and the only one that says so out loud.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body425 / 456 · 31 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B2 — The tray: the page's own inset panel, --pill on a 1 px --line. No new material, no new idea, and it never competes with the bays inside it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body425 / 456 · 31 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B3 — The rail: no enclosure at all, and a bar under the bays that is the outlet — so the box and the start of the tube are one object rather than two.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body389 / 456 · 67 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B4 — No box. Kept as the control: it is what the previous study drew, and the comparison is only worth anything with it present.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W1 — A single 2.5 px stroke, the filament's colour when something is loaded and grey when the head is only wired. What the previous study drew.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W2Tubing: a #C9C9C9 casing with the filament's colour as a core inside it. That is what the machine has — PTFE with filament in it — and it settles the wired/loaded distinction without a second colour, because the casing is always there and the core only where there is filament.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W4 — Tubing, routed orthogonally with a rounded corner. Reads like a plumbing diagram; the cost is that it looks routed rather than flexible, which a bowden tube is not.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M1 — A dot on the body of the artwork. Four states: at the extruder, in the tube, nothing detected, feed error — all four are real fields, not a binary.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
at the extruder
Toolhead 2
PLA
Forshape
in the tube
Toolhead 3
Nothing
detected
Feed error
Toolhead 438 %
at the extruder
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M2 — A chip beside the head, in words. Unambiguous, and the only option that survives being printed in greyscale.

+
Filament|1 ACE unit
Toolhead 1
PLA
Jayo
Toolhead 2
PLA
Forshape
Toolhead 3
Nothing
detected
Toolhead 438 %
body427 / 456 · 29 spare

Frame + · BELOW-S — The head sits under the box in Bambu's own order, at 0.45 — 29×63. It carries no filament any more, so what it has to be big enough for is its own silhouette and one sensor marker.

Filament + · S3 — Bambu's slot card: a solid capsule of the filament's own colour with the material named inside it in contrasting ink, the address small in the corner, and a neck at the top where the tube leaves. The closest of the four to the reference, and the one that reads fastest across four bays.

Box + · B1 — The cabinet: the chassis from 16‑ace‑visuals.md at working size, with the base drawn wider than the body — which is what makes it read as a piece of furniture rather than as a panel with a line under it.

Tube + · W3 — Tubing plus Bambu's merge: every bay drops onto one manifold and a single tube leaves it. The coloured core runs from the bay that is actually feeding, along the manifold, and out — so the picture says which bay without four tubes crossing.

Marker + · M3 — The artwork's own inlet, lit. The two stubs at x=26 and x=38 are where filament enters; drawing them in its colour says there is filament in this tube using a part the drawing already has.

+
+
+

+ + + + +
+ +

03Measured

+

Body heights in WebKitGTK, against the 456 the panel has. Only the frame +costs height; the filament, the box, the tube and the marker are all affordable in every +combination that fits at all.

+ +
+ + + + + + + + + + + + + +
frameS1 discS2 reelS3 cardS4 well
Head below, 0.45 — the first draft4674874634997–43 over
Head below, 0.40 — what it takes43144342742713–29 spare
Head below, full — the I4 route599611595595139–155 over
Head beside, full41741741741739 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 — +139 to 155 over, on a body that is overflow: hidden and clips in silence. +Nothing in the card can pay that back: the whole box, its bays and its tube together are +84 px. So the choice is not I3 or I4. It is between Bambu's vertical order at a +0.40 head, and a full-scale head with the box beside it.
+ +
+ + + + + + + + + + +
heightwhat it costs
Box B1 cabinet / B2 tray / B3 rail / B4 none427 / 425 / 425 / 389The cabinet costs 2 px over a tray and 38 over nothing at all. + At that price the question is only which one reads best
Tube W1 line / W2 tubing / W3 manifold / W4 orthogonal427 in all fourNothing. They are 4, 11, 28 and 11 paths respectively — the manifold is the + most drawing and the same height
Marker M1 dot / M2 chip / M3 lit inlet427 in all threeNothing, because the chip sits beside the head rather than under it
+ +

04What to take

+ +
+ + + + + + + + + + + + + + + + +
axistakewhythe runner-up
FrameHead below, 0.40It is Bambu's order and the tube runs straight down into the inlet, which is the + one relationship this panel exists to draw. The head carries no filament any more, + so what it has to be big enough for is a silhouette and one markerHead beside, full — 39 px spare and the artwork at 1.0, if the + extruder is judged to be carrying weight at full size
FilamentS3 slot cardThe material name sits inside the colour, so a bay is one object and not a + drawing with a label under it — which is what lets four of them read at a + glance in a 391 px cell. It is also the closest of the four to the referenceS4 well, the only one that says the amount is unknown rather than + implying full. Take it if spool_binding ever fills in
BoxB1 cabinetThe base drawn wider than the body is what makes it read as a piece of furniture + rather than a panel with a line under it — and the geometry is lifted from + 16‑ace‑visuals.md rather + than invented, so it will not drift from the C++ surfacesB2 tray, 2 px cheaper and it introduces no new material. The right + answer if the cabinet is judged too loud four times over
TubeW3 manifoldTubing rather than a line — a #C9C9C9 casing with the + filament's own colour as a core — is what the machine actually has, and it + settles wired-versus-loaded without a second colour: the casing is always there, + the core only where there is filament. The manifold then says which bay is + feeding without four tubes crossingW2 tubing, direct. Same treatment, one tube, no merge — better if the + manifold reads as machinery that is not there
MarkerM1 dotFour states in 11 px, no words, no width. And they are four real + readings, not a binary dressed upM2 chip, which is the only one that survives greyscale and the only one a + screen reader gets for free
+ +
The one thing 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 material name, it is fair to ask whether it is doing anything a label would +not. Two answers, and they are 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, the honest move is the head beside, full frame +— which fits with 39 px spare and makes the artwork worth its space again.
+ +

05Checking it

+
# after ANY change to this file's JavaScript or to the panel CSS
+python3 docs/u1-webui/tools/bake_mockup.py  docs/u1-webui/02-device-page/multiace-toolhead-card.html
+python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/multiace-toolhead-card.html
+

The checks drive the rig: every option on every axis, the height of each +against 456, that no toolhead artwork carries a disc or a name pill or a pencil any more, +that every tube lands on the inlet in every frame and every routing, that a coloured core +appears only where filament is loaded, that all four sensor states are reachable and +distinct — and then the whole no-script path with .js-on removed, +including that every pre-rendered copy still matches what the renderer produces.

+ +
diff --git a/docs/u1-webui/02-device-page/panel-popover-mockup.html b/docs/u1-webui/02-device-page/panel-popover-mockup.html new file mode 100644 index 00000000000..d7a9f2d0324 --- /dev/null +++ b/docs/u1-webui/02-device-page/panel-popover-mockup.html @@ -0,0 +1,1353 @@ + + +Device Control panel — direct manipulation + + +
+

Control panel — direct manipulation

+

Second pass, after the Bambu Device page. Icon-only rows, temperatures + edited in place, and the jog wheel rebuilt so the ring is the step size.

+ +

01The panel

+

Try it: click into any target temperature and type — the row + outlines while you edit and rejects anything outside the machine’s limits. Hover the + wheel to see the hit areas. Print speed is open to show the popover pattern. + Readings are real, from U1 811002511261022618B3.

+ +
+
+
+
Control
+
+ +
+
+ 27/ + + °C
+ 26/ + + °C
+ 26/ + + °C
+ 26/ + + °C
+ + 25/ + + °C
+
+
+ + +
+
Print speed +
+
+
+ Print Speed100%
+
+
+ +
+
50%100%150%
+
+
+
+
+ + +
+
Fan control +
+
+
+ Main Cooling Fan Speed0%
+
+
+ +
+
0%25%50%75%100%
+
+
+
+ Assist Cooling Fan Speed0%
+
+
+ +
+
0%25%50%75%100%
+
+
+
+
+ + +
+
Air purifier +
+
+ + No air purifier connected +
+
+
Off0
+
Recirc1
+
Exhaust2
+
+
+
+
+ + +
+ +
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
+
+
+
+ +

02What changed, and why

+
    +
  1. All four toolheads, icon-only +

    The shipped iconExtruder14 already carry their number, + so a row is the icon and the reading — no label, no badge, no colour chip.

    + Why nothing else: the label was the least informative thing in the + row and cost the width the numbers need. The number badge drawn earlier turned out + to be duplicating what the asset already says. And the filament colour was a + circle, which reads as a status light — the same vocabulary as the live-toolhead + marker — so one shape carried two meanings; colour belongs to the Filament panel, + where a slot is the subject rather than a decoration on something else. What + survives is a corner dot for the head the machine reports live, which says + “state” without competing with the identity the icon is carrying.
  2. +
  3. Targets edit in place +

    The target is an <input type="number"> that looks like text + until you touch it: hover paints a soft field, focus outlines the whole row, and + min/max turn the value amber outside the limits.

    + The cursor you asked about is the I-beam — CSS + cursor: text. The blinking mark it leaves behind is the + caret. Both come free with a real input, which is also why this is an input + and not a styled span: it brings keyboard, selection and validation with it.
  4. +
  5. The ring is the step size, and the axis label lives in it +

    Three bands — 10, 1 and 0.1 mm from the rim inwards — quadrant chooses the + axis, centre homes. The bed row carries the same three steps. + Y, X, −Y, −X sit inside the + outer band rather than floating outside the wheel.

    + Why the ring: this is the good idea in the reference. The previous + pad had a separate 10/1/0.1 selector, so every move was two clicks and carried + hidden state — you could not tell what a press would do without reading another + control first. Here the thing you click is the distance. + Why the label inside: an axis letter outside the circle labels the whole wedge but + belongs to nothing you can press. Inside the band it names the target, and the + wheel no longer needs a ring of empty margin to hold its own captions.
  6. +
  7. Z is a row, not a third axis on the wheel +

    ↑10 ↑1 · Bed · ↓1 ↓10 directly under the wheel.

    + Why: on this machine Z moves the bed, not the head. Putting it on + the wheel would imply the toolhead travels, and the word “Bed” between the arrows + says which object actually moves.
  8. +
  9. Toolhead picking sits left of the wheel, and reuses the heating icon +

    Four boxes carrying the same nozzle glyph and number badge as the temperature + rows, plus the filament colour. Pick extruder and + Park extruder sit side by side underneath, with the extrude ▲/▼ + below that.

    + Why the same icon: a toolhead is one object, so it should look + identical wherever it appears — reading its temperature on the left and selecting + it here should not feel like two different things. + Why two controls: the boxes choose which head jog and extrude address, + which is instant and harmless. Pick extruder changes which head is + live — it parks one and grabs another — so it stays a separate button + that blocks while the gantry moves, not a side effect of selection. + Park extruder is its counterpart and not a duplicate of it: it ends with + no head engaged, which is the state you want before a lid comes off or + a nozzle is changed. The machine already reports this distinction — + extruder.state reads PARKED or ACTIVATE — + so the panel should be able to reach both.
  10. +
  11. Quick settings became tiles +

    Speed, fans, purifier and lamp sit as tiles under the readings, each opening its + panel directly beneath. One fan button holds every fan.

    + Why one fan button and not two: the fans are separate objects on the wire (fan and fan_generic cavity_fan) but they are one thing to think about — “how hard is this machine blowing”. Splitting them spent two of five tiles on a distinction the user did not ask for. Inside, each fan gets a compact row with its own toggle and −/+ stepper, which is how the reference’s air-management sheet handles three of them without strain. Adding a third fan later costs a row, not a tile. Why tiles at all: they separate readings you watch from settings you change. The lamp is a binary, so it keeps a switch and no panel.
  12. +
+ +

03Open questions

+
+ 0.1 mm made it in. Three bands fit by narrowing the outer two rather than the + inner: 26 px, 22 px and 20 px from the rim inwards, with the home target down to + r 32. The step labels shrink to bare numbers because a rotated “0.1 mm” is wider + than the band it has to sit in — the unit lives in the tooltip and the bed row instead. + Watch this on a small window: the innermost band is the first thing that becomes hard + to hit, and it is also the least often wanted. +
+
+ No filament colour anywhere in the Control panel. It was a circle beside each + toolhead, which read as a status light — the same vocabulary as the badge marking the + live one — so a single shape carried two unrelated meanings, and it had to be asked + about, which settled it. The number badge is enough to tell four nozzles apart in + both the readings and the picker. Colour belongs to the Filament panel, where a slot + is the subject rather than a decoration on something else. +
+
+ Absent hardware disables its own control. The purifier is drawn as this + machine reports it — power_detected: false — so its tile is dimmed to + an em dash, the panel says no purifier is connected, and the three modes are + visible but out of reach. The modes stay on screen rather than vanishing so the + panel does not change shape when one is plugged in. Note this is a state message, + not the explanatory copy removed above: it tells the user something about their + machine that they cannot see anywhere else on the page. The same rule wants + applying to the jog wheel while the axes are unhomed. +
+
+ The panels carry no explanatory copy. Earlier drafts put a caveat line at the + top of each one, after the reference. It is gone deliberately: most of what those + lines said described the reconstruction rather than the machine — which wire object a + control maps to is documentation, not product copy. The one claim with something + behind it, that the speed factor does nothing on an idle printer, belongs in the + control’s enabled state rather than in prose the user reads every time. +
+
+ The wheel needs an unhomed state. Klipper refuses a move on an unhomed axis, and + this machine currently reports homed_axes: "". The sectors should read as + disabled until G28 has run, with the centre inviting it — otherwise every + press silently does nothing, which is the bug that started this thread. +
+ +

06Colour options

+

The same components, changing only border, text and hover tint — the + backgrounds are untouched in every one. Hover a card to see its hover state.

+
+
+ + A · Teal + #0f6e7b + +
+

What the mockup carries today. Comes from shared/css/base.css, which is the reconstruction's own palette rather than anything measured off the product.

+
+
+ + 27/ + 0°C +
+
+ + 26/ + 245°C +
+
+ 100% + 0% +
+
+ +
+ Pick extruder +
+
+
+ + B · Snapmaker blue applied + #0C63E2 + +
+

Chosen, and now in use above. Four occurrences in the shipped U1 bundle — the only real accent in it — and already --accent in device.css. Choose this and the mockup and the implementation agree for free.

+
+
+ + 27/ + 0°C +
+
+ + 26/ + 245°C +
+
+ 100% + 0% +
+
+ +
+ Pick extruder +
+
+
+ + C · Orca green + #00AE42 + +
+

0x00AE42 in Orca's C++, the StateColor the slicer chrome uses. Argues that the Device tab should match the app it lives in rather than the firmware it talks to.

+
+
+ + 27/ + 0°C +
+
+ + 26/ + 245°C +
+
+ 100% + 0% +
+
+ +
+ Pick extruder +
+
+
+ + D · Graphite — no chroma + #3B4754 + +
+

Selection carried by border weight and text colour alone. Leaves colour free to mean one thing only: machine state. Nothing is tinted unless the printer is doing something.

+
+
+ + 27/ + 0°C +
+
+ + 26/ + 245°C +
+
+ 100% + 0% +
+
+ +
+ Pick extruder +
+
+
+ + E · Blue selection, green live + #0C63E2 + +
+

Two jobs, two colours. Blue is what you chose; green is what the machine is doing. The live-toolhead badge and any running indicator take the green, so a glance separates intent from state.

+
+
+ + 27/ + 0°C +
+
+ + 26/ + 245°C +
+
+ 100% + 0% +
+
+ +
+ Pick extruder +
+
+ +

07Slider styling — pick one

+

After the shipped Snapmaker fan control. All three are live — drag a + knob. The difference that matters is what happens between the ticks.

+
Version 1 · Faithful

Exactly the shipped control: a free slider, ticks every 25%, plain percentages underneath. Print speed borrows the same language but has only three positions, because 50/100/150 is all the U1 accepts — so its track shows three ticks, not five.

+
Main Cooling Fan Speed + 0%
+
+
+ +
+
0%25%50%75%100%
+
+
Assist Cooling Fan Speed + 0%
+
+
+ +
+
0%25%50%75%100%
+
+
Print Speed + 100%
+
+
+ +
+
50%100%150%
+
Version 2 · Named stops

The same slider, snapped to its ticks, with the name above and the number below. step does the snapping, so the knob cannot land somewhere the label does not describe.

+
Main Cooling Fan Speed + 0%
+
+
+ +
+
Off0%Low25%Med50%High75%Full100%
+
+
Assist Cooling Fan Speed + 0%
+
+
+ +
+
Off0%Low25%Med50%High75%Full100%
+
+
Print Speed + 100%
+
+
+ +
+
Silent50%Standard100%Sport150%
+
Version 3 · Slider + stepper

The slider for reach, a −/+ field for precision. Worth it where a value is genuinely continuous and someone may want 62%; redundant where there are three legal values.

+
Main Cooling Fan Speed + 0%
+
+
+ +
+
0%25%50%75%100%
0%+
+
+
Print Speed + 100%
+
+
+ +
+
50%100%150%
100%+
+
+ +

08Column separation and panel width

+

Three groups now — readings, picker, motion. Four ways to say so, at + two widths. Each preview is the real panel with its popovers removed.

+
A · Hairlines, snug

A 1 px rule between columns, panel sized to its content (~630 px). The cheapest separation there is, and it keeps the three groups reading as one panel rather than three panels that happen to be adjacent.

Control
+ +
+
+ 27/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ + 25/ + 0 + °C
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
B · Cards, snug

Each column on its own raised card over a recessed body. The strongest separation and the most expensive: three more borders and radii competing with the controls already inside them.

Control
+ +
+
+ 27/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ + 25/ + 0 + °C
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
C · Air only, full width

No rules — 40 px of gap doing the work, at the real 830 px panel width from device.css. The quietest, and it needs that width: at 630 the groups start to merge.

Control
+ +
+
+ 27/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ + 25/ + 0 + °C
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
D · Hairlines + tinted centre, full width

Rules plus a faint ground under the picker, at 830 px. The tint says the middle column is a different kind of thing — a chooser, not readings and not a pad — which the other three leave to position alone.

Control
+ +
+
+ 27/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ + 25/ + 0 + °C
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
+ +

09Jog wheel size, in the panel

+

Variant C leaves 144 px of surplus in the motion column — 388 px in + all — so the wheel can grow into it. Bands keep their 8:7:6 taper and the home + circle its share of the radius, so these differ in size and nothing else. Judge the + innermost 0.1 mm band: it has been the hardest target since it was added.

+
Current 244 px · bands 33/29/25 · home r28
Control
+ +
+
+ 27/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ + 25/ + 0 + °C
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
W1 · +12% 272 px · bands 37/32/28 · home r31
Control
+ +
+
+ 27/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ + 25/ + 0 + °C
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
W2 · +26% 308 px · bands 42/37/31 · home r36
Control
+ +
+
+ 27/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ + 25/ + 0 + °C
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
W3 · +43% 348 px · bands 48/42/36 · home r41
Control
+ +
+
+ 27/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ 26/ + 0 + °C
+ + 25/ + 0 + °C
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + Extrude + +
+
+ +
+ Jog Y 10 mmJog Y 1 mmJog Y 0.1 mmJog X 10 mmJog X 1 mmJog X 0.1 mmJog −Y 10 mmJog −Y 1 mmJog −Y 0.1 mmJog −X 10 mmJog −X 1 mmJog −X 0.1 mmYX−Y−X1010.11010.1Home all axes (G28) +
↑ 10↑ 1↑ 0.1Bed↓ 0.1↓ 1↓ 10
+
+ +
+
diff --git a/docs/u1-webui/02-device-page/screenshots/01-device-page-printing.png b/docs/u1-webui/02-device-page/screenshots/01-device-page-printing.png new file mode 100644 index 0000000000000000000000000000000000000000..21b165d05b4a68bae09b8fe82f8ae652028bcadc GIT binary patch literal 272806 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'TgjrNnO@IL{}VY4 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õOyv|OpiCPRpqG*Hv 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'#){Qsd87SEl49jX_h)pJg9Sh0AT*S2Y=s42 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 literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/02-toolheads.png b/docs/u1-webui/02-device-page/screenshots/02-toolheads.png new file mode 100644 index 0000000000000000000000000000000000000000..481861bd51fe73687998082189064660a86832bf GIT binary patch literal 50227 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 boMTv!0(ZV@!bsP!OL53bDoT_*H}wBs5+QF* literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/03-bed-and-chamber.png b/docs/u1-webui/02-device-page/screenshots/03-bed-and-chamber.png new file mode 100644 index 0000000000000000000000000000000000000000..728f8f4a20384b82e2595de77df7a05510050037 GIT binary patch literal 71586 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 literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/05-paused.png b/docs/u1-webui/02-device-page/screenshots/05-paused.png new file mode 100644 index 0000000000000000000000000000000000000000..5843d04b27a41eb320888eea07974dd7cb12fb05 GIT binary patch literal 42778 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 NG}ZJ}OCCKB{ts0A|A7Di literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/06-fault-banner.png b/docs/u1-webui/02-device-page/screenshots/06-fault-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..d291ba2d3edf37e2f821888a921cd8f07a256c1c GIT binary patch literal 15878 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 literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/07-control-roundtrip.png b/docs/u1-webui/02-device-page/screenshots/07-control-roundtrip.png new file mode 100644 index 0000000000000000000000000000000000000000..53bec3aebda36e32d9fc300f7f01a47a008deb90 GIT binary patch literal 73609 zcmd?R^+Q{0(>B^&qlFfB3zQZLv`~se@#6088YsmHQrw|}6nA$?(BKq;R*mlD_V%-Ed0@oHM9>E^4Bn3tiM?WUHUP~}up3(H^_Wi2Jw z!G+-V6wHjpjv3b17o(wI`aC3c^W~9CLP`0LSMl7tBIDz2&!`_ggCK@WU7|kEj3oU5 zjPRe&nziSh|NH@b5yN|C?W>+!o+XD8V;KFhcPt=8DbjX(BXeEfb-mN^ z{B7B!u5pLie=QP5qtJjT5U& z21qyX8!>mE-8UnqidIEfKw$FJvb1_QB>c(0zs)Hqv-ygmLgOw;ettet(Up}}ID8U}q+S9Ax>gvjk zKZf7v>e|-_lfnga>uYG~8E9w)eI)q(*{KK8TBM{FJxq}D@+@?2p^1(`nfgj=PPons zu6n3*kAj7%c$Sgz!0*po5qg%0A7rHdlmvY{w7jUcL9GQe?kApHHELAgsF&%y%#fJ) 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%0AT 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/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 UMt}KJ0J)>&q?M(g49 z&p_Z0;;Jr5yK|Txs%ul{M}l14ADsvYo=_*MHlybUs~zTuv8c7+NLrv1{b5smS{ZCb z$oo&-g5@jv^%E=fh1&7h+)3s9G<8dh@`yA^u@B-jz`yH!*P;YLesYu4{OF_wh=8zt zCBOY!sxI$Bcd(7MwK~YR(t1w7rE3W(nwX7|3h)8~X@F3Ok*@}rj|`BnKJ?FgP>^rp z#6TFxR}Iwv4;R2$Kr*nwjnisfuO`L2cf7nTfFurq<>l`Z``NwUk}K3D+Y(WoKM{lK z6iQX)DFG6v)q$DbGe7^f?BBU&Yr>zbiN%TUgF@GHaKgfAV*Zf>oQE|AbQ043|2vp4K%|+|*p`C*rMDW+6X)dp>!y`QJmMAVRj=|U{ zdz492E43fkTZ3MF@7TY8ZIAIlK1SSJAB-J{T3W|zsEG-e=!yx8WGi83IrKP!fd2`lP z!1m0jFj`PS4%|T%=;-KPP2Tr+x5|sBlD`VX)yK!jdvHs&KYvbVe}M)9l|Lpl0Bt7g zd7h3n-K=pC5)yKrffW@+*J4ym22+W8VVw<627mCuV1*jtBUAR(KWxlIt`?7`YF^gcXBc)+O%8RYtr}g zsEaltl$>ALv2=D;$2GsOP#p}D2o4TjJ%q!!0K*^w4wBbC-m}&FEO-D1xG=>ddHTqrWp;6Jn!F6IgET?L|RY9&{_>1x25`@)}zYh!?X!*wGlb})ZNHIDPkauRTDxoYH` zaaJCrz{74Odn_gC&X957ZfO@RVI-%!<@t1fO4x4wM+Bs)Bu8kaTrh5sjn#8K0kUvu z(7XRt&GurF$$ov16teuOp+8qGFI-^zPqd;4zfr>rlXwn&5#yd1X(~@dxZwV$WQo$e z7dE$E-WR_S)Klq&g@q+0oXbnlG;;Rq4>N$4Q1bw1T^r^<%3JAzp;%*f6B!B5Ngb~l zTMQ~Y(6~bw%6U7@FH%G-l;xJ^8b4ON&E83wS|t{}o~BaRYI5(tt(te+DaCQ@OyPyR zg-%)Ys^`xT_6@0##n0Ie3UC|Wc}57Gby1W_#~0L|-o?rH=-wY~Lv|ST>JVE*>H}1X zJ9h3^ohj#K*Xkl?T^t+XDmJsu_rE9_Y?r5+Zue~B_1w13o9=J-W~u@$VK=T9OMwUT z4cd;Ru0fMwsa8DNUS5r=eiY$d`XXIRBqSt&i%66I14Is<{oG?WwKvOMu&y~z3JHdf zR2f81JLOX|oph=!=i+kho@1d|yv6G1zndCli0xXgPAYY6npS@6JmvUJe!nDKccS3N z#kqkr!zs3qNo}R*dI@3o4e~nSoy9=-i1v6)oIE*!kaUaxlzldZ_T=$bX@vwYquxCE zU))t+M{#Uo_EUJid^li1&0Bn?NycMYecZ$1eO8v5!yXeCw|?ZZywAX2Q(Rn}o}OM= z$z${!87|5n1IBV5^qf8Gef|28sh+24@4g$wueTlQU^Tu-4KDKU(zxb9g(UR!-}%)t zA8%~hufKRHpsE%-Q}8yoa<`D(`(QjopDWeza6fmEpPXN!qmul&YL#ItSA^(E$8W{n zozvhu+SPW|BJAc2?T#oIQP^DX1nU<$^D%h{E(qlI81;>hb+k5LMw6kurY3b{z(!v! zTi$+swnNu$P1DWNk2?Y~EEzaR4gx9}URGwhzFm9Xe@JZ=gN_1m+YGv3*6 zGB&=AsS!vc7nN@>@IpKAgB9lLEkJLe&yFE1Z9 zLy?YIOHRo~3^>0wNh$--T(<2ld^kKJ)%7M!mqw|R!i=iH5Tdp;ne)6ej9B?+-t1bP z6C`JKH7<0ss7GY7x{iInaKDtTZY(lLg@K$ugKi>y+xg&i9;9KoW+UbLVkM$Tqam}tW4H5=Pp zu}Qn|M1Pa1L_+2#Uz@o&XD6B6f|-iX?VgGryI`kJxv-3*|zM_<25ykd-(LGh~J z^BkAttp-Z6!OnPrUm$tmGq9EXjfbyo*i6d(oZP&P=lzWITF%*PCMNLLdt{{Y!xPbH z^74H9I0y4NLnxK#-QD$p;`X0X|EB&I-@kvy4U|~6e$^_T*sTQ3hcD#j8Z|ve0X0L9 zkFS|OjARim9^5Z(d*AIWnYND3Y_=kmYe8Fttejl5gway-H#DR}DX1;PJ6ur|8XG|5SxuL_ zUIs6)fW#b#bZlDyVO?hJI_bT-z>LX=Aql)>_x+k&u1gOQ%#vgJ-?-(NJ%fW)QgEU`Kv3PMxV>6=g*Zp?wH0}t2tb8 zP&Vy^enAIOfKjagIla8R6e0&VqGebknE4cy-P~#^cUsn<01g{ECIQgv59^;8OjsL{ zInF|j%f}^B5-raa`FCJ8HwUh!fsYq|>geB{p7HY*IEMXl7?`aw3=rIQvS!!4KkMCL zOd9n=wLOQ9SGA&r7b&oE3 z&@;7<_er=+IARtuWSWAYxW7seStO;OO1y^iN0iaiuE7lDQ?=HUf7>dWcy=&>>;{y~ zp@wRE_j601is@dz(fX2O-_@?F@KU4*wo!Ofs=kIxMfrB+cMK8YeY-xSkLB2$u}CE-N-Tg=^rf6IGVkZPtE!E z+=Qdn>Wt5mXJVj9;}*mCfW%j^7rM6Q?jc7;PRU!(T;pf39BE%_p`h?DC630a(u_w@iv zVfAY0F#c6x?QCj8{ccv-28Y)&QR`PZ$gJ1(UdS;98{YZ{$XtA4qXUkR=YAMuwYJJy zr_O0_?kV$FuDfYDhKlpr^fe^~?|r3O6LD5$WBuyc^-G-`vd-Bf4|SAkjppka&#@EVt#s5F{u`)J6mq?; z7lsCHVDlC(tz`|$|E#+Rau2N8nMHH=x|%CB?p^;$`^WP%PoIJx3P*LT2nY!Hhu!>; z2c-qd#Da8A&R{G=M)SAPy-gyjIemAAigh>pqXRcrzGa0T%-z_#fYf1f%yS`e1)hlC z6%q4#@4o(pmklP^U9Cz)A0EoRWEV2Eq~=U1F}Ujpn<&z+b3B{0bM4{3NotoHFrV5A z}9V6z?mf|@RrfsTwzZy-Zs4rA=ig1m#)~S6> z{6&!}%V|KX(rJT`#j#pF+PFPQjl=2+dQ8nPWHY-enh?&NGzsrM>SHeA<#J0-KTKP?QsFpU_v?8pq%^Hwsp+OfbWr0MX{|CZ;R zHrBV_{k5M+O^RIlRKxAu70*?$>k9Rq;7qhE4pAmcN;`%XXK%cUATvLSK*J zN-VPHKX z${kd(pofLIM}9cHw5+U*epBRnw`~2$WUcDneoz>~!_+J*lY-1u;|NeRYI>?-xx*-2 zdo*bkF$>M7YuBAato0fS*o1pgpHoth%2+sC&#ySWz~KmQQO_xV_l~-tDAHoPAc)pVX$47ZU{)D_Vj$b&agLO*!lk9=L7q z);F}r3cucMolfZUj(3gT-CiT;C^txP&r}DljVU3^^)=FV^NQc1)0A9@}B`tk8^BH&>+jot5q4B6qM+>vJ@3Fmt&yY^ zT=zt3uzp9c!c3eRjibi-tQuVof~)Vgkc-1&?LALX4R}38(Q5=<@KAd;t5zfQ!c#8A z!+L63daXSqAWZn0NG>!dQZ0@0b~VFQ(QEwJ@+6a2_vFuP`_nkl5;Gm%i67Y?6H2}B zy&BSx%0f+I4&d2bxff?-e*_f98ijN7#^UC=?OQtR&MWI+K`|AMJL4@9xR4)of-Dk9 zev>f`l^mya17NiPhA@2x)waotLY-%?Sq1nI_)Esl6)1v&YyF_82tpyQkX0)_UoFYe zP=NdpB&)`_#sd!vp$8(N- zTR!q!3MjPH+i_4A05`w^(K(ufD7m3VQ$(q^2NHNGp5x^x^z=bguLXN)po{y-spLFn zkq-h=gLQfF-b7yge9VWjjsg(Ln@qr4(ucSIpE%|JE3XaDQy8C}&G&H`85sfM3IN%E zhhsMz>6UA@;o;>VS#q(*Uu{4Ph>Vk;qPqT{k(ccK>j2HB%*vTmR#!@Dt{6%XAW8Ef zo^JHTwlWqbqT#kX%eMY1x`&IcqRl|zWM<&{&1W=JaAGFN0T>&3d1TrJ3ZGLcJ#nVQ zhu#vqodzTo3j$#VKljOOpCB%�iRzoUNmWDXhHoGGzQbC5wUl+B5Bcpd?juyFa{wnyH&-0*) zlGI%PGWMWJfxm|4ZFoW9Mfk0@E}LP*&i`{27EZHWre$ch608hWwO-LTN~1N?MrJ?k z_MZm$KOvSQLF9tB9bL77*2Sq=*iU)(#WCAm)J+(LyuK>-4Y)hz-~h$pkaq0%_gFtydx&+eN2! z^gXS6nqOC7Kh+BzvpB_ge82vz#cGnDGRP&xsYFC>K#{zC{Uu!fEtC|vs;*NBbQuP^^t z9B3~uwgQHj=R4K#-5yqlQ}Nk~Nm`wePhV&%ORKA!sH?}J)<*QMeYLb48MWS0DnblF z3VI(?^*^QQ1r`M5Gc=_0^M2XrK1Af9mJPeuQzT$*syj(Lc_#O##1*2dyh7(HP9Xz} zs=39%$E#;7dm8%fxNH-URz4&rxrs(@buqiXxLKDl;DGMHa`|i<1M`XrO$7N+WIp&S zrKwHkH%XyS9-Ny_S^+B`)4aO^CH`XD>>P^VL<(S5IXPA)frep+%0yS+?sf6|Fxw*( zxtHExP8^tP&00{?RFTgM1CJMYI8_gBD1+aK6du{FipP}y9Wv!(O?V+Bb(Jr5^XB0o zRW{apbFF0f#GgAUuIU1tYYDIEuLaQ7|d8n;rdotp>MK zM)70B58CjH9!Mkf?s6l7j8_3AXq0=Qyu7@4?DG!tYt+=j(*d|eDcs!Lx7(1Ws}-ty zml1$tvHgn)aKbtHH;1iw3*Ps)2Ob-O_UnC`)wb!QTdHE(?FvmshQRlmmvJ$h@!f7Z zb_tUB;(NJ0uMnQsXTleYek$1tvD^SZ0+SjLxwh0q##Nh|wKyb^{hdf})05<4;r{Nt zFM*5KWix}zVTAA* zP^*!tQ7nqeXHhg>8j<5*V`heZ2Vewd`NH-5`EU8eI2rH7yrM>}?QZBr@#j`@+|Ka= zZ5C$c7ctX7vFdQCbxrX5_oqZ1^CwfLskiIAO*seNRm4Irhk!7wOt-mtg~}ANA|16? zc{|Oj4?_I;z}&o71zqb=%9KiW+s^4A1=H4|T87g{Gss^7$6xw&EVr5w;UaW@HPHkg zaGf%amrvreySX@u*Y~QimS;1%Bx<^7&yWnE(@BQ*FrClZM@;ODB~p4Kl&0JO=1o3D z=yKlufP0)nyYeIRAK(=8bo?x1d*E#{Ck#P5&>u-w-7tEQ)PuEn{l?9V5V}7LPBT`3 zBRcEuH`?F_ROct>3c&M0QwUkqlU&ymI?3((fc$}W&CalDI8$2AN`1K@X^*$*GE6q$ zU+Y>!DnRhroyS0KI(QpD3&n;s?G)A_Ic={!3;pOK8aV>X z2$dQT)7=9`-t@o+Os=u9KR3+Nr^;&P?6k;RmqV^NKPLxS*y+0$n9P~b)bEXaWNSJR0NN@zpr^A$k-JGP}=TA(Gjj?GIFBVkI!z>vbx32f=2KkIm z*8A1e)D(q|{CAs72Pt#jkR>|p&t4)cFTX4b6Sytn=$0&s8qO{^Qk#~8S?DnA^svSJ<3%`GHYyG5*@putQANGIV zID`uw%QPsnFJ?xFzyvd4FK$?Qr1ca}OaKD`MSUm$QCYs2Yy3pRt%#{b;FFc)a({a+ z*Z;{k&oyvlL|K!`eCXg@4s?eYRWu%~&W<$#m?W(nH9crIJ@1^uC}1*u+H3yG>1?d5 zF{qiUDGTlg!VPzUqBrT}u*5_bL7DK_Llg2ZWmBt3K<#u=Nt%M*QMZPfHn{gxX1`(4 z_vz{(lge)yJ>5o+niom*6+PWZpEk)jp=J%9P$p$#XODApF){f)8;A5s1Qc1-%%AlX zSaOt?tL%i8=~Dz8b4Rygp64nY0tVTg>4EZDkI7CoxcRvD_|M8tvhzF>_qIM@T2K<@ zi&)RqSI9)Nu(yOU*#XwJGL{E{sH^wUy?9=!j73w$Xjcnn09inAU>;UE#CQSb&Ha2$4xO*N?^rjeY zoVh+bIr%d-mj0-g`1rMw$7)wpX<8l0jy%DL z)DzEiAc@~;SEKt<0IxS5Rk<+^VeO@I{Jx`fH(wyScMaAV`7P)4TCIgDVfNWd&JEvc z9?ESaUB1H4H{x&W=jrr0yUx*->Ef^Jf!JU^`ulrR+$;*_h_ZIvv5mO+H`nM!AAnOI z6Tx@C962Qdw(XCKImD^oKOdkRn_Dfc8*|4&I-@C7n_Ff~LxHDnqb>RM8z*+xI_`wZOZ~;BDPo^^2_v?! zu*hQ0gQZ(EDaYgT*94y{jHOrcuI2^*_@<)XL-f-9eBbkWuS$Yv>(gA3zUY`&j5jhk zwEZLWGoWz%r$pd$TDr>4R6ag7cFXmru~9FD6vvJpw6kqd3MXJeA2TyozaI3yqg|6` zQj#Tvd`Ya2bIpq}viSNHQL0m5v;`156=vkOVa%M&yve!t3U!vx79g_w<(f1oT(C%KG+t z;T{>xJB8E9?^>zu4gtU8&bSHq{kw(x+l6Qa0WhygxD?byQu2HFGE|sFJ^zhOEc2Q? z?;HZ)3~vBu*Km6_(-}s_>xM9kEe5Qxu;Falet=)v)PSi_&v8r*S&>e4T_<5`?eWwA za7^kQBKw2hPB2TM%T`uAyeog9QKz6D0bqyI@$K!e4SI1|i{yGv0*B(uVRW2Kl}?GaDbp>8sVIVnQ{7y>Xq>5<&ApRlN8UhspaJ_guC(Aa|d3K zx7nPSR<5p>fZi}Iy@|rI1^Q^Xw^X|h5~&G`8XMRR6&3)*V>s|vv-3at z{pqH`6FT&%&$xNibXs5oPPhd&7-fT^dOUrm1cxAVCj(>iI%ZTq8WQ}hni@wVUBev5 zyupK$>bcHv@-!GD;lS2tw%q5#0l|YLz=2{7tg$$X&iSb-4*@}undZ}WAWiFAN1U9G zNT+pNQ~DDq?Q{`6C%9H~j-)z&%26NL)i<~#GFi#nx>P)~YyNBBE6L>LWWUB+sLh41 zrqw0me|1hL_4mmqfUynjk&+5yMbSY}tjqfP`j8)~8(ioK7o)@pJn0Ep9BMNCRC#NFXRUTkHv&&E8AK{gx!7~F%;;R)8vI1D zYss>ve)+U-;aQ1dGb5|oFSOejhM0?`NcK|AQRlqgeFGBWO(ru`3Y^S?2YP~)CLJ_; zcQz=-xYUXXEJiJ^>Vxzp|4+N#-q&tFdW=i{Nlg26Yg8I}dok&q7|_T6B!QCx?LbJ8 zSigagsUN8L5h_w6K+Oark6%tECMFu^)M(x}>Ag|vIZ%wgkC(2RDfD^qX{2-Cb`t0& z(bCddRLUur$Y~}Ws~swp;s|8@ITrT-!WxkMRKUOgC%t~~JnQ55rW;AhL7|h64{4Ox z#!d>>;YgO;IXWAFbRA0pL%u2>RgiO13DmBEuKmkVAw-Io1~d(T8iAYyh?aJmLjYzc zV%PaIy$#?WpiL+fXgl~amhQAS&0RVMgsj&srvO-ssV zc-I47bP#~h0Agv1W>f_Jzb}~nI$drKD1FfbM+69|3z`QqjOF$aU>jS2aMSgADI%x; zRFG+ywoOA8YzbPUUZlsirVk#5Vx0V5xZQV#K_UeA`7mdIoDkC*i>6`?87qKBtL;3% zq}l=XKV=lX44T;+*GArY5CHah`nS1Bdq4xh`K3oG(Djun1bCR!Qm4I&i2=ZyQcORZ zJ~;0k1qk)?pN8R4HjmT`w6Z235XgeZN&j<3g&3`)tav@<9PI}^7my)BUvysZJn!dS zxVb&>K5!6dt?KC!y;;Hou(6i8d5-KP5VQht$aow!hfW3r6;X3LSB7_hlz~5|0vSMd zCW=J2;L+;&8lTAt3{`YshFKpAB(_BFjv~mct3R*zB@{K?)xz~_c99WcT&^wllNFt7 z(5A&iVJs@ND`zz_kjhI#;R1-rCqRE!-!=(Zn?XRR(@>%ZVb3ia`uW8d=X92$gcWs!j@r<%1&E`7e}=KLem0H*m$q`#MpN z!+4TwfKP(C6=)|QV3H0aHGYt{H4rbp6FBeg6$w0Hhjv1U40^TALSrYeBEak4w>?+v za^o*};jizuMeo_Ae|H>@Kp<>9ceJ;M>S**I=>X1MS$m+rU-)d&&@sqmr6Y{W>tdj|HF+P$Ba%f->RCDM5OsVg6Z>WXg#78vE>eP0T%-joA{zz?nS}z-;)$6Q({F^R^*I90^+Zxhgj z0Q__K;g4Dj=LQ$&uE;2l(MkfXxCkK5(t|S&fEG+q=Y1W_gpQsWo2FZ&E&8HA1yt{B zSelAt2jDI&Svx0ROav3M(A|auF*pu5M5M8Qz-~Sc2P`@tfRk$g5(g*@yr50y$r!B> zFLU!PNaHc#i5q`5`7r?7$bs07g&A_8_Tc~hcQUYwrvNN^3~(>V%%K#%;CiwiM$Gvt zhpYZ<+T!~B0BLyopFSDn3vDFXQvzSVJlW6(vZW9rj<{9V4iZx#po#_b!@Xu^9=UFt zd@(@mKeclTsK2+zPC*qiTh@IS_fr^1%jxCfbR32y{T}1ZSKDQ(T6-fhskUwh1rO?x z9h^t)L{RY3(Z`X4^Tu;wejY|f(-AmW&}lcab&bITNX5xSy^t#DebUbh2;5B&04dV* z1R!Io%W}|vCutq{$N60+Xqa~p6L>4%*rI=$Sv#x1d(!!NjGc+>EC3xdE8cIHDYDzEvMcF%@UYp z#kv2*WF&r^;xGbkZ*SMGv<%`oIsg)am7ALzfG3chjg`FsoJ{US?>Q=-$i*Us^K1!_ zgV!(;3^d)H7O|9Le*cVA2AUDXpWc4)mhW+Eep*$ss`hnr^taL0C9K;!J6U3GtvI}0 zt^i%b7l|e7U`hfd;_E+-qY#+dnCZfxY5H>6m$IhkwH#;3IJ8zZUGX2BONj> zN1DKEL%D@ai$FtQ*dNgKtryTiU5WK(s05C>>mXpoLq@pBE`bgd0n)O8I*dG&&_HTd zGT3ZJItuSUoQ5f1i_9iLVv5rL(>VV=AN0{(2|?|lgLJuVPH#ZceR%#@h$kKoTx}bn z<1(75pTu~hB2sH^?OmAoj2^K93xI}DQTV21YL zFWuus|Bhj(pX5VXT#!esprV+4QvwT9#?TAoA|3j=uGqRiki*nVqm@xX1neeTbtOf z#lCbf-`-*==ZaDr#$aG!sGAm2l1~7}md?W@6ZRiVCz9n>TQ;le2t!0 zOg#I_NPs@}S!uAO|IN?QFX2n%dKYTLqrkH)xxBqI@h%%Y;2MW#XOm^Jp@Q7<5aqAS z?s8v~@Ljwse?iWOk$9#emNzLm{bLwHALqq%j|%z{sSo*ub~$0Aw{_~aw(zuWP5~Rk zM{hUM)u&>TM56+(lcl=)ayWqRGjSZz-O&%oqwta-#8Z&WkHTAwyQkNBdgIUhP|}=3 zxbbYepz!a_xKh|WjPr*GIZCN&f3D)-av1XJUr}C}w$x^vqldo5G)|(Q#X>Gf!1Y#KfH!~k3SMNB$f$|!Z?*;zp{JQNsbT{B^ULYS zd|xosm7qnzFZ{SiahWj!zE_4nLNlLzo!CWm2-gSs)D+q53b%S+VhXF-=Cv8*U?fH; z1N{jMf2lR_?@69eF=>5oH((RO4zv_Nlw-vau0R4G8L|PlG#Jw4wH}+e36bsEBnWCV zI8nK4NAaO#yn8*g9?RUbi9Z|=^F!k!#;f47BN?H!V-z${@5s``#tHq{B7Z$2BM)Fr8g=#nTuP4iV6+p+@HS_Ju)-T99>_SaGbxKk)*eoh#MATf^h^J~Y%4ft<9 zEc42Bt2JA`5x*}@8J9wL%_m|Q3RT&UO=L?srQU(ywHGKFAv40ialY$Q>nwkZ0|?fa zMn-dyn~4`BeKg~JdrrL!;J>hMK%e-mw{aiG_NnXWjk!~zt|C!i(`vF0r9;L_m!S!7c-amdnxIvEZ=K^=G;@-89 zr^%iq!z20m@d%a(Xwm8y#O<&v-_(g*q&ji|>qiCvB;uO$*Z7i%wGO6mGp6~n8eOoy z6oTxAx3{M+yQ~+<#J8Chhfjlvbk{grOeQ%?^JEt_Trnd$pBqw9=$sBf7mPOI4xYe5 zORpZ&aAbFmXLyo>9G)WCiH5ioUYQXs2?+@+tAeUnw%0-dB(36?esp-qy7XIHCnu-c zn*oEJSvz4mlk1yCoZyuBXS2DX+LnYnKO1&<&(jYs&HN;~34=-vo4EfU{v<^o!i+;I*>q>gwbZ zx-FP0?lYs~b$3fsX9a5<+d4W{2+7*Umf8ZH zf(wnFj-&dq_01s2RnvIdA2+`4=5OhLeaE1>5MFD?S?7t7-vY#bgsmUY#29HoXrQ6I zN2F_4TD8}*4Ula8oR0yFEID{`FS*ZAct(CjeOpuVwFUB>{ijg{`Mb#m--X?GJd z1nUr3gvG+3^fg`D#M~_?(KlRGOR(!(D(J6rWPJ0 zLxkH|4qPuD=$l9m1{g9i4my%*IK0h;SxqN(!nEx1m)MD&g1kHnGqa)5kzWzHMMd!- zh7vXw7UangEcJmuI>qr>3CY)!6{m!C3$-gPkK%S)yLm7frm+(4+FCR+kE!=FtT$7! zc{9A-C}zK@)nV5h7#y6KC?dce`$+V^_dOtbqFgB!1G|E&+BqgD#lLn`9SuznrS}7h zLSPJlA&BL+VwtQ&SmT5=2BnGzf@_HF1@=&b{Fgrb`}fbNT-GhrEHwfp10}5$d9ua+ zCf+A^+qVQh^zz_p*so$%7on8;dLcuqsu{LKqpW{{^ptX{akY7Ei$;mZxy89QGq1|` zHOS2PD3V&ir|+O^ZVF7@hg;%7O;6Xss6GS8Io@W1o}S*&yDVVh1Ztz;q;T<1P^73R zy(Ar|lHk2!L(zDNE4ce6R!BcB#IEi)-^i_b9m92n>9hPF4qPU>Epaw49VmVhV}s^G zfG6!kFB1a;F?{aZa_As#?@6&9d^%laqpz#0E5o~3&5`cgqUf6^8(?j0^&&XzZ_)lv z-b5lt>+FZASt>rwTH?Y)jl@A4+}2?&{murq4zL5kxQ#5<-(wX(gDYotJ3?FRxYz(3 zA%KOPB$L|&dbxq=3IzoPadGi3276U5^_N6Mas|pgO-t9(@W=VEP*chRX7O+0x!j2j zjp=%f^XvmI6nZGQ>4Q0j#VL&7&bzizR$#&Di2sDIjQLySI<7C_5R!K*+(4|Ci zm6JK5%)|Qn4)TO)CFQPSC03KSnc`<6zo8MO8NMMQ2EoK14$NU3OG#JP3b$D~;oqEJ ze*$v{yxJ>uVPTh7iA zn}(}2t*bf3%JXBSSKR(KIXr11?r!!fN>zRXq6k}1WGy7Lc%+H2z`qweN z3`aNK4di!9y@Yv%wG{r<&nT17b!Tqw#yBur!0i&AaF&R`EjR$G7e?DmnJ-v5t9t`_ zF?=Y)kjqJeJ+LsFr8s$bbn9_G7^vVaRTpLX9x0w5b9dp*X{LGW!!nfYZBnz={Q_ z>*%mfF&UZ8P4E^=6xS6gC4?q>ou)`}nYdzhH*{cwYYS}J^4?C!%uF@$78(tj7xqXA zs`JuDG(_;91tLyk<82><3F1(z+YAgV>0&`&-hkSqkcX5(M`~_-d8;9-f8Bn4Ao zq$&6xkS^Ua%r9yZu{jbgbaY6~tw6?i_TG;HS;c=d0w#kc^fURj!U+FXW{qiiIHwa) zr;IMnIMH|jcE0b5;nYo~$ryl5Ko4h;MUsgVh%Gq#mb_%a)cP!m8fXg0)@6@h<@LZ0@L55zy$l+l~G`j zdI9?9rU^xC3~QxaVS`tenHn6XBpXBE<5;4USz&KFL>t_#DEp;&IE-Ih zYGJW_Jua%55Tt?mFoQZ|tSzi#Um818TKabG9VDF#ygAlTN=8chR;z*rW9KM~kc8N$ z{lS3Wb&HA=+3ZvnpkZJStr(C1!;6TrCHpghWQCmD?I%{D*@T9>n`WNQ&+!W$Lrp`@ zYJ(8hVU;3U3yWswAHMFcN9B!KToFn8@~ zD@zt<-rL&RBJe!*nF03$GusL~n|Y}iS_L!a6}ZjsFIa%$tK4XRtmDfOT?u-OgV`Dt z#oElj)5N@q{z=l|W9{Iep({(Q?jKGz{j2jPCMKi>X=-l~gOJAbNUSEMtPMFVP~3YY zCzf~OiZ-Std=@((3Clm*n_+D402smDUCSVCH)}ZY=kA}!5WMg7YO-L>jejB6aB_iHAkWnhx;ZY$3WX-pJDU7(|l@wujW@hHAv%SRBR4TjsADRl%9GKABjjx`b4XWG{XW7)`QK~-lx3ZYJ z^c+LS{6jMn*@+csAZ(<<550mbn8j#2{)l~7VY46tZWlM71tN)%^lV#H2wc%M-nazLg-a|uhLbnJEooqMe zV72h&O5@vOY^B#kXEdcpq6(_%gMK_H;@H-4X7ImOo}|{LNalTDDgxg3nnWYGRse&M z(%2C@PC}?a74cN1Aa>r&)6?C7${?%rD=zLYwg~0ZG5u$MRv_e)i>A9w<-nYmo3^XxB$1ckYjzApP)!)M*@0=gY>};LSSp)*F}^IW3Y8pRp~C}eUos# zN@)Mg=bEC8==wTxYa|E^b#x8pbc{2Lit6)zQ}Bp1HEafQ$FuPN$*MrflBhG^(tsfw zKrSYB>;J>vdqzdobzP!`5=4SZ5Xq7yi%3q2NEA?VkR(xZ4n-7HK*>?b5-oBLMJNy? z=Tu~r3`NeF-o^92-Cy7P-O>H$c8?yT&p$X-r}jB}pS9PTYp%JlM?+Tq501530Xzs9 zU`@eZ>|}x~)N5M)Q8^wVv*xmBGhEL{+~|+BuZOYpdg4Q10q8|89JwISQ3BFgAhSdJ z*zlR`%|F%q?OJp^T3AkL9)y2~tszr@R8;erdLHbOJhwFb$+dh99+Hn~WdN4{3-sbl zmi5oW6cRhxDAcRH?H}_##6tVAreH?0lmO!>kJgr*lmWwqH0o$_-023&sq>SEC{4`+ zu%j^xh*??OXLXjajT&JNt}QahWJ%0eKSiDp;W>x&@f2K>MU0xY16jW0>se z0BiG4mnRTD$~7bcOqNKts?6EUsN4ozItD!^M_uryTrbALhYr1qj;kNiz+S)(@*?u` znG{KeRa!kkO;M!8tkjg;1v*wa$peA>3=A)35*B8}y-#H~kfMw(Q)Gh%7%L48@CAiU zEWh6C8`Nuh4t93wsep)%A(3vg^R<&rup~^)%-$N@)EsJI=1x4 zj9N?-em;0GA^$w#H+@f`YisSfV9_`OLMJPr{Ox~BPPXYLA`vfFcV|^^Gt5Bc55NQ? z<%Fcm!|1P+9Gz|IwoKUz;N7nM1=5eMN|d=0}^z#c^r z*Rdtj9qj*-KIFwwaC%_7TrSs|HUu6BoG^dSUPVa5)z-q|@#o$(`z4|1)i?MMGcSf) z5D5G`;PZZxm$L<&zQUhBBkaFf2kK9+jYS-P_kQS5i5{CWxin~u+MSPuBiga%_R_qr-VBdvRW>+gNy zdH)#l@gdrRk-W2XzTUxDT&Yn2SNI4v#j?lBijcCTVuU|(1%RKDY zDs5={;?M9jG<0-W=sBBQt6=xi%47+~$kcD7DAd(W5eEwbL&swfm3Wii+1c5k(DM-F zgHuY0V2cP^-CZq zTx)0lbP4(%Oc2m9($`Pf3}PQw{#C5x-MaWmYBp#YpO#vO?7e#NS&8)lv|i=;h`0B_ zN4PYZ=^h>*t#E&NcH7Pq`39PB85hiqnu-P`C#&V48sxc?5g{Yw@wLN|@;v=LpcMm8 zO)UV;Jk^)SR$~0vwo#Pl-}CvPe+cSope?(-0JMjC4V@`B8zY-TT!$SpxPdCo^(!vN zh#e2$fKQdx)mnXqR3#56={E@-b>!;61gIGCGv8IgJwnD_EaJn@tu_-TeWaK5jUk-r z+>ml0I|QJx$F^0vGzhe%zDaifzWf6v#+z+%sj1u*vmqh)PHhu+2i_HYw1-`Eet_!y z;s1Kb74lkfAIr~`{$Gv`f-8$Tsz|tY){cJNwKfa}o)cF*P^@|uZ^ML((sn(t4St)u zK7{b~4KAuotHfFb-uTG9eJlk!og*fGIB}wr3w^x#rO;YxK27A;%SOnxI+)nEk8rU2 z0dcJKbb8!I=-9FeC7r@wfyn9Z|nh`L)Duh6e8-e&`YTr6|z9 z0~=DIo~D}Gie)h%{$k-a=km(!$2#X4=PFI-dWTFEDm2i~)io`NCRvsV+i71l5!yS-OH zC&uE=^;)rU#&@lGTl!j6mEmA=YHAATX8;fG!O*$%aDTs{xOrf-bHTE(3iWjI1IS%; z91qcvQZN{pz-KA-EnQxJ)cUcJ=q-~8ji7xR3?_HO9iQSO6yh$@UwIj5CGhOQu)eE2p5 zPJ~|jnB`@?->ng|y_=NQz2BFRgQ`rc1)hiN9lgD1a7_k=kD`9^J8#vW^0ZQC15>LPeEsm&(mUTVLKH4z zChTw;+;B^TkD&E4OB%N!5ftdbf+#;jUSCmptKA^bxyxKJi~sWvY{Td18_!pYf5e0% z-DOQZk|QwgEnMennX;~lCF2uz3=if-x*r$=BwU5Xy6;SJ!Kd|Gvo`e>5Cyc| zGJOBEU)@F?AuKTA4<2xe0H~32GyV1y$K6;wr$xJXKm>B_sI5$i1}P{fW|m@5aQzz# zpmlLGFs@(A{hNOb!+2JT9wx+w?$azfClJ%07#SIvlEM{Et)elq5k0?YmQo@oS$zZ2 za=nTau6TtuwSgtWB4zeB8t;me zq?EGy$g7^@AK96mGC_iXv>vE*ybB6jD=LWmLht1`8-bd@qC)H(oC+grHg7iS$^-I+ zH*JQbWw5gK49b_N;2;+=Q!L11^uvNME&zfLyd_uFV*N%@iiuXmdoMH6^;vctVX839 z9M)0J5uQRzv9*hS!^>aCF` zm0w`jA3b8(33fWzmxX`Hv(q^~_@>NshDzk%2G*KX<=JRx`oZPt8;Mw6)K1lY-p3>Z zuEuKoiI+F8RTEoSo-3pmaRBKRW9+oTq&@zZjFb;eeHnb^g+r%#wASk3-Yy{HAUgr|pM^rpm@~FCT;U{(esNcc~K7T&zOV8x9Kiqc4p? z3a{(h!hV#+(khJxax{_vp~@B={QcA+O6Z_bjps)3?fceDQ=iq4J3 z>=m?>O-$%?=m7@=E>X<&KG&r6lfPwXljMy8uOAQbh!jofyc(Ay_9rVbvIwLnT>uld znB!kV@&)lBb|DNFd93#O^~3R3mchVREe%i$Fut=jEW-GN&BcHJuj8G>gaqePQo7LS z=AojLrK%q5_c+UPDiA|Cen82)8(sKHem@oyIHwys?wws-OeyMMBbqDbkX8H!e^df?V4=CC=$Q8(g4p0Zs}R)+$VKJ_LJ;R4{#EiM8{H|40r1s?5D zSblDlSQO2g91ZcRLqhv`BXxzcj2!<9={jt&*`i^eMg319kIbC2;&N->9P{-# z#@H%Xjoa+j2Z+cjWg&imk|zPBZ^^M6d$ghj2%*QA<>ypA-j)9b7{qavpw4rEk}q~y zOlU1l7}Hs7y@o7GRqA3J^N%-5H;zbTbt?rsJIw+0P>3#@oO<|-^-N|!$&_!^?6iH_ z^W2>%9~bU-C-RbU2V}%x3NF5!dDNwBrBca&36Lgk|L3zquU+YPq0w z@9O1P9*wK%>assqa>I|aEFR`J6_B&|YmWLc|M+{e$r<}kN1T1x38-ay1V*8==qSU> zM9sB)_CQ%aeP4nfTbb+3M^luHT_6H0t@!!#(L$2&#TtFFz6QjQ=^En;4AX*sW7i&y zmD7&EHygg=!ue)U6I2z7u3@Az;VV4WxRtS25YyulIaOWNs!`(!eY+CD24825bp?>w z;Q&MQ>H)Aoci0PZDB{{cW-Pb`?6|oHEEbnWtKzLsKVlp5p?`;iGxANOv)7Y5U72Lq z_zxcbP@v7z9RZ7hkr~DGO>RT;mp!oA06Q?n@yIPH9}{M7o>PvVAchOkMCu-V4^7Pwmp^KWOJ*`gMOdfgcFH24 zA2j+Q`2ntg&yltfvYv^mmwv7iuEW8qv|B7?ki04SymNOUyCaz=XZ9}i7LV!hjLjj) z7;674C{Pa`DACxgq)AQ;!h=wtncMwb(wxJq&TD4%MDW3Y{px<`Qx56?7hQvC6;18f zsS*H9O}`WMx-_X#aGZ^SF|QwZn5F^;n|Z`kb9n|1hd&#u%sJ}!K|=aVu8rkJh%EC- zE?6_l%}#$BF)~Q<6U7u5L5vgeAm4nY#QnKS18c;YAxU#jG$m|Q+xNw;C74H0_;;y8 z_b#jgYnAs~qB=@0>V~RH);P$L=Qcq&t{s!PAjCa?*RGd#EXp*{ zX2q?ktgK8=m+XtnY#aJ=uYaN`+{s2uPDXy=M(G&M9|nD3aYnSF;yZ!I_ZY?2rTL z3unTBj>+F_gnhGiF=_Q8^-78|Tb$KeKsS&>D-BHWSq8~s*rBLB& z7sq{T!DekIsab@DW$?Dn?2BpIv17C6IM!lXsg&ruPQ=6 z=7KjXr=C8Jl^Ne4j1pbP^*9AdllDu1 zaVqTPtd4&pf$U48&3OMsh-B8VkIUgus5?`L+=I=boIU`{Im^c7418&{3A53AjWN17 zyUqR-iiOI7dzX@Ym{bjjb?TJMJhw_}N=h}2rqR~fETBoyjsFUK1pvOK1=6BQx3C~- zU$2v3!WjWSpTf1rkZ(5}TcW{FH9Vk(n+0Hkh5ncfMt5R(j23B$Rg9pbU!097qNEQ)WTihX46EdY>^q|inu?zquV?jgePEUi zzmes6C9kORr7`Lw(|!4ST1Sepp8(}&obgPSC@$Hh=ubplyzGO~tqBY7X;``oL?e4Y z4{u@#LUqQHKI`bNr$^Was1HtrZM}VZ+Bb3h)PSxqfX@_QV;Pi-Y^n@100e-;g<(f) z)R~8qRd%zD_KNS{gK7pq4M??)QUi>XPm42^1esIh-C(ffem!UHiM!DHR=&h&`(wHR zib93`5mheO3K_A(MN|A<^hb<+rGM_hsYTwkVM^-sLRyzL^bP9>-D7TUZUA=f7JSqP zc%5L(3d*r2^9Dz-=9j7fYyyDpM@5)MTT-ja=UC`bX#~qH==zy#DZkyNd9hY5%t)zh ziZeYjE+~a)3bM&OfK2Qb0A|4lak-Xs+xD(csZtovw$QM%b5b%-$!zn&%GQ;pLARYE`9qI#sHu0gxRkBGm*T@6 ztp6-sbq%ehwzRfSvox|Dewfm+vW6x7Q?~9EZ5Ugz5iQj=}VO)Fh z1LGL1FrQy=XIO2o{7USvA|-F(X*sqyYHR`ulWr5>Jg2MkWeMizWq`uWFi$y$qsV+N zEKBVOyiu3;7wJp(g|i7#N4dFd#_vo>tq*KC5|=MWx`aDB&th-7$XF;UF)8=x1%)=Q z`tG8^Ft0og{d`|e&#<|ClW>r~#2r&H! zuX9jhA%s3~X*TT=I2;P_rBW67CSjw5t{m21^ECaVs>^9l zZ!bUea1CNjQk7Bvg(t9hpi`&KFrC|P2AzqAK%*j7?Ok42$oUaLnRd}sLnXw(4HLJ4 zf-vHwn6bb1)zh~1R5pW#q#;GZ7oDC0Iu40LcBKqDkvz5HGT$G@?FD4%ATq$jji)_hgjVGhYq@gEP)7~OsMTYk5WFTA-k7wfvR-Bqfr zq!c#%fvH>OYtLzES!wD1&)y|GD|avc`G;fj<27B}?A|_t9f8O)cYVV=IXw&&oJ-vHrol z8o8w9FX{c**vmB6-Ze}}{e$bp7Acz`pE+x1Ufx9z>@R%r`R%iIR6^~rt1O0SQ}e;L z8qq)xhSu%6dqv|GgH#SuRvF5rGcw`FrkMt$qpB2;G^VE{bP!Nl@xs3l(@spyB_UWq zwjvlXlYra0pI1u&G;m3-jg7F_gC|We)m4}`pr`^t?i$R3HV@n8mfzAF$On5uBW6A~ z{7A0x#TDmW&KKaJ(jeJP>Oqz7jVijEs0r@>nxvzC80mxPIFtz;HLqqrd%&V_)XOJb zI2a!Tz?sBEN(WyUF6x*qQ{K9+qWXizZ3IntTOG3(+z?0!p%?eFz=4~ptG4wj!#%eK zoeo*VHsmS$ z*$RIj0I@{Tm(!3*SbG*_n6;1ybO~w%T>lEpmB7o{ail4uLul_`>vco?^x1W;bj1w9 zxEKMSC0G|g(_pnO>6m)@#|hPC?TVH=5@(L)ZoQa8k~{p;uUDC=mNM{+kWb+{r{Mf4 z5}d*-rvhh?#qf(o8@H7T$@Bz|39SH2>NG8^MC6)A^=;gtp%aQv2FCsdvCxpbCxhH# zff!ooM4_L4*W2GC9iv*t9I_*tWRKAhJ7w*(EeyA-_f=l>Eo#<99&T>s&CDqyYF1TV ztxjc)bUU*M2N=YV75#=lIpxZr&)cSh3KE#GGZVbP_sx^JQlQ(E#rWJHYA9=v`gU?>79fpa zK}ON+o-~=;xLI_`p6a!Mc%#IFYnJu^^ycA+j%5#&))^gf6zeUMtyr29hEO)?`z8;- zZdzeHQ$`!1G?^#My5VicDU_AT1IVeh{DoTk-bmi7FVC770(8myta!fWdgo8Wa!D zI}QnKl?`Ssj&gcPt(|Bm2$NQ-dyw;YqBN1oYl%hiunhdk6iug8Tvi?=)!p!8Margs1p0RRmL5(pk@Vr;sHX;2qiTU!9+ zrTnQPDCogxoCA#kApNZAy+&3F3YN5q67pACBRm1dE^7XRFAeKCvOuY9o(OiAKM7mt z<0(3BZ zQ{B5@6-=OW_0QtJXgI#glqepj^A1p~Jx&zKwzHQRP(@B`xCS?=+;zq2kOz0ip46K1 zSM2@dP}QhtAX-xh_N-8qMb{LHaLic$@Fqp~R1LLTB#kimcV2}atSG?dR~fOkv{Y$Y z&oR!8&Do1QD%=WSbOOm!B?MF$z|AYKueV&%{YIszqon88i82KoiLU~vkqncsAc!-}CK;SAp`bU|7?4={(U z+_)Z4&uu)sEE0gDx@1_4119Cy&42GXuccb!XahoCiDB(Wi8*;dpt>hS;3uU7$Or=}JRm>^H-TaZ8de>b*GGm{TC);>8#({X&Fi4u`^}s9# z_88^x<5slL-kavTVvaMfhLqS3fZNfkuy5+a0Fk}UG(~)2ys6NkCRrp3SabqUpH@$A zf=_+@@}+wt?}gb1(2zz9z1y+=5c3Y0WP8_?a1 z-JcvDX5<;0-)UDsZPw;ehdAfr^|E>Vee+geY}DKPMP-yHM%v`{(C(x)n*jDP;J<-b z9f?x>BnrdX96Zs+ajcQlPOIgHWHMiSqvRp7+G=V5A)Oo>dzAG@ZAIUzYI1R60n`~S zIAK9#)vu>9T6TCiKtZlX{dO_fU~+50L=pxvn1nG+h~9jbiw1Ba28q^ClVLUfJ;IhV zf20a)#MYm~5836$KuepqT*mM}+g2-&TE~W^`;0tdR)7u!Wdpj{Nd^7>K=uvcn{44k z0f6P`C7!C!U8Cmmw4mr(f76q zcVo6=4_n4_nsA+E-C5YhX$3GiB2?@t0q}!YHhI%jA9sYPn0Ju(=jn}SegEVdzhI(& zUgzMCmdb70J-=>{Ll15s%5?%w8MeFnH6Z;N*Uz7uF;ZQ*8@1yO7@~|Vm7KNmkkJ9; z)?Ty^Mq18wQg}vMzCm~g{XQ8R8&5t#Cp^9MQ!@=b;q_%X+F(&!=itTk+4c#PTnqn5 zmDku6?_j$K7_$B~Gc~PwTL^H9B4pzIOqo!Av;C2xEP;;F7_a2#rPgJWYRj)I@F6Sj zt|zYxOESNQnOy5eLEF3o)+c=83=3Ui2=UIaX}< zjbb-&A^X+SSA8PZ_9>RMMUxqWWoITLHFf@*F!Z&)blUUX6t^qY6yAqbC3U5p z$us7S5gDJ&G;C&fIxd-uIGq}U?#W=eMbA?nZCzS;zEls85h^{qLtBUx*qq+&qi$$8 z$!%!bC^?{ZvM#Z5`=}|jK(DBnCgx%~=^7KSDNubOKVBM0<2f9O6?nIF)1(^ChqFT zYyZy8+k5FJaVrUCio2EDOV8L_@e6FTJ1;-k{3&5MKt+GwwEOw~)t!#Q%`Y3e;*XSR zp$p&WY8rMoQqPY9HrA@&1QHx~I#{BBSWlSH-|dFouK3#BizD&A2QJ;;8qS%qA$niz zr~v5fw&OW%>kE~Jw#>>5?tR9m9+Q||bzUI3L-B+I>6SLJIFx7|cKHH_u-k2kWS??W z;6)Z0m3a9h3En*SM!yDrP4xgxR^%o39e2FXG(zQeKH0no_x|?FRh-Veo2HYE11HTm z%Odj)^3{J_I{9kreB{>2$4^e^r~{^53^tDz2xu>3=aTZf&y8g8H|(PMLdAjijqA0A zX6Sz9oc>$vA*Sz}8G6~en_Mei@H3A9Ka4cu`^FyXE?Zm0N3%puF+HUyBhfN&>7J^cY}!m@Uz1g4Gp&KV!eF!D_S4Ekjjie0S#GOR*+x*|e(gPN z8ao}*x^&B()Lz!kNBeruP-*>b@9x?X7>rCA@@(P%w?2&T<6++{nWnUubxxAQeHj4QYl6w#-> z{gT(f#D7%Q`%j#%hjjSyIKa1K*tXI|7w6#aoSoDHWuY{jKgh}l;0r{*W;4M>#cm)f ze}i2k;%*T#2mBqedtAYZ4chR1d@8^m9*B4aHXfCP$$6`62o*Kxj+id0k4QzlM*lbV zJa_5flr$X#GqRGZ(a7f6TyO!HjE`Kj!3fDac}++Jet6sR-pSAgeE|l1eY6JDAKG-y=(-hR*e2@h3rO2rQxvSpe2-6nP;f+{|5V8c>v zMKH=8&7!v#RO^gTaKr{|6lx$auv?7&nvDECmG8GmSVXx3c@-{KHG+bf`yoV&M<;72 z!U!Lcw;7UxrPDn{MQ)&HCc1&w~ejw^f1HyT`H|JJ2hwp|dwO&`*A z9{~xa!69lw^XwioWd#M*6|Y^qZzOb6h0JzOUg+}i`WiWxTcSgQyQ!J8`Ri|M#gg|Y zO&2t}2;wKX1Pp#K{Nq5juv8d)vi;Sw_2^m!6MVYIOml^K>O2gDcz!d6ZDG`C8N^DP zNpT=42iR;t@Yky)=-zUQ=jJya<9$LP;GHPp+i(N)4zxI#3avh&W(xga18D~OTsU`x z-qJ-)BwXB1PRq=cs!)FMq8WVs97s%`{W7Zuy#j&q`3aD7jpNYXMQbhs0leG-kSHdB zG)|zK96G0F`jF2Dk`j3PVLbe4IWOj9HeK^0@V96Y(sM*zQ*5V&ILe1bF~J!~fTOh$ ziJZ~uh(NaV^^I-NEjC*z83;aL0J#Xu*;=>W#iIs;Ja|;zZt9(@<3SiPbBbG6Vk{fh z2ok9*=G5ubCzjFw3A`F(cWANlWXPIZ?K0t~qll zt+&nL9sa;~;Es>i-;SHfY~singxF<*=UTm$Idyc7ii)f2;qeZ!&t9W9P~8K)8h=Q} zgH?0gEBO3`05Q2hBkD7;uAQOaD5_pt6=UnQv@^(H_kDOKyQv-&&!hDunM`NU>e);C zR$5iI$&<+~Zgn?If2VdvLVN#%e~meqsujBw}C4z3ImgwCZX+x_se;eonmuy z2BOFF8PkbZ4Z9z*zHT{-dR@J2o|7?jo1TGBflXF%04TCdL+u(&OZt|5|6DTd7M*~e zxR0F0f%)5ZQowBwk?s=#egx9!R9J$7&@=@>^_k-6zEr1)1loFp%N0$<`Kjk&7*!VNtAG$TYuYfc_n5rd zbi;1iN&sZOk{_4bPA+>|jrhs!-PdzjYrH7pYFO->L=E%hTxr8H$V5*P?e5&UQ{9`+ zqq_VSDDi9MS=-y;IITU_=;ou(XhO)5=ZEqnSN93#Z@oI=i*>JrJ6XtQPz z%biyGQ+dP6+4bK%2YYL_t&Z;W6-0+hHQKzZB!VR+O4NgeD?5}l4aSVr_ zH!=>T;C~uNMmlk6&o5>rCbl%Zm38c|Y-VXz-9bdY!f2 z@B70_7D`Jr$jo+Jp6fhLP$gdL*^+byBoZ5F!wPiv_9!>UUuTz<<(}#ljY<+hLdoQ7T@(WMFJbdh8 zuCJ)}iooNv-8g*8LEmWgg}_v)qeAjpq`v0{p9i{W!M%pnzk#&+HI7+e3fV_&&-wym zYnneF5O^8XyLm3|;L&)kmBdyhd9@4lxTN~3xvd=_JlDU^a#PiKEY5Ff=6+BmoAubd z$I$0(zd>nd>m{giO5nJ0vXb04(rAp02^ZR!Z5ZLX=)Lkjhk53ViZ!gnY3^}d{$}8o z@J61jebH_Nx6RUjxY2QS>|X^d!$+9 zvvfOY&^fYg8yLSBui{F3v8`MGU*U=kiAhGO&SLFWUs!lodd^rA@p5m9qsb58sBO&@+kTz@I4&V-_g2oRqhx5g zmpXNCRRra_-;G&k+I8PU6o|<;3tarck#H9X0n@@;eRPG|#A8koHkvEn;+~c8naMXi zGjH>YZn!9jyhrhHnZ{w_x51rqBQ6T)?&G7%f-^k_C4;JqRa(Ww$2-FvWZcl4t(Tgq zLQl3YTsH@n4V+cdNOWtxr*+SizwvIJqxTPB{_^)uefB3j0|}vvr{_JI;u3Sa9?PM$ z{Pur%h8o-k`up@feZx@^Uqc#{KjOLC_}cU=zu>NQn5mM!FBI$aNA?8K*;CX|)NnbS zmqWu_Y%zB1gb>lMzpC1(I6Yh+r#)6|)_!U|+$hUgIy5xY(Wv(scopz)hE{&LHq(Po z#w_K8X0amfQzcA!3T}WWQWVJx`c~g|(&?RR=j^uERQE_vV$Cqz9vcnWU}Z0I#7}ur znYoyy5SH|q-u&fv%$;v!(doBu-v-@JCj0v%qN6i_C&$$!-gu3_gi_%iNH@3wpZ#8SouVw^nQyVs2jn4*%QExk+;zf8~ zEysFZ9prUopLc5UIe(+k?5b|JI1^;hE3UOgh`9aPeya+bP_HuIn&}gVi}!!2J%B6E z7%YqWtc{S_Ty1n(DIw|$w+5Cg%D)n_kh||}xam9pb?#^cY#TFI*GkD{^k&3g^NHb9 zWHcrg>xoT^FC8_mc-QL^xmAJFU}!POW8yX@^4pxE`>cT5YMQtVcPca)W5~XmYfAlu z|IjAEse|sl$I5%nE3+NY%`zcxmzva#wnFA5d}69041VWGwwudRu}n$k%`8S+x9)j= zek2{n(MRpCz7h%}K8H(q3Y*@BCl1D&2~QrMhv*l%?buYQX=)l&ZL?&~D!nAO_UaYRf)cq%ai}!+H<^;#v;a$iL>v7Y`C7U1IvF+m7$;yfR&RckW=j_J` zg<_K?$PX+@Zgrjq&B}|JKL%{Vb{D~YE1an=t3cnD2aFTgdYmqcjZTUOlPG@dsWxj67 zZs=*rasbHVW`WE>5bZT}q4(Dwb1$u^8Su zUuYt$M8f#jo$Qw*XKQSBPF%vhwm8N;I*;NI2CEt`S-7js|L)LoU2J9s=O`sy2BeBz zni>9CzMutNPpepzMO}VXHC*g%w7nJE%1nQWcbaz$Xu{)M*ocd12G5kkKrAV~@XlF!MFD3T0}+V?!0(?u3tdD5>6 zzzkZCjb4J{Ekb%oTfYa!*xUrU9}0y6O5GE{CGE4VQce=w$uH}r;57>w_LKk(rOy4K$`10TZ6u_-f~|QQ?BrcLM^reL=On7}YQC`h5tv)yQ+# zPee}SX0d(kG`cAa7#58K3NUc%?Egw87SvPgqvOOU>TS7sd5I29HJVrUO&-h1sqL4T zf6(MP^!T1ZHW@eWG)i7FUm>o}5Fi5Z0O9v;(R(1J=7(`JlSCBM?x9<4g#9xdIN5*y z>w%bmxX@=7mPoU+vRIvQH_#;S(=?;Ww`2H6xDAilHk>E=XATgOH0vI_} zB3UxT4p=~u*DnQK%{-l-oh_Y}3&JS129QSb%X~~g1oHAgLl%&V$N}}ssLbU`Guj6+ zJHPYv`fD=)6naPpG`4TY(a}wj4_~XsK%fcNs>T0=Y!Iy*=+rLAKi~rV0UZBJbo>89 z{^3%>)9uErhDXlVs|r&EeMXV6q`P>5LD^#*Jc&P^a=pc;CKCQr8SrBWbK={~=*dv) zD!!74;!1K0?~B37R{Rzt;lpHo$@omu$Evcziof34-!>NDd9}Yb+?ng{5o-W|zigl+}rg_np))i%Fr67@`M$!9nQb5z}J`@jdkMM!x;7 zloYPDg+;sPXnT85T1y;I{PoZPk2{EgpELotY%G)Eod^r-fXU)zypX8I` zgh<(}M%Kn2!5$KDD&KwcOj%iZ=hs)Pmr>EltaG<~MYm-fHm7QI3N&6j0v2+PQgXs- zXnVG#199s0C0_D;<#b{jp3WsU$`I7ke;x_$!aD>*@QXXP;rSaXDykNk>hq2}Z357G zpU+{CF||jRX614+Ev`x>LB3r{Ur64Ml-0g@;Mdm7)h`vJWfJ%k3-A6d6|AJI%5m$l zw4oVd>$>c@18B0+^7i&)nwAYl?l>-9US0+U1{M~aJD4zJH{WUyQEswKlfegVvSn~T zu)RR>V19XdBM5VP3%!UGuF5^@*W<*mUgZTTni?8HU=Z^uK;fA45YpZrTVyn66-;rxHh=b-)&r}m}j8BxBcU`uTyH$IYU3VK8CVq_l;%ytBeH` zvutmjpX}Yk-_F{3g>}s3#n3=D8-~9Pq~48`Fk!>5o`Mk;T#r!dZWVfjhk@d({PMZz z5n}o`GlyB_%8%ubRlhq8v0>wi$HO5P>=Lh4yfqu<(Qone8}0Koz70j;^p7^nZ?-iJ zU4Q48D!sgY`9t|UG|JLFO->3I)0fVD=Np?*nUW`j(1z{U!QS2;^j@OpF;sdik?#i^ zD;EGmsJkA*I8iG4){XPz3Yk+Qea!NW78CNEjgK8eaA3lN znSBKNobinK-kZT@2iNpilE)IFr#Y*!ejB23lmzLa_+b8hyWhK}5v06h!}eH4MQA(MNhrBjnZfE5rtf`37HdII3b2jG#>E{1oQ9vDp8_BIIqVCm;q~`bau{U7ycXFFgrNH?=za~U z7whKyi8hO!vF<9bbJ62LAEc#P8~~`91=H)h^VNU@zcC;^onACJ9y5y(8(b-U1?q<6 zESeZD4Ei`PetD{j5bpOFWFZReI~m7Dy-3SjzKPmVCA5RO>i?Z%=c;$z znF{Vr8iH=m8nmW|#_FHL5Y+{Y=1 zzG2)ugFLD;+~|XESBex0n15x=aNe?=n3)(DH+2en$)4ag93S|0X3TuNCTpi7jHXrw zVe#JO;&kHnP|M^x{@!83#cAHwe)0qRGpWmiuB*0@J)%9=wwZZ5#N}>cW%LV%gem_h z_M(gZcg*8uVIOEGe>`sxB!n z-0Q|Je_C1Uul6}^HdJm96l{thk=%EvPOU<}hP;cK`Xck7 zD-Rh{RZ;#$Zg9z79xgdLgT@2GrzFQMnIK)+*#3#LUUw!Wf&L zoGD$r6#E&3dy&4I3qR$HUE+0sRFujjq*$XQ3-W(@X>SC$sAv7X`RTsKuytG^{TDHuu%C=6bU3ePUykYPIu{m}2cI!=$kujN;!|fcP_L>a!HkiFHqgt6aWHotw+Fmt5uvr(UsGu6Ba$jpi*Mt zag5-8ojN1ai}6kZQ!Sq4!%?T3#e+leq4d0|y6oKY*Fl{Vr@4plQN1`30d*3%NaAJ@ zN=v0`xS{Nz(Wu1Yy}FIJ&8OM|)3dPY`-z1n;|T``9}Z`$iY{klTE3`%u4FlKWBtq& zo3}=r|Ac0yzW%+YZoEo)dA85R7ufySvitf@7m^%}krJ-;ZnilDZ8(VOvuv$8!Lwt} zts{>Lte2X4ni^4)SBF-C@UWy^(>}yHoEL$&IU^yy7d7N%+Hld2Q~6xcr^I8`g{iga z&gC|RxL}N)`#?@y9A|H;q|rF?YDT2Zd+(f}JMG?Q_AV}z*!JNhOCkIZeRGX!&o|GV zPyxLV*Mrl)#gKK8%g(Q!p3C}H6wtGJ;tTqmF|U(BE3R#7kG(r~KJLSj;%={WkGvVY2Q5pfhoVLzZd9UT8Q&5MAFekCgHUKRFN|{iyN^@ zEsx9hc(*mfG8#Xri!7KaE<$piCnqM3j*7kGDYW#l-5N@a556GCLssZa)9ZU379P%? zH!*i3XI1Oh4;5Va70&DoPu$W_pQV{1hD>9&^;8bm)Aes> z_T}ebk}C< z`8tRaS6I$PB)kH}|5Q7q%Mw`x>({Qddtb62OupbmyK&N}UVJ>13NM#UI`*FI^1MS; z`u=NS{bm%s;w&Z3tSGe7c55$XO=zJ%u}j>^C35pvq)%e@@|>OjjaNrb5!dLUu7S_u z3sW|vckQXog`~k#0o_VsMI{HLIJ>m1a`Si5JNQ%^Yh6zbJVtVQpl2dQ=ZBvTE05}1 z6}YbU-vyxTm%GF+nu*_9-d5Gs)m2m5FH61Pc-qqd(i%k{8^fF8w<`h4&81arc(69& zuX0nF26(r(SIf#>$G&b(uvzJLp+=9K3v8#2OEeSklWUKT9>j^gMU-|keuTMt+aNnP z3T#G2tmI#crnR2=AeQ{uZ5v8&ks^iS^)6uZ+%$rY!#<{ez2Y}E<{N6_{{{)s`c`ry z3fHA(n=-ZzgvjoP6OY#&9uXc0;xi#hmAZZh%5UKW>IucG$6;|z_0(K`R;|C<>JzK) zRgN)9cJCW4;)EZ3{>@x}_q5Bd?!S=t)^AyU+t(;v64H%?NSAafNQra{(h}0$h;)N= zgLHR;bV+x2cOxBV!DsLNe&6@q=llcbxh{W^=Z+MW%KX%$M$|tN=^->^wiK=?gt6hVBVoE#g2t;w8p#8qjEwf_HSbcLT>YofntUn zFrQ9&yyTd8T!<0O9=0mGvP?KEG!L7JzUMVv9{SZw{#EBxB4=pmtD3y6arlf)<|1bF z>F`T)UNQ1s@o3?B;o?TItFHR5S9V!Fiy9V!>BZDZb(#}9*FhzXe|a8Z4}xCIU%0uG za4a}>v)i0Bl&T)NU1TXx5eZaCX6T^RM~XUit#T|{_Jur{L*Z&HA1vt| zD7dan%ub`+{w7Hq`R0|CVVlf>89&F`DqGY;e>Wa7#duNk_`)_67cHr`{_n*e?%Gth zN-qA@LV;VL*t(&lQ67!=+yMy@5g#zpdNPFtah8(6%;Y4%AL>gTK&-V047yHFTD{;K z8X91;kd;A_Lv2?Js9s(A623%F5Qh=Hns2*4VKUp3lNuU&hlv2)kB=j*-x?sY$ku4JbLeHq98=E+!%RPdiyVfvlW^Bl5 zjm!0-M$2jCAWa1OlLZ@L_L;@+&7`Aoqb?^Y*2CZW+hO-J-Y8aFiQBYC zQ_VKz;Lvz4DHpi^YTTuCIAImB7QJ)LFlzfg8hkxoIU&x<7O%EqdKBFt;f}>rVg=+iy_uYrF-C8673uZ6 zvb75RLtnew6_{mEh0K)68WV-bKN*_BI<-yf$FLCiSm)h8r=ybeA%eXv$SLXO-oBbsLb+FmqqKK$R=Anu)vZq;6c`=V%z! zvvJvRdk-3KwH+#^J#knAIEm%7!l!>=V5?kCab>7mlIdLueGpy-2(RQhM zrC_LH|M_F9|G~gu%`Hl-+pZ@O%vIRCBmTv|-CJ7k^lLKOfA?<>l_{0ITAH4#MtWy7tk||<4feL#2lHM&Nej|g*GKBA|(+l+$o9mNJm zi}jtQT-ng#S#O`%`P7oF&*S&;CNdYT=CP%B?j1SY`g;TYgKT(P8^?vva-PfRWayNQ zVz1oJfeF}eVAn{|qskk?hSrTUco!gB`YR_hYxL10oGZa(h;%&dvm6Mn+shiK{+c~wgxp#M; zc+gx4bGU6Yfb!|nSE?2IGds@tJGHG>vyV%!plbx{_p=L1P#R1pIBi#rQa=UPE;`vA z@fxPm*WRVfecF`d7dCoP(tPba(8{T`c$@M&Yj~hWvyAyC2J2>mMMKZ!sfoqI=2Qus zxtHQa-5%5$a9$f8>;CP8bN!Qf?aG*;1r#6Ou={Eh7b|H@-{i^TVrfrP zzt>UYahnVIlxsJ!N}?)VXa>N@mh}oIZh)`r<>e)OYR}M+R)f9SHuDHT$2{dl)r@XQ zC9L@Q`GF<_2kWD>Js(^^*^DXZ!-#qFr=_LEq3J9cV4>7UPRkzf-;cdsEu*DyF?H6L z*lj)B6BF}u=+J+zH&rq_IBgk8AFXo{qf4ndpB5R3=-o}2#coaPrd?>LVKcR8r|Y70 z5U9=jUdP=Q|J^~$rSF5f8Uu&l_mKhi#L1~(1@9e`xk_h0r?G1NUXn*#wR%&M`}A^s zx~Xwxcmb9}qMPRS`Vy-#3YU6j3d-mz!YLIozF{RE2lLr+>*Z2t{1WH;_9{bBi@!_6 zH9CF8=4Sc}n?MU^=)i7nQKf2z8a7#*Tlm{}BB5vD%m#yy^CC~`s3Yf-?jW{QESKq0 zrqMIlr2P8IIIMl&Y__sz$M)ga?_JcpRRo>O!v)YevXyhWo9R1%MSuunBkakF)Rsr% z!w0W8mWpz@B*v0o;*KZ)mgEc(wE;9rDV5w`lzVh{N45mK0`0&LEK0>QHd7Ab4GQbB{#dTAW(C?V5O-*xLY$hEpE+kG?P1ADlH(u6t8J8Khe<)h;=((t> z;``QYvS-7~8CA(&p21p*U~aNke>-((9aZwp+4(4lNnq;tH<3EeZQ>HEZ~*4MA*)Oe znk|_z)g*>`m%``t_WZsOAsY*1+49lAJ-tYy!G5mVOxGG&f5MJk2|Fvht!=ayyNXUC z%KVeIVrE-lv>jb+Y~qU-Qu=)+Q}lG@4t8+lrWRr*emUvpQc5(Mbrnt3{fm>vh-fJi zLn-_Vo7iaS_G(7Og+&FU4poOWqT($NcLfXuY=pL1N_OlM9{4OOg`hUQc(2TsZe+7` zXtaJwQ(4`?Fg~x;_Bx>+$%FevGvln8naRN@Q$a})8*V}`%ZJUwNq&uouv;s-fYBWx5^qZ78Dj2lrVn!jT-d?#+5z6b4o1nlC9EG3LDkx z4f{McpTq6@U+^n;$A>Fo7i4vt(Y|vzlhpfICs$@a8+D9!eoi1EF?db{G9=Cy>Nyhv zn{?6bW*Tj&?n7?%L$fn<{h!)W1q@NK)eXaR=|9WcZt_a>N|_gQZqLqO{2L1(P%cCX zHZ^6`vNf=m(x-%TF|2VH|Ei#WfV5D2YmsNXov(`dr?xSsn$ckan9E36p+2kz$eifMOdf5c`Oar84x8J4SGBp4jd+?=)B$O|rg3X(BQB zF-hWb(AW3gmp3uLt&2jy0hOPmlL5K4A#uCXxp6RKnH&8rdrkMs%h{iVw?-@qBn^*m z-tr{pl{eLu3#>N=E|@VBRua`m`TIw712{|{6q1*zu7?>^ToW!`iEa~Grr&kHd$2l2 zQPHrQ0Is4iS-@$4Je_6U?O<-oe7dOU+pLsi;RAKl6+)QtJFT`BaD=aL zqVlI<6a2?FJzp@^Zp5f}WY!fm5Gt2^s5YHfvyt8wrm=6|eSfoXXGK4mAR(Dvb7rcd z*HAT9%o>e1B-O&xIAbd7TxhtOr@L2KcQ2EC+ZkBZs1T(XlSE8RP_;Q=qEbV8Z(dYF z&9_8E5KJk;(@@p@-Cj4ju}u}@ffxkl+tn9IJLw124zHAv4Juf4Ti9b%V*CgB%_jXb z?4A&2%2#02B+Nmzc2-ta+8QT0g!@HBDvH|>@P7}f>15+}IBN2(XTTXt*vw8iwGlFO z*z>KLbLpMpwBX;`6sk|}ZJ@1;nS_3b%e6gp&+c|S3(U%H$((iII50&E_ES<-)-bC+ zKnxD`*;1d2DvzGny08`fYF0u0YHO+KqRWo(f>4>wY|e5}!>=p(gM!oFfvyz(+k0rP zh9Y=1hS@;uXb%j_)U2v+e*I;>5&64sW#)?QA0af|G>TjbH}xmh=6`-P%r7jWIr#qd zV7ALGxiOG%!py|QL_yDlO3VYmrRnP(PfW{vc|`?qp>aQIy!?JUx6}k|<=Zs*9gmmw z!uT)1yK!rMy#q|hqP2LA#BM#8^+k1(aG4#8?^ior8*nCIFIu3u;GA!K>`#UYIy7Fa zOPl}2%CI#3WxC#NY(YxdY^OY7U_hyycCXB?@~~V{S^4&;!i2iun=#|;NNv4!a=T(( zda;w6{&b`5LL*L)yjsP&=|ffJ;n4|KM`VG8ySzXKgVy7DwegyxjVd(~9S57m;zDI( zcKMuM@t3l^x_UlKKdqWZ8wZnuy5FNDyqy7$5XG$A#O zzO%~R3XInkD0EBqh+ zqd$hLvOiD!cqWmoqI_IBRz>zTNZi5+NLK{_?akD9KI(S4P-DFz!B=6kCGEYEXkP#E zHGCMgnzFiP+T=O!L7RjQC@|D4;Y{;{+=REG6wg{!Sd{!X3A2j37+FBkUa4*2FWep? zD7@=Yd%LIgsD}9iz9;f~t8D#mXD9rlT0**b7|(aWdxI-^uw!S;o7<}H3yb~loUd4^ zwz134L`{E8$GfAKllcmeGH{dxAgLEO1FSi0o`xz7at@zmF0kX>f!R3>{`mN~Cz7f?C5+XTcN5Yk z0<=LFZ!Aq)!~B6+@egN({lr=6saNe%K8Su~M{WVksVgY$we&u)PqhhK1wO|sq-z}k zn5cfxOr2+VFP3~PIxbh|Sl_;7rt%zrF-I_f79!hUDs1u;)R3Zg6afaUL%B%zW0{;h zO2ZQ|B~MFD&Gr563fMFYrLy56a}8SZBy(*ZJA+0VYHt}BP~u^_??p4h4R4gtum@{V zwi9HZ$KQZyG}a}_7^ zC&0Ks0!|q{Pf+;>sXTkNjr^-><3LvX6tZVt?0?a3!ZaR`fVz76$U{9H@$|`pdEykF zJ`#ve59{gk->L5Zo6r0I(FuQ&f!BG`|4T9-plfSu>*(mnrv3Hn>vOH&3E(lu#Nga8 z{eoQ65@ds=AQK(J@PNa=!F%lOim_MTUORv3X=J>f!aV(k;t=tJgYTV!^qA)WxWr;c zn1J(&4=&K9Mx$%-Tdj70U0zvBi^<;rJ9O8w+<=Rel~n;oNJMo$+Tz8FF2~Uw+toj4 zscp^3Bv>!BAQFDw;d-_zWsLYǦQ)~^DKK)eVRcDA))L|x-kE$+|h+rxpsA*V5o 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%0AR`!xE}pJ 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 HKJtG6ZlJ3Y literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/original-filament.png b/docs/u1-webui/02-device-page/screenshots/original-filament.png new file mode 100644 index 0000000000000000000000000000000000000000..e63ea10461758e1965584cfcc0f9f8e3677aeb21 GIT binary patch literal 33496 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 A)Bpeg literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/original-reference.png b/docs/u1-webui/02-device-page/screenshots/original-reference.png new file mode 100644 index 0000000000000000000000000000000000000000..f818217933677b39efb6f889fcbe2a42dc1deb66 GIT binary patch literal 47146 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

DNv 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�F6H?T;FY!I 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 Xse%_G8%rwBt$v}Z{j6O1P3Zps2Mts- literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/reconstruction-filament.png b/docs/u1-webui/02-device-page/screenshots/reconstruction-filament.png new file mode 100644 index 0000000000000000000000000000000000000000..c37749cb8465b965550f54dda10b7755767aa17a GIT binary patch literal 32023 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 Cet2;J literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/reconstruction-full.png b/docs/u1-webui/02-device-page/screenshots/reconstruction-full.png new file mode 100644 index 0000000000000000000000000000000000000000..56d7b6d44955a0287c439e1d0e503419d9d124dd GIT binary patch literal 101987 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 HQ$iB}Ku=w2 literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/reconstruction-matched.png b/docs/u1-webui/02-device-page/screenshots/reconstruction-matched.png new file mode 100644 index 0000000000000000000000000000000000000000..9827d3e14335f3c62ae1f11c82dde936ab1d6685 GIT binary patch literal 55363 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

i$0n#bbl0O7wMa?TR}d$7sVCABp zCc7_xPbo(SY(U44M)Xc5Lu-=Krk5z0Tlg0;(;s^vumf#mbc$#g(X#=4vYn7=(%gVQ zr8oX>>yy2xViS2zSGk!N8|g5jr||>jw%n$Gnltt^Qhc3wZs-jkcNvHwbgr|VA5}1N zmi;(V<~!$XM}daBHX8ubsl!OB*8r{lYJ#6r8WW*U2oPBVLiGuR<*f5509tc zi?81T@vMw#=~Zds$W-J_+}=LLeg943@+Ur0iDtjedbld|sKwEUfBlzo(a{JK+HW=$ z(KLxf@7D7f4EhrfDDE4JZgWX{u6<|X#Rl3-je2hBtQdLL5!8r&S$2}MXclkeNX7={ zyDPC)K-%zRTM;ZTi`9-mjXM}bBF%EL)>>09xc>`$>xr`Ts52gArSd0IGzPcIe!P?U ze>oL84=`(wH)^~*n8IsC)*a*(waxuo9nJCEjitlU0l{a9?8j5IBA+j3O@0cOPOcdd z_Zg17Kc}PuBjo$6?6+4`ui1Pl_@VQjxzA9-Ael7JvhUbc$J3IVFiHM_fe$uFX-X`)%Rhjc5mC@ic@iJBRbxE~We`5P z2U5WYL;3x6d;WPF2t?ka-G1kWX2j{<67cwX;_)i_aHZyE*z7N`jc&AYwS+UUZ%

uTl6fz7hRbI1zyg;#$C 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 YW-Lan%8lISZ$D-!F*(svVFTa)1?qv{`Tzg` literal 0 HcmV?d00001 diff --git a/docs/u1-webui/02-device-page/screenshots/reconstruction-with-badge.png b/docs/u1-webui/02-device-page/screenshots/reconstruction-with-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..419066c28c1b819fe66b39aaac33177183f7ba88 GIT binary patch literal 95587 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

cSb*=kDF2$_(W@6i)( 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

b6S*ekp!2>TkI&)k{|^tq-h2JLHn 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 MV)CL@!Uh5V4TFn6XaE2J literal 0 HcmV?d00001 diff --git a/docs/u1-webui/03-print-processing/screenshots/reconstruction-path5.png b/docs/u1-webui/03-print-processing/screenshots/reconstruction-path5.png new file mode 100644 index 0000000000000000000000000000000000000000..ce9ceb5241f1f3e85f5ffcdcb54c48c7bd967b61 GIT binary patch literal 30787 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 R>ws@al)&l=C32=8{s$~3TVMbH literal 0 HcmV?d00001 diff --git a/docs/u1-webui/04-bridge-wcp/01-envelope.md b/docs/u1-webui/04-bridge-wcp/01-envelope.md new file mode 100644 index 00000000000..e960d4be32a --- /dev/null +++ b/docs/u1-webui/04-bridge-wcp/01-envelope.md @@ -0,0 +1,159 @@ +# The SSWCP bridge — wire protocol + +SSWCP ("Slicer Studio Web Communication Protocol") is the JSON message bus between the +Flutter Device page and Orca's C++. Everything the page cannot do itself — talk to the +printer, touch the filesystem, open dialogs, read account state — goes through it. + +## Transport + +| Direction | Mechanism | +|---|---| +| Page → Orca | `window.wx.postMessage()` — a `wxWebView` script-message handler named `wx`. Delivered to `SSWCP::handle_web_message` via `PrinterWebView::OnScriptMessage`. | +| Orca → Page | Orca evaluates `window.postMessage(JSON.stringify(), '*')` in the page. | + +`resources/web/flutter_web/index.html` installs a `window.sendMessage` shim that forwards +to `window.wx.postMessage` and logs to the console when the host is absent — which is what +makes the bundle runnable in a plain browser for inspection. + +There is a second, parallel path: `SSWCP::send_message_to_flutter` mirrors every response +over a WebSocket debug server (`WebSocketDebugServer.hpp`), and +`SSWCP::handle_webmsg_for_debug` accepts commands with no webview attached. This exists so +a Flutter debug session outside Orca can drive the same bridge. + +## Envelope + +Recovered from `A.be` / `A.bM` / `A.Hu` in the bundle, whose `toString()` implementations +still carry the original Dart class names. + +**Request** — `WcpPacket{header, payload}` where payload is `RequestPayload`: + +```json +{ + "header": { "seqid": "1732" }, + "payload": { + "cmd": "sw_ControlBedTemp", + "event_id": null, + "params": { "temp": 60 }, + "metadata": null + } +} +``` + +**Response** — same envelope, payload is `ResponsePayload`: + +```json +{ + "header": { "seqid": "1732" }, + "payload": { "code": 200, "message": "success", "data": { } } +} +``` + +| Field | Meaning | +|---|---| +| `header.seqid` | Correlation id, a monotonically incrementing counter rendered as a string. Orca echoes `header` back verbatim, which is how the page matches replies. | +| `payload.cmd` | One of the 111 `sw_*` commands — see [command reference](02-command-catalogue.md). | +| `payload.event_id` | Non-null turns the call into a **subscription** (see below). | +| `payload.metadata` | Always `null` in this build. | +| `payload.code` | **`200` on success** — see below. `1` on timeout, `-1` for missing/invalid params. | +| `payload.message` | Human-readable error text, empty on success. | +| `payload.data` | Command-specific result object. | + +Orca serialises responses with `json::dump(4, ' ', true)` — pretty-printed, with invalid +UTF-8 replaced rather than throwing. + +### `code: 200` means success — `code: 0` is a failure + +This is the single easiest thing in the bridge to get wrong, and it is worth stating +plainly because `0` is the natural guess for "no error". + +Three independent confirmations: + +1. **The C++ default.** `SSWCP.hpp:219` — `int m_status = 200; // Response status code`. + Every handler that succeeds without touching `m_status` replies `200`. +2. **The bundle's own guard.** `main.dart.js` compares `payload.code` against the literal + `200` in **eleven** places, and against no other integer: + ```js + if (J.o(j.b.h(0,"code"), 200)) { /* success path */ } + ``` +3. **Observed behaviour.** Replying `0` from a simulated host makes the page raise + `Exception: getUserLoginState failed: code=0, message=success`, retry three times, and + then declare the user signed out. Replying `200` lets the session proceed to + `ConnectionStatus.connectedOnline`. + +The C++ is not internally consistent — a few handlers set `m_status = 0` +(`SSWCP.cpp:4439`, `:4475`, `:5620`) and others use `1` for failure — but the page's +contract is unambiguous. **A client that treats `0` as success will reject every real +response from Orca.** + +## One-shot vs subscription + +The distinction is `event_id`, handled in `SSWCP_*::process()`: + +```cpp +if (m_event_id != "") { + send_to_js(); // 1. immediate ack, echoing the original header + m_header.clear(); + m_header["event_id"] = m_event_id; // 2. all later pushes are keyed by event_id +} +``` + +So a subscription produces: + +1. **One ack** carrying the original `header.seqid`, sent *before* the command runs. +2. **N pushes**, each with `header = {"event_id": ""}` and no `seqid`. + +Lifetime differs too — `SSWCP::handle_web_message`: + +```cpp +if (event_id != "") m_instance_list.add_infinite(instance.get(), instance); +else m_instance_list.add(instance.get(), instance, DEFAULT_INSTANCE_TIMEOUT); +``` + +One-shot instances are garbage-collected on a timeout; subscriptions live until explicitly +cancelled (`sw_Webview_Unsubscribe`, `sw_UnsubscribeAll`, `sw_StopMachineStateSubscription`, +`sw_UnSubscribeMachineState`) or until the webview is destroyed +(`SSWCP::on_webview_delete`). + +## Routing + +`SSWCP::create_sswcp_instance` picks a handler class by membership in one of eight command +sets. The class determines threading, lifetime, and which subsystem the command reaches: + +| Instance class | Command set | Domain | +|---|---|---| +| `SSWCP_MachineOption_Instance` | `m_machine_option_cmd_list` | Printer control, files, camera, timelapse — **the bulk of the Device page** | +| `SSWCP_MachineConnect_Instance` | `m_machine_connect_cmd_list` | Connect / disconnect / pincode | +| `SSWCP_MachineFind_Instance` | `m_machine_find_cmd_list` | LAN discovery (Bonjour) | +| `SSWCP_MachineManage_Instance` | `m_machine_manage_cmd_list` | Saved-device list | +| `SSWCP_UserLogin_Instance` | `m_login_cmd_list` | Account, downloads, timelapse upload | +| `SSWCP_SliceProject_Instance` | `m_project_cmd_list` | Projects and recent files | +| `SSWCP_PageStateChange_Instance` | `m_page_state_cmd_list` | Page visibility/state | +| `SSWCP_MqttAgent_Instance` | `m_mqtt_agent_cmd_list` | Raw MQTT passthrough | +| `SSWCP_Instance` (base) | everything else | Cache, logging, tabs, browser, exit | + +Unknown commands fall through to `handle_general_fail()`. + +## Command coverage + +| | Count | +|---|---| +| Commands the Device page calls | 117 | +| Commands Orca dispatches | 127 | +| Both (the live contract) | **111** | +| Called by the page with no C++ handler | 6 | +| Handler exists but the page never calls it | 16 | + +The 6 page-only commands are `sw_GetMachineFilamentMapping`, `sw_Query`, `sw_SendCommand`, +`sw_UnSubscribeMachineState`, `sw_UploadFile`, `sw_UploadFileResult`. Some are aliases the +page never reaches at runtime (`sw_StopMachineStateSubscription` is the name that actually +dispatches to `sw_UnSubscribeMachineState()`); the rest are dead paths or belong to a newer +bundle than this Orca build. + +## Raw MQTT passthrough + +`SSWCP_MqttAgent_Instance` exposes the MQTT client directly to the page: +`sw_create_mqtt_client`, `sw_mqtt_connect`, `sw_mqtt_disconnect`, `sw_mqtt_subscribe`, +`sw_mqtt_unsubscribe`, `sw_mqtt_publish`, `sw_mqtt_set_engine`. + +This is a full escape hatch — with it the page can bypass every typed command and speak +to the printer itself. `sw_mqtt_set_engine` switches which side owns the connection. diff --git a/docs/u1-webui/04-bridge-wcp/02-command-catalogue.md b/docs/u1-webui/04-bridge-wcp/02-command-catalogue.md new file mode 100644 index 00000000000..b5362826c89 --- /dev/null +++ b/docs/u1-webui/04-bridge-wcp/02-command-catalogue.md @@ -0,0 +1,259 @@ +# WCP command catalogue + +Generated by [`tools/gen_command_catalogue.py`](../tools/gen_command_catalogue.py) +from [`data/wcp-commands.json`](../data/wcp-commands.json). Do not edit by hand. + +Two independent sources are cross-referenced: + +- the page's `WcpCmd` enum, whose `gaX()` getter maps each enum index to a + wire name (`sw_*`) and survives dart2js as a plain switch; +- `SSWCP.cpp` / `SSWCP.hpp`, which route each wire name to an instance class. + +| | Count | +|---|---| +| Commands in the page's `WcpCmd` enum | 116 | +| Implemented by the C++ host | 125 | +| Present on both sides | 111 | +| Page-only — no host handler | 5 | +| Host-only — absent from `WcpCmd` | 14 | + +## Version skew + +The shipped bundle and the C++ host are versioned independently, and the +mismatch is visible in both directions. + +**Page-only** — the page can emit these, nothing answers them: + +| Wire name | `WcpCmd` value | +|---|---| +| `sw_GetMachineFilamentMapping` | `—` | +| `sw_SendCommand` | `—` | +| `sw_UnSubscribeMachineState` | `unSubscribeMachineState` | +| `sw_UploadFile` | `—` | +| `sw_UploadFileResult` | `—` | + +`sw_UnSubscribeMachineState` is worth singling out: the enum declares both it +(index 7) and `sw_StopMachineStateSubscription` (index 10), but the host +implements only the latter. A call on index 7 would go unanswered and fail on +the page's 3-second timeout. + +**Host-only** — implemented in C++, absent from the bundle's enum: + +- `sw_CancelDownload` +- `sw_DefectDetactionConfig` +- `sw_DeleteMachineFile` +- `sw_FinishPreprint` +- `sw_GetMachineFindSupportInfo` +- `sw_GetPrintHistory` +- `sw_GetPrintLegal` +- `sw_GetPrintZip` +- `sw_Test_connect` +- `sw_UnsubscribeAll` +- `sw_UnsubscribeDownloadState` +- `sw_UnsubscribePageStateChange` +- `sw_WakeupFind` +- `sw_mqtt_unpublish` + +`sw_FinishPreprint`, `sw_GetPrintZip` and `sw_GetPrintLegal` belong to the +print-processing flow and *are* exercised at runtime, so the page reaches them +by some path other than a `WcpCmd` constant. + +## By routing group + +`SSWCP::create_sswcp_instance` picks an instance class by testing membership +in one of eight `unordered_set`s; anything unmatched falls through to the base +`SSWCP_Instance`. + +### Machine discovery + +`SSWCP_MachineFind_Instance` — 4 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_GetMachineFindSupportInfo` | `—` | — | — | yes | +| `sw_StartMachineFind` | `startMachineFind` | 0 | `send_machine_list` | yes | +| `sw_StopMachineFind` | `stopMachineFind` | 4 | — | yes | +| `sw_WakeupFind` | `—` | — | — | yes | + +### Connection and pairing + +`SSWCP_MachineConnect_Instance` — 6 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_Connect` | `—` | 1 | — | yes | +| `sw_ConnectOtherMachine` | `—` | 2 | — | yes | +| `sw_Disconnect` | `disconnectMachine` | 3 | — | yes | +| `sw_GetConnectedMachine` | `getConnectedMachine` | 11 | — | yes | +| `sw_GetPincode` | `—` | 62 | — | yes | +| `sw_Test_connect` | `—` | — | — | yes | + +### Machine control, files, print jobs + +`SSWCP_MachineOption_Instance` — 56 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_BedMesh_AbortProbeMesh` | `setBedMeshAbortProbeMesh` | 87 | — | yes | +| `sw_CameraStartMonitor` | `cameraStartMonitor` | 24 | — | yes | +| `sw_CameraStopMonitor` | `cameraStopMonitor` | 25 | — | yes | +| `sw_CancelPullCloudFile` | `cancelPullCloudFile` | 38 | — | yes | +| `sw_ControlBedTemp` | `setControlBedTemp` | 91 | — | yes | +| `sw_ControlExtruderTemp` | `setControlExtruderTemp` | 92 | — | yes | +| `sw_ControlGenericFan` | `setControlGenericFan` | 90 | — | yes | +| `sw_ControlLed` | `setControlLed` | 85 | — | yes | +| `sw_ControlMainFan` | `setControlMainFan` | 89 | — | yes | +| `sw_ControlPrintSpeed` | `setControlPrintSpeed` | 86 | — | yes | +| `sw_ControlPurifier` | `setControlPurifier` | 88 | — | yes | +| `sw_DefectDetactionConfig` | `—` | — | — | yes | +| `sw_DeleteCameraTimelapse` | `deleteCameraTimelapse` | 101 | — | yes | +| `sw_DeleteMachineFile` | `—` | — | — | yes | +| `sw_DownloadMachineFile` | `—` | 30 | — | yes | +| `sw_FileGetStatus` | `filesGetStatus` | 32 | — | yes | +| `sw_FilesThumbnailsBase64` | `setFilesThumbnailsBase64` | 94 | — | yes | +| `sw_FinishFilamentMapping` | `finishFilamentMapping` | 28 | — | yes | +| `sw_FinishPreprint` | `—` | — | — | yes | +| `sw_GetCameraTimelapseInstance` | `getCameraTimelapseInstance` | 99 | — | yes | +| `sw_GetDeviceDataStorageSpace` | `getDeviceDataStorageSpace` | 36 | — | yes | +| `sw_GetFileFilamentMapping` | `getFileFilamentMapping` | 26 | — | yes | +| `sw_GetFileListPage` | `getFileListPage` | 96 | — | yes | +| `sw_GetMachineObjects` | `getMachineObjects` | 8 | — | yes | +| `sw_GetMachineState` | `getMachinState` | 5 | — | yes | +| `sw_GetMachineSystemInfo` | `getMachineSystemInfo` | 13 | — | yes | +| `sw_GetPrintHistory` | `—` | — | — | yes | +| `sw_GetPrintLegal` | `—` | — | — | yes | +| `sw_GetPrintZip` | `—` | — | — | yes | +| `sw_GetPrinterInfo` | `getPrinterInfo` | 12 | — | yes | +| `sw_MachineFilesGetDirectory` | `machineFilesGetDirectory` | 23 | — | yes | +| `sw_MachineFilesMetadata` | `machineFilesMetadata` | 21 | — | yes | +| `sw_MachineFilesRoots` | `machineFilesRoots` | 20 | — | yes | +| `sw_MachineFilesThumbnails` | `machineFilesThumbnails` | 22 | — | yes | +| `sw_MachineHeartbeat` | `machineHeartbeat` | 39 | — | yes | +| `sw_MachinePrintCancel` | `matchinePrintCancel` | 19 | — | yes | +| `sw_MachinePrintPause` | `matchinePrintPause` | 17 | — | yes | +| `sw_MachinePrintResume` | `matchinePrintResume` | 18 | — | yes | +| `sw_MachinePrintStart` | `matchinePrintStart` | 16 | — | yes | +| `sw_PrinterDefectDetection` | `setPrinterDefectDetection` | 93 | — | yes | +| `sw_PullCloudFile` | `pullCloudFile` | 37 | — | yes | +| `sw_SendGCodes` | `sendGCodes` | 14 | — | yes | +| `sw_ServerClientManagerSetUserinfo` | `serverClientManagerSetUserinfo` | 109 | — | yes | +| `sw_SetDeviceName` | `setDeviceName` | 84 | — | yes | +| `sw_SetFilamentMappingComplete` | `setFilamentMappingComplete` | 27 | — | yes | +| `sw_SetSubscribeFilter` | `setSubscribeFilter` | 9 | — | yes | +| `sw_StartCloudPrint` | `startCloudPrint` | 34 | — | yes | +| `sw_StartLocalPrint` | `startLocalPrint` | 35 | — | yes | +| `sw_StopMachineStateSubscription` | `—` | 10 | — | yes | +| `sw_SubscribeMachineState` | `subscribeMachineState` | 6 | `subscribeMachineState` | yes | +| `sw_SystemGetDeviceInfo` | `systemDeviceInfo` | 33 | — | yes | +| `sw_UpdateMachineFilamentInfo` | `updateMachineFilamentInfo` | 67 | — | yes | +| `sw_UploadAsyncTimelapseInstance` | `uploadAsyncTimelapseInstance` | 102 | — | yes | +| `sw_UploadCameraTimelapse` | `uploadCameraTimelapse` | 100 | — | yes | +| `sw_UploadFiletoMachine` | `—` | 31 | — | yes | +| `sw_exception_query` | `setExceptionQuery` | 95 | — | yes | + +### Saved-device management + +`SSWCP_MachineManage_Instance` — 6 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_AddDevice` | `—` | 52 | — | yes | +| `sw_DeleteDevices` | `deleteDevices` | 56 | — | yes | +| `sw_GetLocalDevices` | `getLocalDevices` | 51 | — | yes | +| `sw_RenameDevice` | `renameDevice` | 54 | — | yes | +| `sw_SubscribeLocalDevices` | `subscribeLocalDevices` | 53 | `subscribeLocalDevices` | yes | +| `sw_SwitchModel` | `—` | 55 | — | yes | + +### Projects and recent files + +`SSWCP_SliceProject_Instance` — 6 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_DeleteRecentFiles` | `deleteRecentFiles` | 48 | — | yes | +| `sw_GetRecentProjects` | `getRecentProjects` | 46 | — | yes | +| `sw_NewProject` | `createProject` | 44 | — | yes | +| `sw_OpenProject` | `openProject` | 45 | — | yes | +| `sw_OpenRecentFile` | `openRecentFile` | 47 | — | yes | +| `sw_SubscribeRecentFiles` | `subscribeRecentFiles` | 49 | `subscribeRecentFiles` | yes | + +### Account + +`SSWCP_UserLogin_Instance` — 15 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_CancelDownload` | `—` | — | — | yes | +| `sw_DownLoadFile` | `downLoadTimelapseFile` | 103 | — | yes | +| `sw_DownLoadFileAndOpen` | `downLoadFileAndOpen` | 114 | — | yes | +| `sw_FileView` | `—` | 107 | — | yes | +| `sw_GetFilesFromDir` | `getFilesFromDir` | 106 | — | yes | +| `sw_GetUserLoginState` | `getUserLoginState` | 43 | — | yes | +| `sw_GetUserUpdatePrivacy` | `—` | 112 | — | yes | +| `sw_NotifyUploadTimelaspe` | `notifyUploadTimelapse` | 104 | — | yes | +| `sw_OpenTimelapseFolder` | `openTimelapseFolder` | 108 | — | yes | +| `sw_SubUserUpdatePrivacy` | `subUserUpdatePrivacy` | 111 | `subUserUpdatePrivacy` | yes | +| `sw_SubscribeDownloadState` | `subscribeTimelapseDownloadState` | 105 | `subscribeTimelapseDownloadState` | yes | +| `sw_SubscribeUserLoginState` | `subscribeUserLoginState` | 42 | `subscribesUserLoginState` | yes | +| `sw_UnsubscribeDownloadState` | `—` | — | — | yes | +| `sw_UserLogin` | `userLogin` | 40 | — | yes | +| `sw_UserLogout` | `userLogout` | 41 | — | yes | + +### MQTT transport agent + +`SSWCP_MqttAgent_Instance` — 7 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_create_mqtt_client` | `createMqttClient` | 77 | — | yes | +| `sw_mqtt_connect` | `mqttConnect` | 78 | — | yes | +| `sw_mqtt_disconnect` | `mqttDisconnect` | 79 | — | yes | +| `sw_mqtt_publish` | `mqttPublish` | 82 | — | yes | +| `sw_mqtt_set_engine` | `mqttSetEngine` | 83 | — | yes | +| `sw_mqtt_subscribe` | `mqttSubscribe` | 80 | `responseCb` | yes | +| `sw_mqtt_unpublish` | `—` | — | — | yes | + +### Page lifecycle + +`SSWCP_PageStateChange_Instance` — 2 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_SubscribePageStateChange` | `subscribePageStateChange` | 110 | `subscribePageStateChange` | yes | +| `sw_UnsubscribePageStateChange` | `—` | — | — | yes | + +### Ungrouped — handled by the base instance + +`SSWCP_Instance` — 28 commands + +| Wire name | `WcpCmd` value | Idx | Push channel | Host | +|---|---|---|---|---| +| `sw_Exit` | `exit` | 113 | — | yes | +| `sw_FileLog` | `writeFileLog` | 73 | — | yes | +| `sw_GetActiveFile` | `getActiveFile` | 60 | — | yes | +| `sw_GetCache` | `getCache` | 65 | — | yes | +| `sw_GetFileStream` | `—` | 59 | — | yes | +| `sw_GetMachineFilamentMapping` | `—` | 29 | — | **no** | +| `sw_GetSoftwareInfo` | `getSoftwareInfo` | 115 | — | yes | +| `sw_LaunchConsole` | `—` | 70 | — | yes | +| `sw_Log` | `—` | 71 | — | yes | +| `sw_OpenBrowser` | `openBrowser` | 75 | — | yes | +| `sw_OpenNetworkDialog` | `openNetworkDialog` | 50 | — | yes | +| `sw_OpenOrcaWebview` | `openOrcaWebview` | 76 | — | yes | +| `sw_RemoveCache` | `—` | 66 | — | yes | +| `sw_SendCommand` | `—` | 15 | — | **no** | +| `sw_SetCache` | `setCache` | 64 | — | yes | +| `sw_SetLogLevel` | `—` | 72 | — | yes | +| `sw_SubscribeCacheKey` | `subscribeCacheKey` | 68 | `subscribeCacheKey` | yes | +| `sw_SwitchTab` | `switchTab` | 61 | — | yes | +| `sw_UnSubscribeMachineState` | `unSubscribeMachineState` | 7 | — | **no** | +| `sw_UnsubscribeAll` | `—` | — | — | yes | +| `sw_UnsubscribeCacheKeys` | `—` | 69 | `unsubscribeCacheKeys` | yes | +| `sw_Unsubscribe_Filter` | `—` | 98 | — | yes | +| `sw_UploadEvent` | `uploadEvent` | 74 | — | yes | +| `sw_UploadFile` | `—` | 57 | — | **no** | +| `sw_UploadFileResult` | `—` | 58 | — | **no** | +| `sw_Webview_Unsubscribe` | `webviewUnsubscribe` | 97 | — | yes | +| `sw_mqtt_unsubscribe` | `—` | 81 | — | yes | +| `sw_test_mqtt_moonraker` | `—` | 63 | — | yes | + diff --git a/docs/u1-webui/04-bridge-wcp/03-command-parameters.md b/docs/u1-webui/04-bridge-wcp/03-command-parameters.md new file mode 100644 index 00000000000..bd3118a175b --- /dev/null +++ b/docs/u1-webui/04-bridge-wcp/03-command-parameters.md @@ -0,0 +1,162 @@ + +# SSWCP command reference + +112 commands dispatched by `SSWCP_*::process()`. `page` = called by the Flutter Device page bundle. + +## Machine control / files / camera + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_BedMesh_AbortProbeMesh` | yes | `sw_BedMesh_AbortProbeMesh()` | - | - | +| `sw_CameraStartMonitor` | yes | `sw_CameraStartMonitor()` | `domain`, `expect_pw`, `interval` | - | +| `sw_CameraStopMonitor` | yes | `sw_CameraStopMonitor()` | `domain` | - | +| `sw_CancelPullCloudFile` | yes | `sw_CancelPullCloudFile()` | - | - | +| `sw_ControlBedTemp` | yes | `sw_ControlBedTemp()` | `temp` | - | +| `sw_ControlExtruderTemp` | yes | `sw_ControlExtruderTemp()` | `index`, `map`, `temp` | - | +| `sw_ControlGenericFan` | yes | `sw_ControlGenericFan()` | `name`, `speed` | - | +| `sw_ControlLed` | yes | `sw_ControlLed()` | `name`, `white` | - | +| `sw_ControlMainFan` | yes | `sw_ControlMainFan()` | `speed` | - | +| `sw_ControlPrintSpeed` | yes | `sw_ControlPrintSpeed()` | `percentage` | - | +| `sw_ControlPurifier` | yes | `sw_ControlPurifier()` | - | - | +| `sw_DefectDetactionConfig` | | `sw_DefectDetactionConfig()` | - | - | +| `sw_DeleteCameraTimelapse` | yes | `sw_DeleteCameraTimelapse()` | - | - | +| `sw_DeleteMachineFile` | | `sw_DeleteMachineFile()` | `path` | - | +| `sw_DownloadMachineFile` | yes | `sw_DownloadMachineFile()` | `filename`, `url` | - | +| `sw_FileGetStatus` | yes | `sw_FileGetStatus()` | - | - | +| `sw_FilesThumbnailsBase64` | yes | `sw_FilesThumbnailsBase64()` | `path` | - | +| `sw_FinishFilamentMapping` | yes | `sw_FinishFilamentMapping()` | - | - | +| `sw_FinishPreprint` | | `sw_FinishPreprint()` | `status` | - | +| `sw_GetCameraTimelapseInstance` | yes | `sw_GetCameraTimelapseInstance()` | - | - | +| `sw_GetFileFilamentMapping` | yes | `sw_GetFileFilamentMapping()` | `filename` | - | +| `sw_GetFileListPage` | yes | `sw_GetFileListPage()` | `files_per_page`, `page_number`, `root` | - | +| `sw_GetMachineObjects` | yes | `sw_GetMachineObjects()` | - | - | +| `sw_GetMachineState` | yes | `sw_GetMachineState()` | `objects` | - | +| `sw_GetMachineSystemInfo` | yes | `sw_GetSystemInfo()` | - | - | +| `sw_GetPrintHistory` | | `sw_GetPrintHistory()` | - | - | +| `sw_GetPrintLegal` | | `sw_GetPrintLegal()` | `connected_model` | `legal`, `preset_model` | +| `sw_GetPrintZip` | | `sw_GetPrintZip()` | - | `content`, `name` | +| `sw_GetPrinterInfo` | yes | `sw_GetPrintInfo()` | - | - | +| `sw_MachineFilesGetDirectory` | yes | `sw_MachineFilesGetDirectory()` | `extended`, `path` | - | +| `sw_MachineFilesMetadata` | yes | `sw_MachineFilesMetadata()` | `filename` | - | +| `sw_MachineFilesRoots` | yes | `sw_MachineFilesRoots()` | - | - | +| `sw_MachineFilesThumbnails` | yes | `sw_MachineFilesThumbnails()` | `filename` | - | +| `sw_MachineHeartbeat` | yes | `sw_MachineHeartbeat()` | - | - | +| `sw_MachinePrintCancel` | yes | `sw_MachinePrintCancel()` | - | - | +| `sw_MachinePrintPause` | yes | `sw_MachinePrintPause()` | - | - | +| `sw_MachinePrintResume` | yes | `sw_MachinePrintResume()` | - | - | +| `sw_MachinePrintStart` | yes | `sw_MachinePrintStart()` | `filename` | - | +| `sw_PrinterDefectDetection` | yes | `sw_PrinterDefectDetection()` | - | - | +| `sw_PullCloudFile` | yes | `sw_PullCloudFile()` | - | - | +| `sw_SendGCodes` | yes | `sw_SendGCodes()` | `script` | - | +| `sw_ServerClientManagerSetUserinfo` | yes | `sw_ServerClientManagerSetUserinfo()` | `auther` | - | +| `sw_SetDeviceName` | yes | `sw_SetDeviceName()` | `name` | - | +| `sw_SetFilamentMappingComplete` | yes | `sw_SetFilamentMappingComplete()` | `status` | - | +| `sw_SetSubscribeFilter` | yes | `sw_SetMachineSubscribeFilter()` | `objects` | - | +| `sw_StartCloudPrint` | yes | `sw_StartCloudPrint()` | - | - | +| `sw_StartLocalPrint` | yes | `sw_StartLocalPrint()` | `path`, `type` | - | +| `sw_StopMachineStateSubscription` | yes | `sw_UnSubscribeMachineState()` | - | - | +| `sw_SubscribeMachineState` | yes | `sw_SubscribeMachineState()` | - | - | +| `sw_SystemGetDeviceInfo` | yes | `sw_SystemGetDeviceInfo()` | - | - | +| `sw_UpdateMachineFilamentInfo` | yes | `sw_UpdateMachineFilamentInfo()` | `objects` | - | +| `sw_UploadAsyncTimelapseInstance` | yes | `sw_UploadAsyncTimelapseInstance()` | - | - | +| `sw_UploadCameraTimelapse` | yes | `sw_UploadCameraTimelapse()` | - | - | +| `sw_UploadFiletoMachine` | yes | `sw_UploadFiletoMachine()` | `url` | - | +| `sw_exception_query` | yes | `sw_exception_query()` | - | - | + +## Connection + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_Connect` | yes | `sw_connect()` | - | - | +| `sw_ConnectOtherMachine` | yes | `sw_connect_other_device()` | - | - | +| `sw_Disconnect` | yes | `sw_disconnect()` | `dev_id`, `need_reload` | - | +| `sw_GetConnectedMachine` | yes | `sw_get_connect_machine()` | - | - | +| `sw_GetPincode` | yes | `sw_get_pin_code()` | `ip`, `nickname`, `port`, `userid` | - | +| `sw_Test_connect` | | `sw_test_connect()` | `ip`, `port`, `protcol`, `protocol` | - | + +## Discovery + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_GetMachineFindSupportInfo` | | `sw_GetMachineFindSupportInfo()` | - | - | +| `sw_StartMachineFind` | yes | `sw_StartMachineFind()` | `last_time` | - | +| `sw_StopMachineFind` | yes | `sw_StopMachineFind()` | - | - | +| `sw_WakeupFind` | | `sw_WakeupFind()` | - | `result` | + +## Saved-device management + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_AddDevice` | yes | `sw_AddDevice()` | - | - | +| `sw_DeleteDevices` | yes | `sw_DeleteDevices()` | `dev_ids` | - | +| `sw_GetLocalDevices` | yes | `sw_GetLocalDevices()` | - | - | +| `sw_RenameDevice` | yes | `sw_RenameDevice()` | `dev_id`, `dev_name` | - | +| `sw_SubscribeLocalDevices` | yes | `sw_SubscribeLocalDevices()` | - | - | +| `sw_SwitchModel` | yes | `sw_SwitchModel()` | `dev_id` | - | + +## Account, downloads & timelapse + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_GetUserLoginState` | yes | `sw_GetUserLoginState()` | - | - | +| `sw_SubscribeUserLoginState` | yes | `sw_SubscribeUserLoginState()` | - | - | +| `sw_UserLogin` | yes | `sw_UserLogin()` | `show` | - | +| `sw_UserLogout` | yes | `sw_UserLogout()` | - | - | + +## Project & recent files + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_DeleteRecentFiles` | yes | `sw_DeleteRecentFiles()` | `paths` | - | +| `sw_GetRecentProjects` | yes | `sw_GetRecentProjects()` | - | - | +| `sw_NewProject` | yes | `sw_NewProject()` | `preset_name` | - | +| `sw_OpenProject` | yes | `sw_OpenProject()` | - | - | +| `sw_OpenRecentFile` | yes | `sw_OpenRecentFile()` | `path` | - | +| `sw_SubscribeRecentFiles` | yes | `sw_SubscribeRecentFiles()` | - | - | + +## Page state + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_SubscribePageStateChange` | yes | `sw_SubscribePageStateChange()` | - | - | +| `sw_UnsubscribePageStateChange` | | `sw_UnsubscribePageStateChange()` | `event_id` | - | + +## Raw MQTT agent + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_create_mqtt_client` | yes | `sw_create_mqtt_client()` | `ca`, `cert`, `clean_session`, `clientId`, `key`, `password`, `server_address`, `username` | `id`, `type` | +| `sw_mqtt_connect` | yes | `sw_mqtt_connect()` | `id` | - | +| `sw_mqtt_disconnect` | yes | `sw_mqtt_disconnect()` | `id` | - | +| `sw_mqtt_publish` | yes | `sw_mqtt_publish()` | `id`, `payload`, `qos`, `topic` | - | +| `sw_mqtt_set_engine` | yes | `sw_mqtt_set_engine()` | `ca`, `cert`, `clientId`, `code`, `engine_id`, `id`, `ip`, `key`, `link_mode`, `need_reload`, `password`, `port`, `sn`, `user`, `userid` | - | +| `sw_mqtt_subscribe` | yes | `sw_mqtt_subscribe()` | `id`, `qos`, `topic` | - | + +## common + +| Command | page | C++ handler | Request `params` | Response `data` keys | +|---|:--:|---|---|---| +| `sw_Exit` | yes | `sw_Exit()` | - | - | +| `sw_FileLog` | yes | `sw_FileLog()` | `content`, `level` | - | +| `sw_GetActiveFile` | yes | `sw_GetActiveFile()` | `is_zip` | `checksum`, `file_name`, `file_path`, `metadata`, `origin_size`, `url` | +| `sw_GetCache` | yes | `sw_GetCache()` | `keys` | - | +| `sw_GetFileStream` | yes | `sw_GetFileStream()` | `is_zip` | `checksum`, `file_name`, `file_url`, `origin_size` | +| `sw_GetSoftwareInfo` | yes | `sw_GetSoftwareInfo()` | - | `http_host`, `version` | +| `sw_LaunchConsole` | yes | `sw_LaunchConsole()` | - | - | +| `sw_Log` | yes | `sw_Log()` | `content`, `level`, `module`, `time` | - | +| `sw_OpenBrowser` | yes | `sw_OpenBrowser()` | `url` | - | +| `sw_OpenNetworkDialog` | yes | `sw_OpenNetworkDialog()` | - | - | +| `sw_OpenOrcaWebview` | yes | `sw_OpenOrcaWebview()` | `url` | - | +| `sw_RemoveCache` | yes | `sw_RemoveCache()` | `keys` | - | +| `sw_SetCache` | yes | `sw_SetCache()` | `objects` | - | +| `sw_SetLogLevel` | yes | `sw_SetLogLevel()` | `level` | - | +| `sw_SubscribeCacheKey` | yes | `sw_SubscribeCacheKey()` | `key` | - | +| `sw_SwitchTab` | yes | `sw_SwitchTab()` | `target` | - | +| `sw_UnsubscribeAll` | | `sw_UnsubscribeAll()` | - | - | +| `sw_UnsubscribeCacheKeys` | yes | `sw_UnsubscribeCacheKeys()` | `keys` | - | +| `sw_Unsubscribe_Filter` | yes | `sw_Unsubscribe_Filter()` | `cmd`, `event_id` | - | +| `sw_UploadEvent` | yes | `sw_UploadEvent()` | `content`, `funcModule`, `level`, `tagKey`, `tagValue`, `traceId` | - | +| `sw_Webview_Unsubscribe` | yes | `sw_Webview_Unsubscribe()` | - | - | +| `sw_mqtt_unsubscribe` | yes | `sw_mqtt_unsubscribe()` | `id`, `topic` | - | +| `sw_test_mqtt_moonraker` | yes | `test_mqtt_request()` | - | - | + diff --git a/docs/u1-webui/05-printer-protocol/01-jsonrpc-methods.md b/docs/u1-webui/05-printer-protocol/01-jsonrpc-methods.md new file mode 100644 index 00000000000..d4f475a1d1b --- /dev/null +++ b/docs/u1-webui/05-printer-protocol/01-jsonrpc-methods.md @@ -0,0 +1,119 @@ +# JSON-RPC method surface + +The printer speaks **JSON-RPC 2.0**. The namespaces `printer.*`, `server.*`, `machine.*` +come from [Moonraker](https://moonraker.readthedocs.io/); Snapmaker has added +`printer.control.*`, `server.client_manager.*`, `camera.*`, `custom.*`, `system.*` and +extended `server.files.*`. + +Envelope: + +```json +{ "jsonrpc": "2.0", "method": "printer.control.bed_temp", + "params": { "temp": 60 }, "id": 7443000123456789 } +``` + +Methods and parameter names below are extracted from the Flutter bundle by +[`tools/extract_rpc.py`](../tools/extract_rpc.py) — that is, they are what the Device page +actually sends. Machine-readable form: [`data/jsonrpc-methods.json`](../data/jsonrpc-methods.json). + +`dynamic` marks a call whose params map is assembled at runtime, so the key list is not a +literal in the bundle; the C++ counterpart in `MoonRaker.cpp` is cited where it resolves one. + +## Print control + +| Method | Params | +|---|---| +| `printer.print.start` | `filename` | +| `printer.print.pause` | — | +| `printer.print.resume` | — | +| `printer.print.cancel` | — | +| `printer.gcode.script` | `script` | +| `printer.bed_mesh.abort_probe_mesh` | — (C++ only; page reaches it via `sw_BedMesh_AbortProbeMesh`) | + +## Hardware control — Snapmaker extension + +Standard Moonraker drives hardware with G-code. Snapmaker added typed methods instead: + +| Method | Params | Notes | +|---|---|---| +| `printer.control.bed_temp` | `temp` | | +| `printer.control.extruder_temp` | `temp`, `index`, `map` | per-toolhead; `index` selects the head | +| `printer.control.main_fan` | `speed` | | +| `printer.control.generic_fan` | `name`, `speed` | e.g. `name: "cavity_fan"` | +| `printer.control.led` | `name`, `white` | e.g. `name: "cavity_led"` | +| `printer.control.print_speed` | `percentage` | | +| `printer.control.purifier` | *dynamic* | mode / exhaust / inner fan | +| `printer.defect_detection.config` | *dynamic* | first-layer & spaghetti detection | + +## State + +| Method | Params | +|---|---| +| `printer.objects.list` | — | +| `printer.objects.query` | `objects` | +| `printer.objects.subscribe` | `objects` | +| `printer.objects.setSubscribeFilter` | `objects`, `sn` | +| `printer.objects.unsubscribe` | *(named in the bundle; see `sw_UnSubscribeMachineState`)* | +| `printer.info` | — | +| `machine.system_info` | — | +| `machine.heartbeat` | — | +| `machine.set_device_name` | `name` | +| `system.get_device_info` | — | +| `server.exception.query` | — | + +`objects` is a map of *object name → field list*, where `null` means every field. Its exact +contents are the [state model](04-state-model.md). + +Note `printer.objects.setSubscribeFilter` — camelCase, unlike everything around it, and it +carries an `sn`. It is a Snapmaker addition layered onto Moonraker's subscription model. + +## Files + +| Method | Params | +|---|---| +| `server.files.roots` | — | +| `server.files.list_page` | `root`, `page_number`, `files_per_page` | +| `server.files.get_directory` | *(C++: `MoonRaker.cpp`)* | +| `server.files.metadata` | `filename` | +| `server.files.get_status` | — | +| `server.files.thumbnails` | *(C++)* | +| `server.files.thumbnails_base64` | `path` | +| `server.files.delete_file` | *(C++ only)* | +| `server.files.get_userdata_space` | *(C++)* | +| `server.files.pull` | *dynamic* — cloud→printer transfer | +| `server.files.cancel_pull` | *(C++)* | +| `server.files.start_local_print` | *(C++)* | +| `server.files.start_cloud_print` | *(C++)* | +| `server.files.metascan` | *(named in bundle strings)* | +| `custom.file.filament.objects.get_mapping` | `filename` — filament→toolhead mapping for a job | + +## Pairing and client management + +| Method | Params | +|---|---| +| `server.request_key` | `clientid` — the pairing bootstrap ([transport](03-mqtt-transport.md)) | +| `server.client_manager.request_pin_code` | `userid`, `nickname`, `app_id` | +| `server.client_manager.request_lan_auth` | `clientid`, `app_id` | +| `server.client_manager.confirm_lan_status` | `clientid` | +| `server.client_manager.set_userinfo` | *(C++)* | + +## Camera and timelapse + +| Method | Params | +|---|---| +| `camera.start_monitor` | *dynamic* | +| `camera.stop_monitor` | *dynamic* | +| `camera.get_timelapse_instance` | `page_index`, `page_rows`, `thumbnail_direct` | +| `camera.delete_timelapse_instance` | *dynamic* | + +The live view is an HTTP still endpoint, `/files/camera/monitor.jpg`, not an RPC stream. +`camera.start_monitor` returns `CameraMonitorResponse{urlType, isNewDevice, salt, +iterations, hasPw}` — `salt` + `iterations` indicate a PBKDF2-derived credential for +newer devices, matched by `NewDeviceDecryptionParams{keyLength, ivLength}`. + +## Async notifications (printer → client) + +Delivered on `/notification`, dispatched by `method`: + +`notify_camera_upload_timelapse`, `notify_file_pull_progress`, `notify_job_queue_changed`, +plus `job_loaded`, `jobs_added`, `jobs_removed`, `print_started`, `pre_print_failed`. diff --git a/docs/u1-webui/05-printer-protocol/02-jsonrpc-reference.md b/docs/u1-webui/05-printer-protocol/02-jsonrpc-reference.md new file mode 100644 index 00000000000..6a4dce01011 --- /dev/null +++ b/docs/u1-webui/05-printer-protocol/02-jsonrpc-reference.md @@ -0,0 +1,91 @@ +# JSON-RPC reference + +Generated by [`tools/gen_jsonrpc_reference.py`](../tools/gen_jsonrpc_reference.py) +from [`data/jsonrpc-methods.json`](../data/jsonrpc-methods.json). Do not edit by hand. + +Every method the bundle constructs, grouped by namespace. For the same surface +grouped by *function* — plus the printer's async notification channel — see +[JSON-RPC methods](01-jsonrpc-methods.md). + +Extraction works because dart2js lowers a Dart map literal to +`A.x(["k1",v1,"k2",v2,...])`, so an envelope appears literally as +`["jsonrpc","2.0","method","","params",A.x([...]),"id",]`. Parameter names are +recoverable whenever the params map is a literal; where it is built at runtime the +method is still found but its params are marked *dynamic*. + +35 distinct methods across 6 namespaces. + +| Namespace | Methods | Origin | +|---|---|---| +| `camera.*` | 4 | **Snapmaker extension** | +| `custom.*` | 1 | **Snapmaker extension** | +| `machine.*` | 3 | Moonraker machine API | +| `printer.*` | 16 | Klipper/Moonraker core, plus Snapmaker `printer.control.*` | +| `server.*` | 10 | Moonraker server API, plus Snapmaker `server.client_manager.*` | +| `system.*` | 1 | **Snapmaker extension** | + +## `camera.*` + +| Method | Params | Call sites | +|---|---|---| +| `camera.delete_timelapse_instance` | *dynamic* | 1 | +| `camera.get_timelapse_instance` | `page_index`, `page_rows`, `thumbnail_direct` | 1 | +| `camera.start_monitor` | *dynamic* | 1 | +| `camera.stop_monitor` | *dynamic* | 1 | + +## `custom.*` + +| Method | Params | Call sites | +|---|---|---| +| `custom.file.filament.objects.get_mapping` | `filename` | 1 | + +## `machine.*` + +| Method | Params | Call sites | +|---|---|---| +| `machine.heartbeat` | *none* | 1 | +| `machine.set_device_name` | `name` | 1 | +| `machine.system_info` | *none* | 1 | + +## `printer.*` + +| Method | Params | Call sites | +|---|---|---| +| `printer.control.bed_temp` | `temp` | 1 | +| `printer.control.generic_fan` | `name`, `speed` | 1 | +| `printer.control.led` | `name`, `white` | 1 | +| `printer.control.main_fan` | `speed` | 1 | +| `printer.control.print_speed` | `percentage` | 1 | +| `printer.control.purifier` | *dynamic* | 1 | +| `printer.defect_detection.config` | *dynamic* | 1 | +| `printer.gcode.script` | `script` | 1 | +| `printer.objects.list` | *none* | 1 | +| `printer.objects.query` | `objects` | 1 | +| `printer.objects.setSubscribeFilter` | `objects`, `sn` | 1 | +| `printer.objects.subscribe` | `objects` | 1 | +| `printer.print.cancel` | *none* | 1 | +| `printer.print.pause` | *none* | 1 | +| `printer.print.resume` | *none* | 1 | +| `printer.print.start` | `filename` | 1 | + +## `server.*` + +| Method | Params | Call sites | +|---|---|---| +| `server.client_manager.confirm_lan_status` | `clientid` | 1 | +| `server.client_manager.request_lan_auth` | `app_id`, `clientid` | 1 | +| `server.client_manager.request_pin_code` | `app_id`, `nickname`, `userid` | 1 | +| `server.exception.query` | *none* | 1 | +| `server.files.get_status` | *none* | 1 | +| `server.files.list_page` | `files_per_page`, `page_number`, `root` | 1 | +| `server.files.metadata` | `filename` | 2 | +| `server.files.pull` | *dynamic* | 1 | +| `server.files.roots` | *none* | 1 | +| `server.files.thumbnails_base64` | `path` | 1 | + +## `system.*` + +| Method | Params | Call sites | +|---|---|---| +| `system.get_device_info` | *none* | 1 | + diff --git a/docs/u1-webui/05-printer-protocol/03-mqtt-transport.md b/docs/u1-webui/05-printer-protocol/03-mqtt-transport.md new file mode 100644 index 00000000000..dbb9d604928 --- /dev/null +++ b/docs/u1-webui/05-printer-protocol/03-mqtt-transport.md @@ -0,0 +1,100 @@ +# MQTT transport + +The U1 does not speak HTTP to Orca. `MoonRaker.cpp` ships two client variants and +the U1 uses the MQTT one: + +| Class | Transport | Used by | +|---|---|---| +| `Moonraker` | HTTP + WebSocket | legacy / other Klipper hosts | +| `Moonraker_Mqtt` | **MQTT over TLS** | the Snapmaker U1 | + +## Topics + +Four topic suffixes, defined as statics in +[`MoonRaker.cpp:844`](../../../src/slic3r/Utils/MoonRaker.cpp): + +```cpp +std::string Moonraker_Mqtt::m_request_topic = "/request"; +std::string Moonraker_Mqtt::m_response_topic = "/response"; +std::string Moonraker_Mqtt::m_status_topic = "/status"; +std::string Moonraker_Mqtt::m_notification_topic = "/notification"; +``` + +Every topic is namespaced by a prefix — the printer's **serial number** — and +composed by simple concatenation: + +``` +/request Orca -> printer JSON-RPC requests +/response printer -> Orca JSON-RPC responses +/status printer -> Orca subscribed object state +/notification printer -> Orca unsolicited events +``` + +Orca subscribes to `/notification` and `/response` when a device connects, and to +`/status` when it starts watching machine state; it publishes only to `/request`. +All four use **QoS 1**. + +Inbound routing is by suffix match: + +```cpp +if (topic.find(m_response_topic) != npos) { /* resolve a pending request */ } +else if (topic.find(m_status_topic) != npos) { /* push into the state model */ } +else if (topic.find(m_notification_topic) != npos) { /* dispatch an event */ } +``` + +The bundle contains the same four literals, plus a second `/config/...` set +(`/config/request`, `/config/response`, `/config/notification`) that the C++ does +not use — an additional configuration channel, unexercised on this path. + +## TLS and credentials + +Two client objects are held: `m_mqtt_client` (plain, `mqtt://`) and +`m_mqtt_client_tls`. The U1 path uses the TLS one, on port **8883**; `MoonRaker.cpp` +special-cases the `:8883` suffix when deriving an HTTP host from the MQTT one. + +`MqttClient` takes CA, client certificate and private key as **in-memory strings** +rather than file paths: + +```cpp +MqttClient(const std::string& server_address, + const std::string& client_id, + const std::string& ca_content, + const std::string& cert_content = "", + const std::string& key_content = "", + const std::string& username = "", + const std::string& password = "", ...); +``` + +That matches the cloud endpoint `/user/device/getMqttCert` found in the bundle: +certificates are fetched per device from Snapmaker's API and handed straight to +the client. It also matches the `ca`, `cert`, `key`, `clientId`, `user`, +`password` and `port` fields on Orca's own `DeviceInfo` record +([`AppConfig.hpp:33`](../../../src/libslic3r/AppConfig.hpp)), which is what +`sw_GetConnectedMachine` returns to the page. + +## Who drives the session + +The page does. `SSWCP`'s `mqtt_agent` routing group exposes the whole client +lifecycle over the bridge: + +| Command | | +|---|---| +| `sw_create_mqtt_client` | build a client from cert material | +| `sw_mqtt_connect` / `sw_mqtt_disconnect` | session | +| `sw_mqtt_subscribe` / `sw_mqtt_unsubscribe` | topics | +| `sw_mqtt_publish` / `sw_mqtt_unpublish` | messages | +| `sw_mqtt_set_engine` | swap the underlying client | + +`sw_mqtt_subscribe` is a subscription in the bridge sense too — its push channel +is `responseCb`, so inbound MQTT messages surface to the page as bridge pushes. + +This is the clearest single illustration of the [layer model](../01-architecture/01-layer-model.md): +the C++ owns an MQTT client, but the *decision* to connect, what to subscribe to, +and what to publish all come from the compiled Flutter page. + +## Client library + +The bundle credits `mqtt5_client` in `assets/NOTICES` — the page carries a full +MQTT 5 client of its own. On the desktop path it does not use it directly (the +C++ holds the socket), which suggests the same Dart codebase drives Snapmaker's +mobile/web clients over MQTT natively. diff --git a/docs/u1-webui/05-printer-protocol/04-state-model.md b/docs/u1-webui/05-printer-protocol/04-state-model.md new file mode 100644 index 00000000000..809d095d1c0 --- /dev/null +++ b/docs/u1-webui/05-printer-protocol/04-state-model.md @@ -0,0 +1,135 @@ +# Machine state model + +The Device page subscribes to a fixed set of Klipper/Snapmaker objects with an explicit +per-object field filter. Both are literals in the bundle and are recovered by +[`tools/extract_state_model.py`](../tools/extract_state_model.py) → +[`data/state-model.json`](../data/state-model.json). + +This is the most useful single artefact here: it is the complete list of everything the +Device page knows about the printer. + +## Subscription list + +24 objects, sent as the `objects` map of `printer.objects.subscribe` / `.query`: + +``` +extruder extruder1 extruder2 extruder3 +heater_bed virtual_sdcard print_stats display_status +gcode_move webhooks idle_timeout job +file_metadata print_task_config motion_report purifier +fan fan_generic cavity_fan led cavity_led +filament_feed left filament_feed right filament_detect defect_detection +machine_state_manager +``` + +`extruder` + `extruder1..3` confirm the U1's **four toolheads** (Klipper names the first +one `extruder`, not `extruder0`). Object names containing a space — +`led cavity_led`, `fan_generic cavity_fan`, `filament_feed left/right` — follow Klipper's +`

` convention. + +## Field filters + +`null` = subscribe to every field of that object. + +| Object | Fields | +|---|---| +| `extruder`, `extruder1..3` | `temperature`, `target`, `power`, `can_extrude`, `pressure_advance`, `smooth_time`, `state`, `nozzle_diameter` | +| `heater_bed` | `temperature`, `target` | +| `print_stats` | `filename`, `state`, `total_duration`, `print_duration`, `filament_used`, `message`, `info` | +| `virtual_sdcard` | `progress`, `file_position`, `is_active`, `file_size`, `file_path` | +| `display_status` | `progress`, `message` | +| `gcode_move` | `speed_factor`, `speed`, `extrude_factor` | +| `webhooks` | `state`, `state_message` | +| `fan` | `speed` | +| `fan_generic cavity_fan` | `speed` | +| `led cavity_led` | `color_data` | +| `purifier` | `power_detected`, `power_det_value`, `mode`, `exhaust_fan`, `inner_fan` | +| `machine_state_manager` | `main_state`, `action_code` | +| `configfile` | `settings` | +| `print_task_config` | 23 fields — see below | +| `toolhead`, `motion_report`, `defect_detection`, `filament_detect`, `filament_feed left`, `filament_feed right` | *null (all fields)* | +| `idle_timeout`, `job`, `file_metadata` | *null (all fields)* — fall through the default arm | + +The selection rule for extruders is a substring test, not an exact match: any object whose +name contains `extruder` gets the extruder field list. That is why a U1 with more heads +would need no client change. + +### `print_task_config` — the Snapmaker-specific job object + +``` +filament_vendor filament_type filament_sku +filament_official filament_sub_type filament_color +filament_color_rgba filament_color_multi filament_soft +filament_edit filament_exist +extruder_map_table extruders_used extruders_replenished +auto_bed_leveling flow_calibrate flow_calib_extruders +shaper_calibrate save time_lapse_camera +auto_replenish_filament can_auto_replenish auto_replenish_index +``` + +This carries the whole multi-material story: which filament sits in which toolhead +(`extruder_map_table`), which heads a job uses (`extruders_used`), and the auto-replenish +state used when a spool runs out mid-print. + +## Typed models + +The bundle's Dart classes still carry their original names and field names in their +`toString()` literals, recovered by +[`tools/extract_dart_classes.py`](../tools/extract_dart_classes.py) → +[`data/dart-classes.json`](../data/dart-classes.json). The state-bearing ones: + +| Class | Fields | +|---|---| +| `Extruder` | `index`, `temperature`, `target`, `power`, `canExtruder`, `pressureAdvance`, `smoothTime`, `state`, `nozzleDiameter` | +| `HeaterBed` | wire: `name`, `temperature`, `target` | +| `PrintStats` | `printDuration`, `filamentUsed`, `state`, `message`, `filename`, `fileMetadata`, `info` | +| `VirtualSdcard` | `fileSize`, `filePath`, `isActive`, `progress` | +| `PrintTaskConfig` | `filamentVendor`, `filamentType`, `filamentSubType`, `filamentOfficial`, `filamentColor`, `filamentColorRgba`, `extruderMapTable`, `timeLapseCameraFlag`, `autoBedLevelingFlag`, `filamentEdit`, `filamentExist`, `extrudersUsed`, `autoReplenishFilamentFlag`, `canAutoReplenishFlag`, `autoReplenishIndex`, `nozzleDiameters` | +| `MachineStateManager` | wire: `main_state`, `action_code` | +| `PurifierFan` | `powerDetValue`, `mode`, `exhaustFan`, `innerFan` | +| `ExhaustFan` | `delay`, `speedThreshold` | +| `InnerFan` | `rpm`, `delay`, `work`, `speedThreshold` | +| `ExtruderFilamentStatus` | `filamentDetected`, `disableAuto`, `channelState`, `channelError` | +| `DeviceException` | `index`, `code`, `level`, `message` | +| `FilamentItem` | `usedG`, `colorHex`, `usedM` | +| `DeviceStorageSpace` | wire: `free_space`, `total_space`, `units` | + +And the device/session models: + +| Class | Fields | +|---|---| +| `DeviceModel` | `key`, `ip`, `txtIp`, `port`, `clientId`, `id`, `devId`, `nozzleSizes`, `presetName`, `deviceConnectionState`, `name`, `deviceName`, `img`, `machineType`, `deviceModel`, `productCode`, `sn`, `authCode`, `userid`, `accessCode`, `linkMode`, `connected`, `status`, `online`, `isBind`, `deviceVersion`, `certConfig` | +| `DeviceCertConfig` | `endpoint`, `port`, `clientId`, `cert`, `key`, `ca`, `subscribeTopics`, `publishTopics` | +| `CameraMonitorResponse` | `urlType`, `isNewDevice`, `salt`, `iterations`, `hasPw` | +| `User` | `nickname`, `userid`, `email`, `account`, `status`, `icon`, `token`, `cellphone` | +| `UploadTaskState` | `result`, `isDone`, `isError`, `errorMessage`, `detailMessage`, `filePath`, `printStarted`, `checksum` | +| `DirectPrintParams` | `profile`, `filename`, `filepath`, `fileSize`, `fileChecksum` | + +`DeviceModel.linkMode` distinguishes LAN from cloud; `authCode` / `accessCode` feed the +pairing handshake. + +## `print_stats.state` — a spelling trap + +The bundle's `PrintState` enum carries an ordinal-to-wire-string getter, which is the +authority for what actually appears in `print_stats.state`: + +| Ordinal | Wire value | +|---:|---| +| 0 | `unknown` | +| 1 | `ready` | +| 2 | `standby` | +| 3 | `printing` | +| 4 | `paused` | +| 5 | `cancelled` | +| 6 | `error` | +| 7 | `complete` | + +The Dart *member* for ordinal 7 is named `completed`, but the value it emits is +`complete` — Klipper's own spelling. A client that matches on `completed` will never +see a finished print. + +## Status push shape + +Pushes arrive on `/status` as a partial state — only changed objects and only their +filtered fields. Clients must merge, not replace. Values sit under `params`/`status` +keyed by object name, matching Moonraker's `notify_status_update` convention. diff --git a/docs/u1-webui/05-printer-protocol/05-cloud-api.md b/docs/u1-webui/05-printer-protocol/05-cloud-api.md new file mode 100644 index 00000000000..a1b445566c0 --- /dev/null +++ b/docs/u1-webui/05-printer-protocol/05-cloud-api.md @@ -0,0 +1,221 @@ +# Snapmaker cloud API + +Cloud-connected devices are reached through Snapmaker's REST API rather than a LAN broker. +The Device page uses `dio` for these calls. + +> **Corrected 2026-08-24.** This page used to say the cloud "only supplies credentials and +> file transfer". That is wrong, and understates it considerably. On the WAN path the cloud +> also supplies **a different MQTT broker and a different topic namespace** — the session +> does not go to the printer at all. See [The two transports](#the-two-transports) below. + +## The two transports + +`DeviceModel` carries two endpoint builders, and which one runs decides what the MQTT +session even connects *to*: + +| | LAN (`getOrcaLanEndpoint`) | WAN (`getOrcaWanEndpoint`) | +|---|---|---| +| Broker | the printer, `:` | **`certConfig.endpoint`** — AWS IoT Core | +| `link_mode` | `lan` | `wan` | +| Client id | the device's own | `certConfig.clientId` | +| CA | from the LAN handshake | **hardcoded in the bundle** (below) | +| cert / key | from the LAN handshake | from `getMqttCert` | +| Topics | `/…`, fixed suffixes | **full topic strings from the cloud** | +| Port | as saved | forced to **8883** if the cert says 1884 | + +The LAN builder's topic map, recovered from the bundle, is exactly the set +[03-mqtt-transport.md](03-mqtt-transport.md) derives from the C++, plus the auth pair the +connect work found: + +``` +publish request "/request" authRequest "/config/request" config "" +subscribe response "/response" authResponse "/config/response" + status "/status" notification "/notification" + config "/config/notification" +``` + +The WAN builder does not use suffixes at all. It takes `certConfig.subscribeTopics` and +`certConfig.publishTopics` — **lists of complete topic strings** — and indexes them by each +topic's *second* path segment, so a topic `a/status/b` files itself under `status`. It then +appends `"/" + certConfig.clientId` to the `config` topic. WAN also carries two topics LAN +has no equivalent for: **`data`** and **`error`**. + +## It is AWS IoT Core + +The function that calls `getMqttCert` is named `fetchUserAwsIoTConfig`, and before calling +it the bundle awaits a helper that returns one hardcoded PEM tagged `"aws"`. That PEM +decodes to: + +``` +subject = C=US, O=Amazon, CN=Amazon Root CA 1 +notAfter = Jan 17 00:00:00 2038 GMT +SHA-256 = 8E:CD:E6:88:4F:3D:87:B1:12:5B:A3:1A:C3:FC:B1:3D:70:16:DE:7F:57:CC:90:4F:E1:CB:97:C6:AE:98:19:6E +``` + +So the **CA is not returned by the API** — only `cert`, `key`, the endpoint, the client id +and the topic lists are. The trust root ships inside the page and is pinned to Amazon's. + +`certConfig` is assembled as: + +``` +{ ca, <- the bundled Amazon Root CA 1, not from the response + cert, key, + clientId, + endpoint, <- read from the response's "ip" field + port, <- defaults to 1884, then forced to 8883 + subscribeTopics, <- list of full topic strings + publishTopics } +``` + +## Orca's C++ cannot drive a WAN session + +This is the load-bearing limitation, and it is worth being blunt about. + +`sw_create_mqtt_client` takes `server_address` as a free parameter, so the page **can** hand +the C++ an AWS IoT endpoint and it will connect. But the engine that then speaks the +protocol, `Moonraker_Mqtt`, builds every topic itself: + +```cpp +std::string main_layer = "+"; +main_layer = m_sn; // MoonRaker.cpp:1340, :1476, :1602, :2661 +... Subscribe(main_layer + m_status_topic, ...) +... Publish(main_layer + m_request_topic, ...) +``` + +`publishTopics` and `subscribeTopics` appear **nowhere in `src/`**. The cloud-supplied topic +strings are ignored, and every request goes to `/request` regardless of what the cert +said. A WAN session therefore works only if AWS IoT happens to namespace that device by the +same serial — which is exactly the thing this cannot confirm without a live cert. + +Two further consequences of that code: + +- `m_sn`, and all four topic suffixes, are **`static` members** (`MoonRaker.hpp:389-401`, + guarded by `m_sn_mtx`). The topic namespace is process-global, so Orca can address one + printer's topics at a time by construction. +- `main_layer` initialises to `"+"`, the MQTT single-level wildcard. If `m_sn` were ever + empty at that point the client would subscribe across *every* device namespace the broker + would serve it. The code guards this on the publish paths (`if (main_layer == "+" || ...)`) + but the subscribe path at :1340 does not re-check. + +## What the printer says about its own cloud link + +From the LAN topic survey ([06-mqtt-topics.md](06-mqtt-topics.md)), the printer publishes its +cloud state on two shared topics, and both read *down* throughout a healthy LAN session: + +```json +mqtt_agent/notification {"state": "disconnected", "userid": "", "username": ""} +LAVA/notification {"server": "offline"} +``` + +That is the printer's own outbound agent — the one the mobile app reaches it through. It is +independent of whether Orca is talking to the printer, and it is not a signal about the LAN +session's health. + +## Verification status + +Everything above is recovered from the shipped bundle and from `src/`, and the CA is +confirmed by decoding it. **The `getMqttCert` response itself has not been observed.** A +direct call from this environment is refused at the CDN before it reaches the API: + +``` +HTTP 403 Cloudflare error 1010 "browser_signature_banned" +``` + +which is a user-agent rule, not an authentication result — the bearer token is never +evaluated. Reaching it legitimately means letting the page make the call from Orca's webview, +where `fetchCloudCert()` in `device_page/js/core/connection.js` already implements it. Until then, +these remain unconfirmed: the endpoint hostname, the real topic strings, and therefore +whether the SN-prefixed C++ can address a WAN device at all. + +## Token shape + +The persisted bearer token is an RS256 JWT. One observed instance carried: + +``` +iss = http://172.31.131.102:8184 <- an RFC1918 address, in a production token +aud = mall-app +scope = [openid, profile] +lifetime = 24h +``` + +The internal issuer is consistent with the internal dev host +`http://172.17.100.32:8100/api` already left in the shipping bundle. `SMAccountPersist` +stores this token in plaintext `app_config`; its own header says an OS keychain would be +better, and that is still true. + +## Hosts + +| Environment | Host | +|---|---| +| Production (global) | `https://api.snapmaker.com/api` | +| Production (China) | `https://api.snapmaker.cn/api` | +| Identity | `https://id.snapmaker.com/api` | +| Pre-production | `https://pre.api.snapmaker.com/api`, `https://pre.api.snapmaker.cn/api`, `https://pre.id.snapmaker.com/api` | +| **Internal dev (left in the shipping bundle)** | `http://172.17.100.32:8100/api` | + +The `.com` / `.cn` split is region selection; `get_international_url()` on the Orca side +rewrites the loopback URL to match the user's region before the webview loads it. + +## Authentication + +OAuth2 against the identity host: + +``` +POST /oauth2/token +``` + +with `client_id`, `grant_type`, `scope`; the reply carries `access_token`, `token_type` +(`bearer`), and is sent onward as `Authorization: Bearer `. + +Token expiry surfaces to the UI as `TokenInvalidationEvent{uri, accessToken, msg, code, desc}`. +The session user is `User{nickname, userid, email, account, status, icon, token, cellphone}`. + +Orca persists the login across restarts — see `SMAccountPersist.hpp` and the +`sw_GetUserLoginState` / `sw_SubscribeUserLoginState` bridge commands. + +## Device binding + +| Endpoint | Purpose | +|---|---| +| `POST /user/device/bind` | Bind a printer to the account | +| `POST /user/device/unbind` | Release it | +| `GET /user/device/list` | Bound devices | +| `GET /user/device/info` | One device's detail | +| `POST /user/device/checkAuth` | Check current authorisation | +| `POST /user/device/connect/auth` | Authorise a connection attempt | +| `GET /user/device/getMqttCert` | **Fetch the mTLS material** — becomes `DeviceCertConfig` | + +`getMqttCert` is the cloud counterpart of the LAN handshake, but "after which the transport +is identical" is only true of the *protocol*. The broker, the client id and the whole topic +namespace differ — see [The two transports](#the-two-transports). + +## File upload + +A three-step create/complete/cancel flow, consistent with pre-signed object storage: + +| Endpoint | Purpose | +|---|---| +| `POST /user/device/upload/create` | Begin an upload, obtain the destination | +| `POST /user/device/upload/completed` | Finalise | +| `POST /user/device/upload/cancel` | Abort | + +`index.html` allows the `x-amz-checksum-sha256` request header, so the destination is +S3-compatible and the client checksums with SHA-256. `UploadTaskState` carries a +`checksum` field, and `DirectPrintParams` carries `fileChecksum`. + +Once uploaded, `server.files.pull` tells the printer to fetch the object itself, with +progress reported back over `/notification` as `notify_file_pull_progress`. +`server.files.start_cloud_print` then starts the job. + +## Model library + +`/model/list`, `/model/detail/`, `/manage/model/list`, `/manage/model/detail/` and their +`/cn/` variants back the model-library browsing surface. + +## Legacy HTTP printer path + +Strings for a plain Moonraker HTTP host also survive — `/server/info`, +`/server/files/upload`, `/server/files/gcodes/`, `/status`, `/status/poll`, `/health`, +`/ping`, `/preUpload`, `/preUploadAndPrint`. These belong to the non-U1 printer path +(`?path=3`), where `PrinterWebView::SendAPIKey()` injects `X-API-Key` into `fetch` and +`XMLHttpRequest`. The U1 does not use them. diff --git a/docs/u1-webui/05-printer-protocol/06-mqtt-topics.md b/docs/u1-webui/05-printer-protocol/06-mqtt-topics.md new file mode 100644 index 00000000000..d2e9b110d5f --- /dev/null +++ b/docs/u1-webui/05-printer-protocol/06-mqtt-topics.md @@ -0,0 +1,241 @@ +# Every topic the printer publishes on + +[03-mqtt-transport.md](03-mqtt-transport.md) lists four topics, derived by reading +`MoonRaker.cpp`. Those are the four **Orca subscribes to** — which is not the same +question as what the printer *offers*. Reading a client can only ever recover the +client's own view. + +This asks the printer instead. Both legs of the connection are subscribed with the `#` +wildcard, so the broker enumerates itself, and traffic is provoked in labelled phases so +each topic can be attributed to what caused it. + +Captured from `811002511261022618B3` at `192.168.2.242`, firmware as shipped, using +[`tools/u1_topics.py`](../tools/u1_topics.py). **Seven topics on the session leg, four of +which no reading of the C++ would have found**, because Orca never subscribes to them. + +## The connection is LAN, end to end + +Worth stating up front, because the topic names invite the opposite guess. Both sockets +are to the printer's own address on the local network — `192.168.2.242:1884` and +`:8883`. Nothing in this path traverses Snapmaker's cloud, and the printer says so +itself on two of the topics below: + +```json +mqtt_agent/notification {"state": "disconnected", "userid": "", "username": ""} +LAVA/notification {"server": "offline"} +``` + +That is the printer's *cloud* agent reporting itself disconnected while this LAN session +runs perfectly well. The cloud appears in this system in only two places, neither of +them a broker: `/user/device/getMqttCert` can **issue** the mTLS material a LAN handshake +would otherwise obtain ([05-cloud-api.md](05-cloud-api.md)), and timelapse download has a +`wan` mode where the device uploads and Orca fetches (`SSWCP.cpp:4928`). The MQTT +transport itself is always `mqtts://:`. + +## Two legs, two very different topic sets + +| Leg | Port | TLS | Topics visible | +|---|---|---|---| +| authorisation | 1884 | none | **1** | +| session | 8883 | mTLS | **7** | + +The plain leg is authorisation-only. Subscribing `#` on it yields exactly one topic, so +the broker is restricting an unauthenticated client to the config channel rather than +serving it a filtered view of everything: + +``` +12345678/config/response the mTLS material, in reply to request_lan_auth +``` + +`12345678` is the fixed LAN auth code, not a PIN — see +[02-device-page/06-connection.md](../02-device-page/06-connection.md). The matching +`12345678/config/request` is publish-only, so it never appears in a subscription. + +## The session leg at a glance + +Counts are from one 90-second survey and are only indicative; the shapes are not. + +| Topic | Msgs | Bytes | Carries | Scoped to | +|---|---:|---:|---|---| +| `/status` | 33 | 5,122 | `notify_status_update` | this printer | +| `/response` | 7 | 288,679 | every JSON-RPC reply | this printer | +| `/notification` | 74 | 15,514 | `notify_filelist_changed`, `{"server":…}` | this printer | +| `/request` | — | — | requests **out**; never subscribed | this printer | +| `moonraker/response` | 39 | 5,811 | unsolicited identity/login state | **shared** | +| `camera/response` | 3 | 21,433 | camera replies, **duplicated** | **shared** | +| `mqtt_agent/notification` | 39 | 4,485 | `notify_mqtt_agent_status` | **shared** | +| `LAVA/notification` | 1 | 21 | cloud reachability | **shared** | + +The four shared topics carry no serial number. They are the ones a client written from +the C++ alone will never see. + +## Each topic in detail + +### `/status` — subscribed object state + +Klipper's own status push, one entry per changed object plus a monotonic timestamp. +Only *changes* are sent, so a field's absence means unchanged, not zero. + +```json +{"jsonrpc": "2.0", "method": "notify_status_update", + "params": [{"extruder": {"temperature": 27.0}}, 67379.22754892]} +``` + +`params` is a two-element array: `[objects, eventtime]`. Nothing arrives here until a +`printer.objects.subscribe` has been sent — the topic is live from connect, but silent. + +### `/response` — every JSON-RPC reply + +Correlated by `id`, and stamped with the same `cli_time` / `dev_time` fields the request +carries. This is where the bulk of the bytes are: a `printer.objects.list` reply alone +enumerates **228** objects. + +```json +{"jsonrpc": "2.0", "result": {"objects": ["gcode", "webhooks", "exception_manager", …]}, + "id": 2, "cli_time": 1787566394, "dev_time": 1787566394} +``` + +Errors use the standard envelope, and an unsupported method answers `-32601`, which makes +probing a firmware cheap and safe: + +```json +{"error": {"code": -32000, "message": "Start monitor failed"}, "id": 6, "jsonrpc": "2.0"} +``` + +### `/notification` — unsolicited events + +Two distinct kinds share this topic, and they do not share a schema. + +**`notify_filelist_changed`** — by volume, the busiest thing on the connection, and the +mechanism behind the camera (see below). Note `root`, which namespaces the path: + +```json +{"jsonrpc": "2.0", "method": "notify_filelist_changed", + "params": [{"action": "delete_file", + "item": {"modified": 1787566383.19, "size": 90826, + "permissions": "r", "path": "monitor.jpg", "root": "timelapse"}}]} +``` + +**A bare payload with no envelope at all** — no `jsonrpc`, no `method`, no `id`: + +```json +{"server": "online"} +``` + +A parser that assumes JSON-RPC on this topic will throw on it. This one refers to the +printer's *local* Moonraker; the cloud's equivalent is `LAVA/notification` below, and the +two disagree by design. + +### `moonraker/response` — identity, unsolicited + +Shared, and pushed about every two seconds with `id: -1` — a broadcast wearing a reply's +envelope, answering nothing: + +```json +{"jsonrpc": "2.0", "result": {"state": "success", "device_name": "U1 G", + "logging_out_userid": ""}, "id": -1, "cli_time": -1, "dev_time": 1787566384} +``` + +`id: -1` and `cli_time: -1` are the tell. A client correlating strictly by `id` ignores +these safely; one that treats any `result` as a reply to its last request will corrupt +its own state. + +### `camera/response` — camera replies, duplicated + +Every camera reply lands on `/response` **and** here. Measured directly, by issuing +one request per service family and recording which topics answered each `id`: + +| Request | Answered on | +|---|---| +| `printer.objects.list` | `/response` | +| `server.info` | `/response` | +| `server.files.get_directory` | `/response` | +| `camera.get_timelapse_instance` | `/response` **+ `camera/response`** | +| `camera.start_monitor` | `/response` **+ `camera/response`** | + +Two consequences. Orca's four-topic subscription misses nothing — everything is on +`/response`. But the duplicate is **not scoped to the requester**, and timelapse +listings carry embedded JPEG thumbnails, so any client authorised onto this broker sees +them: + +``` +result.instances[0].thumbnail_base64 3,747 chars +result.instances[1].thumbnail_base64 5,367 chars +``` + +### `mqtt_agent/notification` — the cloud agent + +```json +{"jsonrpc": "2.0", "method": "notify_mqtt_agent_status", + "params": [{"state": "disconnected", "userid": "", "username": ""}]} +``` + +`state` is the printer's link to Snapmaker's cloud, **not** to us. It reads +`disconnected` throughout a healthy LAN session. Rendering a connection indicator from +this field shows a working printer as offline — the same trap `DeviceInfo.connected` +sets, from the opposite direction. + +### `LAVA/notification` — cloud reachability + +```json +{"server": "offline"} +``` + +Bare, like the `/notification` variant, and the counterpart to it: `LAVA` is the +cloud service (the printer runs as user `lava`, and gcodes live under `/home/lava/`). +`offline` here alongside `online` on `/notification` is a consistent picture, not a +contradiction — local up, cloud down. + +## What this settles about the camera + +[STATUS.md](../STATUS.md) listed camera frames as an unverified pass-through, with +`pickFrame()` sniffing field names for base64 in an MQTT push. The topic survey shows why +no field name would have worked: **frames never travel over MQTT.** + +``` +camera.start_monitor {domain: "lan", interval: 2, expect_pw: false} + → {"state": "success", "url": "/files/camera/monitor.jpg"} +``` + +The printer then rewrites a single file on its own filesystem at the monitor interval, +which is what the flood of `notify_filelist_changed` for `monitor.jpg` is. The frame is +fetched over HTTP from Moonraker's file server — verified, 96,001 bytes with a JPEG SOI +marker: + +``` +http://:7125/server/files/timelapse/monitor.jpg +``` + +Note `domain` must be `"lan"`. The reconstruction sends `""`, which the printer rejects: + +```json +{"error": {"code": -32000, "message": "Start monitor failed"}} +``` + +Port 80 also answers, but serves the printer's own web UI and returns that SPA's HTML for +this path — a client that fetches it and finds 2,934 bytes of `text/html` has hit the +wrong port, not a broken camera. + +## Reproducing + +```bash +# enumerate topics on both legs, write the overview +python3 docs/u1-webui/tools/u1_topics.py --md /tmp/topics.md --json /tmp/topics.json + +# response shapes for files, thumbnails and camera +python3 docs/u1-webui/tools/u1_probe.py --out /tmp/shapes.json +``` + +Both read the device record out of Orca's config, take `--ip` / `--sn` / `--client-id` to +override it, and speak MQTT directly — Orca does not need to be running, and should not +be: the session leg authenticates with the saved `clientId`, and a broker evicts the older +holder of a duplicate client id. + +Both are read-only. Every method they send queries, lists or subscribes; none moves the +machine or touches a print job. Certificates are held in a temporary file for the life of +the socket and never reach a report. + +The MQTT client underneath is [`tools/mqtt_min.py`](../tools/mqtt_min.py) — MQTT 3.1.1 in +about 200 lines of standard library, because `paho-mqtt` is not installable in this +environment. It implements only CONNECT, SUBSCRIBE, PUBLISH and PINGREQ, which is the +whole of what this protocol needs. diff --git a/docs/u1-webui/06-errors/01-live-error-catalogue.md b/docs/u1-webui/06-errors/01-live-error-catalogue.md new file mode 100644 index 00000000000..983bf7f457b --- /dev/null +++ b/docs/u1-webui/06-errors/01-live-error-catalogue.md @@ -0,0 +1,83 @@ +# Error codes + +## Two tables + +The bundle ships two unrelated error tables. Only the second is the U1's. + +| Table | Source | Shape | Status | +|---|---|---|---| +| Legacy | `assets/files/deviceError.json` | 8 hex digits, `{ecode, intro}` | Inherited from the Bambu-derived codebase — its text still refers to "Bambu Cloud" and "AMS". Not U1. | +| **Live** | `assets/i10n/{en,zh-CN}.json` | 16 hex digits, `error__title` + `error__desc` | **442 codes**, U1-specific, localised. | + +Generated catalogue: [`data/error-catalog.json`](../data/error-catalog.json), built by +[`tools/extract_error_catalog.py`](../tools/extract_error_catalog.py). The legacy table is +kept verbatim at [`data/error-codes.json`](../data/error-codes.json). + +## Code structure + +Each live code is 16 hex digits — four 16-bit groups: + +``` + 0002 0523 0001 0000 + ──── ──── ──── ──── + class subsys unit specific +``` + +| Group | Observed values | Meaning | +|---|---|---| +| **class** | `0001` (48), `0002` (248), `0003` (146) | Error class / severity | +| **subsystem** | 10 values, see below | Which subsystem raised it | +| **unit index** | `0000`–`0005`, `0100` | Toolhead index for per-head faults (`0000`–`0003`); `0000` also means machine-wide | +| **specific** | 55 values | The particular fault | + +The class field almost certainly maps to `DeviceException.level`, which the bundle models +as `DeviceException{index, code, level, message}` — `index` matching the unit group. The +exact severity semantics of `0001`/`0002`/`0003` are **not confirmed** from the client +alone; only the distribution and the message text are directly observed. + +### Subsystems + +| Code | Subsystem | Count | Representative titles | +|---|---|---:|---| +| `0523` | Toolhead | 195 | Toolhead Picking Failure, Toolhead Swapping Anomaly, Nozzle Temperature Anomaly | +| `0525` | Filament | 96 | Filament Anomaly, Filament Pre-loading Anomaly, Extrusion Anomaly | +| `0530` | Calibration / detection | 53 | Calibration Anomaly, Heated Bed Detection Anomaly, Homing Calibration Failed | +| `0522` | System / motion | 42 | System Anomaly, System Failed to Start, Axis Movement Anomaly | +| `0528` | Homing / axis | 30 | Homing Anomaly, Axis Movement Anomaly, Sensor Anomaly | +| `0531` | Job / storage | 17 | Print Start Failed, Modification Failed, Storage Space Low | +| `0532` | Defect detection (vision) | 4 | Possible Foreign Object Detected on Print Bed, Possible Spaghetti Defect Detected | +| `0533` | Top cover / chamber | 2 | Top Cover Anomaly, Chamber Temperature Anomaly | +| `0526` | Heated bed | 2 | Heated Bed Temperature Abnormal | +| `0527` | Chamber | 1 | Chamber Temperature Abnormal | + +The distribution is itself informative: **two thirds of all faults are toolhead or +filament faults**, which is what a four-head tool-changer with automatic filament handling +would predict. + +## Per-toolhead expansion + +Per-head faults occupy consecutive unit indices. The same fault appears four times: + +| Code | Title | Description | +|---|---|---| +| `0002052300000000` | Filament Anomaly | Filament runout detected in **Toolhead 1** | +| `0002052300010000` | Filament Anomaly | Filament runout detected in **Toolhead 2** | +| `0002052300020000` | Filament Anomaly | Filament runout detected in **Toolhead 3** | +| `0002052300030000` | Filament Anomaly | Filament runout detected in **Toolhead 4** | + +So a client should decode the unit group rather than table-matching all 442 codes: +`toolhead = unit_index + 1` for subsystem `0523`/`0525` faults. + +## Retrieval + +Active faults are pulled with `server.exception.query` (bridge: `sw_exception_query`) and +pushed as part of the `defect_detection` and `machine_state_manager` objects — +`machine_state_manager.action_code` carries the code that the UI turns into a title/desc +pair via this table. + +## Vision-based detection + +Subsystem `0532` corresponds to the features named in `version.changelog` for 2026-07-27: +"Filament Tangle Detection" and "Foreign Object Detection". These are configured through +`printer.defect_detection.config` (bridge: `sw_PrinterDefectDetection`, +`sw_DefectDetactionConfig` — note the typo in Orca's handler name). diff --git a/docs/u1-webui/06-errors/02-legacy-error-asset.md b/docs/u1-webui/06-errors/02-legacy-error-asset.md new file mode 100644 index 00000000000..b194486cc20 --- /dev/null +++ b/docs/u1-webui/06-errors/02-legacy-error-asset.md @@ -0,0 +1,132 @@ +# Legacy error asset (`deviceError.json`) + +> **This is the secondary table.** The U1's live faults are the 442-code, +> 16-hex-digit catalogue in [`01-live-error-catalogue.md`](01-live-error-catalogue.md), +> carried in the i18n table as `error_<16 hex>_title` / `_desc` pairs. The 72 codes +> below come from `assets/assets/files/deviceError.json`, an 8-hex-digit asset +> inherited from the Bambu-derived codebase — which is why several of its strings +> still say "Bambu Cloud" and "AMS". It ships, but it is not the live fault table. + +Generated by [`tools/gen_legacy_error_doc.py`](../tools/gen_legacy_error_doc.py) from +[`data/error-codes.json`](../data/error-codes.json). Do not edit by hand. + +72 codes, in `en` and `zh-cn`. + +## Encoding + +Eight hex digits — note this is a *different* scheme from the live catalogue's +sixteen: + +``` + 0300 4 000 + ^^^^ ^ ^^^ + | | └── fault index within the subsystem + | └──── severity + └───────── subsystem +``` + +| Prefix | Count | Subsystem | +|---|---|---| +| `0300` | 23 | printer core - motion, homing, bed and nozzle temperature | +| `0500` | 9 | print-job submission and cloud transport | +| `0700` | 9 | filament path, toolhead 0 | +| `0701` | 9 | filament path, toolhead 1 | +| `0702` | 9 | filament path, toolhead 2 | +| `0703` | 9 | filament path, toolhead 3 | +| `0C00` | 4 | defect detection (first layer / spaghetti) | + +The `0700`–`0703` banks carry an identical nine-fault set, once per toolhead — +which is what identifies the third byte as a toolhead index. + +| Severity digit | Count | Meaning | +|---|---|---| +| `4` | 22 | error - the job cannot continue | +| `8` | 48 | pause / actionable - the user can retry or resume | +| `C` | 2 | warning - a possible defect, resume permitted | + +## Placeholders + +8 codes ship with an **empty string in every locale** — +`xxxx8007` and `xxxx8010` in each of the four toolhead banks: + +``` + 07008007 07008010 07018007 07018010 07028007 07028010 07038007 07038010 +``` + +## Full table + +| Code | Severity | Text (en) | +|---|---|---| +| `03004000` | `4` | Homing Z axis failed. | +| `03004001` | `4` | The printer timed out waiting for the nozzle to cool down before homing. | +| `03004002` | `4` | Mesh bed leveling failed. | +| `03004003` | `4` | Nozzle temperature malfunction. | +| `03004004` | `4` | Heatbed temperature malfunction. | +| `03004005` | `4` | The heatbreak fan speed is abnormal. | +| `03004006` | `4` | The nozzle is clogged. | +| `03004008` | `4` | The AMS failed to change filament. | +| `03004009` | `4` | Homing XY axis failed. | +| `0300400A` | `4` | Mechanical resonance mode identification failed. | +| `0300400B` | `4` | Internal communication exception. | +| `0300400C` | `4` | Printing was cancelled. | +| `0300400D` | `4` | Resume failed after power loss. | +| `03008000` | `8` | Printing was paused for unknown reason. You can click resume icon to resume the print job. | +| `03008001` | `8` | Printing was paused by the user. You can click resume icon to resume the print job. | +| `03008002` | `8` | Obvious defects were detected in the first layer. If the defects are acceptable, click resume icon to resume the print job. | +| `03008003` | `8` | A possible printing failure was detected. If the defects are acceptable, click resume icon to resume the print job. | +| `03008004` | `8` | The filament ran out. Please load new filament in Temperature/Axis and click resume icon to resume the print job. | +| `03008005` | `8` | Printing was paused because the front cover of tool head fell. Please mount it back and click resume icon to resume the print job. | +| `03008006` | `8` | The build plate is not the same as that is in G-code. Please change to other plate and click resume icon to resume the print job. | +| `03008007` | `8` | There is unfinished print job when power off, click resume icon to resume the print job. | +| `03008008` | `8` | Nozzle temperature malfunction. | +| `03008009` | `8` | Heatbed temperature malfunction. | +| `05004001` | `4` | Failed to connect to Bambu Cloud. Please check your network connection. | +| `05004002` | `4` | Unable to parse the received print job. Please update the firmware to the latest version and try again. | +| `05004003` | `4` | There was a problem parsing gcode.3mf. Please resend the printing job. | +| `05004004` | `4` | Printing jobs are not allowed to be sent while printing. | +| `05004005` | `4` | Print jobs are not allowed to be sent while updating firmware. | +| `05004006` | `4` | There is not enough free storage space for the print job. Restoring to factory settings can release available space. | +| `05004007` | `4` | Printing jobs are not allowed to be send while force upgrading or repair upgrading is required. | +| `05004008` | `4` | Starting printing failed. please power cycle the printer and resend the print job. | +| `05004009` | `4` | Print jobs are not allowed to be sent while updating logs. | +| `07008001` | `8` | Failed to cut the filament. Please check the cutter. After troubleshooting, click the retry button. | +| `07008002` | `8` | The cutter is stuck. Please pull out the cutter handle and click the retry button. | +| `07008003` | `8` | Failed to pull out the filament from the extruder. Please check whether the nozzle is clogged or whether the the filament is broken inside extruder. After troubleshooting, click the retry button. | +| `07008004` | `8` | Failed to pull back the filament to AMS. Please check whether the filament or the spool is stuck. After troubleshooting, click the retry button. | +| `07008005` | `8` | Failed to feed the filament outside AMS. Please clean the filament tip and check whether the spool is stuck. After troubleshooting, click the retry button. | +| `07008006` | `8` | Failed to feed the filament into the toolhead. Please check whether the filament or the spool is stuck. After troubleshooting, click the retry button. | +| `07008007` | `8` | *(empty — placeholder)* | +| `07008010` | `8` | *(empty — placeholder)* | +| `07008011` | `8` | AMS filament ran out. Please put a new filament into AMS, loosen the PTFE tube connector behind the printer, pull out the old filament, reinstall the PTFE tube connector and click the retry button. | +| `07018001` | `8` | Failed to cut the filament. Please check the cutter. After troubleshooting, click the retry button. | +| `07018002` | `8` | The cutter is stuck. Please pull out the cutter handle and click the retry button. | +| `07018003` | `8` | Failed to pull out the filament from the extruder. Please check whether the nozzle is clogged or whether the the filament is broken inside extruder. After troubleshooting, click the retry button. | +| `07018004` | `8` | Failed to pull back the filament to AMS. Please check whether the filament or the spool is stuck. After troubleshooting, click the retry button. | +| `07018005` | `8` | Failed to feed the filament outside AMS. Please clean the filament tip and check whether the spool is stuck. After troubleshooting, click the retry button. | +| `07018006` | `8` | Failed to feed the filament into the toolhead. Please check whether the filament or the spool is stuck. After troubleshooting, click the retry button. | +| `07018007` | `8` | *(empty — placeholder)* | +| `07018010` | `8` | *(empty — placeholder)* | +| `07018011` | `8` | AMS filament ran out. Please put a new filament into AMS, loosen the PTFE tube connector behind the printer, pull out the old filament, reinstall the PTFE tube connector and click the retry button. | +| `07028001` | `8` | Failed to cut the filament. Please check the cutter. After troubleshooting, click the retry button. | +| `07028002` | `8` | The cutter is stuck. Please pull out the cutter handle and click the retry button. | +| `07028003` | `8` | Failed to pull out the filament from the extruder. Please check whether the nozzle is clogged or whether the the filament is broken inside extruder. After troubleshooting, click the retry button. | +| `07028004` | `8` | Failed to pull back the filament to AMS. Please check whether the filament or the spool is stuck. After troubleshooting, click the retry button. | +| `07028005` | `8` | Failed to feed the filament outside AMS. Please clean the filament tip and check whether the spool is stuck. After troubleshooting, click the retry button. | +| `07028006` | `8` | Failed to feed the filament into the toolhead. Please check whether the filament or the spool is stuck. After troubleshooting, click the retry button. | +| `07028007` | `8` | *(empty — placeholder)* | +| `07028010` | `8` | *(empty — placeholder)* | +| `07028011` | `8` | AMS filament ran out. Please put a new filament into AMS, loosen the PTFE tube connector behind the printer, pull out the old filament, reinstall the PTFE tube connector and click the retry button. | +| `07038001` | `8` | Failed to cut the filament. Please check the cutter. After troubleshooting, click the retry button. | +| `07038002` | `8` | The cutter is stuck. Please pull out the cutter handle and click the retry button. | +| `07038003` | `8` | Failed to pull out the filament from the extruder. Please check whether the nozzle is clogged or whether the the filament is broken inside extruder. After troubleshooting, click the retry button. | +| `07038004` | `8` | Failed to pull back the filament to AMS. Please check whether the filament or the spool is stuck. After troubleshooting, click the retry button. | +| `07038005` | `8` | Failed to feed the filament outside AMS. Please clean the filament tip and check whether the spool is stuck. After troubleshooting, click the retry button. | +| `07038006` | `8` | Failed to feed the filament into the toolhead. Please check whether the filament or the spool is stuck. After troubleshooting, click the retry button. | +| `07038007` | `8` | *(empty — placeholder)* | +| `07038010` | `8` | *(empty — placeholder)* | +| `07038011` | `8` | AMS filament ran out. Please put a new filament into AMS, loosen the PTFE tube connector behind the printer, pull out the old filament, reinstall the PTFE tube connector and click the retry button. | +| `0C008001` | `8` | Obvious defects were detected in the first layer. If the defects are acceptable, click resume icon to resume the print job. | +| `0C008002` | `8` | Spaghetti failure was detected. | +| `0C00C003` | `C` | Possible defects were detected in the first layer. | +| `0C00C004` | `C` | Possible spaghetti failure was detected. | + diff --git a/docs/u1-webui/README.md b/docs/u1-webui/README.md new file mode 100644 index 00000000000..383086ecff6 --- /dev/null +++ b/docs/u1-webui/README.md @@ -0,0 +1,98 @@ +# Snapmaker U1 embedded web UI — reverse engineering + +Two of the surfaces Snapmaker Orca shows for a Snapmaker U1 are not native C++. +Both are the **same compiled Flutter web application**, shipped in this repo at +`resources/web/flutter_web/`, served over a loopback HTTP server and displayed in a +`wxWebView` at different routes. + +| Surface | Route | Host window | Docs | Reconstruction | +|---|---|---|---|---| +| **Device tab** | `?path=2` | [`PrinterWebView`](../../src/slic3r/GUI/PrinterWebView.cpp) | [`02-device-page/`](02-device-page/) | `resources/web/device_page/` | +| **Print processing** — upload and print | `?path=4` | [`WebPreprintDialog`](../../src/slic3r/GUI/WebPreprintDialog.cpp) | [`03-print-processing/`](03-print-processing/) | `resources/web/print_processing/` | +| **Print processing** — upload only | `?path=5` | the same dialog | [`03-print-processing/`](03-print-processing/) | same, `?mode=upload` | + +Each surface has its own documentation folder and its own reconstruction. What they +have in common is factored out explicitly — see +[what the two surfaces share](00-shared/01-shared-models.md). + +**Orca loads the reconstructions by default** on this branch. The shipped Flutter pages +are one config flag away — see +[serving the reconstructions in Orca](00-shared/03-serving-in-orca.md), which also lists +what the reconstructions do not cover. + +**Continuing this work?** Start at [`STATUS.md`](STATUS.md) — what is proven against +real hardware, what is not, and what to pick up next. If you are about to *change* the +Device page, read +[how it is put together](STATUS.md#how-the-device-page-is-put-together) first: it is one +directory per panel now, and the tooling checks that a command is reachable rather than +merely mentioned. The reasoning is in +[`02-device-page/09-restructure.md`](02-device-page/09-restructure.md). + +## Layout + +| Path | Contents | +|---|---| +| [`STATUS.md`](STATUS.md) | **Handoff** — verified vs unverified, how to debug against hardware, next steps | +| [`00-shared/`](00-shared/) | **What both surfaces share**, the build badge, and how the reconstructions are served in Orca | +| [`01-architecture/`](01-architecture/) | Layer model, bundle inventory, entry points and routes | +| [`02-device-page/`](02-device-page/) | **Surface 1** — screens, state machine, controls, reconstruction | +| [`03-print-processing/`](03-print-processing/) | **Surface 2** — lifecycle, filament mapping, reconstruction | +| [`04-bridge-wcp/`](04-bridge-wcp/) | The `window.wx` bridge: envelope, command catalogue, parameters | +| [`05-printer-protocol/`](05-printer-protocol/) | Moonraker-derived JSON-RPC, MQTT, state model, cloud API | +| [`06-errors/`](06-errors/) | The live 442-code catalogue, and the legacy 72-code asset | +| [`data/`](data/) | Machine-readable extraction output — every file is generated | +| [`reconstructed/`](reconstructed/) | Recovered Dart declarations, rendered as readable source | +| [`tools/`](tools/) | The extractors, the doc generators, and the screenshot harness | + +## Headline findings + +1. **The page owns the protocol, not Orca.** The bundle builds complete JSON-RPC + envelopes — method names, parameter maps, subscription field filters — and hands + them down. The C++ transports, authenticates and applies policy. +2. **The bridge success code is `200`, not `0`.** A response of `code: 0` is treated as + a *failure* by the page. Confirmed three ways in + [the envelope](04-bridge-wcp/01-envelope.md). +3. **The U1 runs Klipper**, behind a Moonraker-derived JSON-RPC 2.0 API with Snapmaker + extensions (`printer.control.*`, `camera.*`, `custom.*`). +4. **Transport is MQTT 5 over mutual TLS**, topics namespaced by serial: + `/request`, `/response`, `/status`, `/notification`. +5. **Two error tables ship.** The live one is 442 codes in 16 hex digits, carried in the + i18n table; the 72-code `deviceError.json` asset is a Bambu-era leftover. Both are + documented, and which is which matters. +6. **`?path=4` and `?path=5` resolve to swapped `AppModule` indices** — see + [entry points](01-architecture/03-entry-points.md). +7. **Everything minified survives as string literals.** 274 enums, 1,370 enum values, + 163 class names — see [`reconstructed/enums.dart`](reconstructed/enums.dart). +8. **DevTools are unconditionally enabled** in shipping builds, so the live page can be + inspected directly. + +## Reproducing + +Every file under `data/`, `reconstructed/`, and the generated markdown regenerates from +the bundle and the C++: + +```bash +python3 docs/u1-webui/tools/run_all.py +``` + +Verify the reconstructions still match that evidence: + +```bash +python3 resources/web/shared/tests/conformance_test.py +``` + +Screenshots of the shipped bundle come from the +[harness](tools/harness/README.md); screenshots of the reconstructions come from +serving `resources/web/` and loading either surface with `?mock=1`. + +## Scope and confidence + +This documents the **client side**: the bundle in this repo and the Orca C++ it talks +to. Printer-side behaviour is inferred from the client contract and from +[`MoonRaker.cpp`](../../src/slic3r/Utils/MoonRaker.cpp), not from firmware — anything not +directly observed is marked *inferred* where it appears. + +Findings are drawn from four sources that cross-check each other: the compiled bundle, +the C++ host, live behaviour captured by the harness, and the reconstructions plus their +conformance tests. Where sources disagree, the disagreement is recorded rather than +resolved silently. diff --git a/docs/u1-webui/STATUS.md b/docs/u1-webui/STATUS.md new file mode 100644 index 00000000000..2b5cfadabc3 --- /dev/null +++ b/docs/u1-webui/STATUS.md @@ -0,0 +1,1162 @@ +# Status and handoff + +Where this work stands, what is proven and what is not, and what to pick up next. + +Last verified against hardware: **2026-09-01**, a Snapmaker U1 (`811002511261022618B3`) +on the LAN at `192.168.2.242`, through Orca 2.3.6 built from this branch and through +`run_webkit.py --real`, which reaches the printer with no Orca at all. That day's pass +was over Moonraker HTTP: the live `head ↔ multi` mode switch, run and restored — +[02-device-page/11-multiace-handover.md](02-device-page/11-multiace-handover.md#start-here-the-panel-has-only-ever-been-right-in-one-of-the-three-modes) +has the results, [data/ace-mode-switch-20260901.json](data/ace-mode-switch-20260901.json) +the payloads, and +[02-device-page/multiace-modes.html](02-device-page/multiace-modes.html) — the seventh +study — the drawings for all three modes. + +## What exists + +| | | +|---|---| +| `docs/u1-webui/` | The reverse engineering: architecture, both surfaces, bridge, printer protocol, errors | +| `docs/u1-webui/tools/` | 19 extractors and generators; `run_all.py` regenerates everything under `data/` and `reconstructed/` | +| `docs/u1-webui/tools/u1_bridge.py` | Answers the page's bridge commands by talking to a real U1, with no Orca | +| `docs/u1-webui/tools/harness/` | Runs the **shipped** Flutter bundle headlessly against a simulated host | +| `resources/web/shared/` | Bridge client, protocol constants, state store, simulated host, fault catalogue | +| `resources/web/device_page/` | Reconstructed **Device tab** — see below | +| `resources/web/print_processing/` | Reconstructed **print-processing popup** | + +Orca shows either implementation. `PrinterWebView` has an **Original / Rebuilt** switcher; +the choice persists in the `u1_reconstructed_ui` app-config key. + +## How the Device page is put together + +Read this before changing it. The page has **two destinations** in one webview — Device +control and Storage — switched by the left rail with no reload, no URL change, and the +printer session untouched. + +**[`js/registry.js`](../../resources/web/device_page/js/registry.js) is the table of +contents:** the destinations, and which panels each one has. Everything inside +`.content` is built from it, so `index.html` is 42 lines of shell. + +**One panel is one directory**, and everything about it is in there: + +``` +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 +``` + +| destination | panels | +|---|---| +| **Device control** | Camera, Control, Printing Task, Filament | +| **Storage** | Storage | +| *neither* (`view: null`) | the fault banner — a fault is about the machine, not the view | + +A panel is handed **its own commands and nothing else**, so reaching for another panel's +command is a `TypeError` rather than a quiet dependency. Three things it may read, and +they 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 a reason worth carrying: **a request stored in the thing +that mirrors the machine gets overwritten by the next push.** That bug was found in three +unrelated controls before it got one mechanism — +[`core/pending.js`](../../resources/web/device_page/js/core/pending.js). + +Two more single-answer modules, each replacing several hand-rolled ones: +[`core/render.js`](../../resources/web/device_page/js/core/render.js) (build once, key +structural change on a signature, reconcile lists by key) and +[`core/session.js`](../../resources/web/device_page/js/core/session.js) (connect, +staleness, retry, heartbeat, the state stream — no panel reads any of it). + +The whole story, pass by pass, including why Flutter was rejected and the bugs each pass +turned up, is [02-device-page/09-restructure.md](02-device-page/09-restructure.md). + +## Verified against the real printer + +The full connect path, end to end: + +``` +create plain client mqtt://:1884 → connect +subscribe 12345678/config/response +publish server.client_manager.request_lan_auth {clientid, app_id} + + cli_time / dev_time ← REQUIRED, see below +receive {state:success, sn, ca, cert, key} +create mqtts client mqtts://:8883 with those keys → connect +subscribe /status, /response, /notification +sw_mqtt_set_engine → snapshot ok: 24 objects → live subscription +``` + +Three findings that only hardware exposed. All three are written up in +[02-device-page/06-connection.md](02-device-page/06-connection.md): + +1. **`sw_mqtt_set_engine` does not connect anything.** `SSWCP.cpp:6643` hardcodes + `bool res = true;` so `Moonraker_Mqtt::connect()` — and therefore + `ask_for_tls_info()` — is never called. The host expects an already-connected mTLS + engine. +2. **The auth method is `server.client_manager.request_lan_auth`**, not + `server.request_key`. This firmware answers the latter with `-32601 Method not found`. +3. **`cli_time` and `dev_time` are mandatory** on every request. Without them the printer + does not reply *or* error — it ignores the message. That silence is expensive to + debug; if a request seems to vanish, check these first. + +Also load-bearing, and both counter-intuitive: + +- `DeviceInfo.connected` is **forced false on every config save** (`AppConfig.cpp:887`). + It is runtime-only and says nothing about reachability. Use live machine state. +- Certificates are **never persisted**: `SSWCP.cpp` blanks them + (`info.ca = /* auth_info["ca"] */ "";`). Every start re-runs the key exchange. + +### Two more the machine settled, 2026-09-01 + +Both were built, checked green offline, and wrong. They are here because the shape of +the mistake repeats: a field that is *usually* true after an operation is not a field +that says the operation finished. + +- **`homed_axes` does not report homing.** A G28 was watched end to end (42 s, sampled + every 200 ms) and it read `"xyz"` from the first sample to the last — the machine was + already homed and this firmware's G28 never clears it. Used as the wait's `done()` it + was true before the machine moved, and being tested first it beat every other check, + so the dialog shut over a bed that then travelled for another fourteen seconds. Nor + can `live_velocity` patch it: it reads exactly `0` for two seconds at a time between + the homing moves, eight times across those 42 s. `idle_timeout` bracketed the + operation exactly — `Ready`, `Printing` for forty seconds, `Ready` — and is what the + wait ends on now. The toolchange trace in `state.js` *does* show `homed_axes` + clearing; that is `G28 X Y` under a homing_override, a different path, and reading it + as a general fact is what cost two attempts. `drive/homing-real.js` presses the real + button; `drive/homing.js` replays the trace offline. +- **A recording has no unique name.** Sixty recordings carry `date_index`, `gcode_name`, + `gcode_path`, `generate_date`, `thumbnail_base64`, `thumbnail_path`, `timelapse_dir`, + `unix_timestamp_s`, `video_duration`, `video_file_size`, `video_local_url_suffix`, + `video_path` — and no `name`, no `id`. So Storage keyed its cards on `gcode_name`, + which repeats whenever a file is recorded twice: 48 distinct keys across 60 + recordings, one of them four times. Two nodes cannot share one key, so the grid drew + **72 cards for 60 items** and grew at every repaint. `date_index` is unique across all + sixty and is what the printer addresses a recording by. `keyedList` no longer leaks on + a duplicate key either. Only hardware has enough recordings to show this; the + simulator holds three. `drive/storage-real.js` is the witness, + `drive/storage-paging.js` poses the collision offline. + +## Not verified + +Everything below was built against the simulator in `shared/js/mockhost.js`, which was +written from the same reading of the C++ as the client. That proves the two agree; it +does **not** prove the printer agrees. The connect path above is exactly where that +distinction turned out to matter. + +Two of the three response shapes below have since been **settled against the printer** +(2026-08-24) with [`tools/u1_probe.py`](tools/u1_probe.py) and +[`tools/u1_topics.py`](tools/u1_topics.py), which speak MQTT directly and do not need Orca +running. Both guesses were wrong, and in the camera's case wrong about the transport, not +just the field name: + +- **camera frames** — *not base64 over MQTT at all*. `camera.start_monitor` needs + `domain: "lan"` (`""` is rejected `-32000`) and returns + `{state, url: "/files/camera/monitor.jpg"}`. The printer then rewrites that one file at + the monitor interval — the flood of `notify_filelist_changed` for `monitor.jpg` — and + the frame is fetched over HTTP from Moonraker's file server at + `http://:7125/server/files/timelapse/monitor.jpg`. Verified: 96,001 bytes, JPEG SOI + marker. This is fixed: the frame is fetched over HTTP by `cameraFrameUrl()` in + `device_page/js/views/device-control/camera/camera-view.js`. +- **file thumbnails** — `server.files.thumbnails` returns + `[{width, height, size, thumbnail_path}]`: **paths, no image data**. The base64 lives + behind `server.files.thumbnails_base64`, as `{width, height, size, data, state, path}`. + This is fixed: `pickThumb()` lives in `device_page/js/core/thumbs.js` and the base64 + command is asked **first**, because the path-returning one succeeds while returning no + image — so a `.catch()` fallback never fired and a real printer showed no thumbnail. +- **discovery results** — still open. `sw_StartMachineFind` is Orca's own LAN discovery + (Bonjour, `SSWCP.cpp:1789`), not an MQTT pass-through, so these probes cannot reach it; + it needs the running app. + +The full topic map is [05-printer-protocol/06-mqtt-topics.md](05-printer-protocol/06-mqtt-topics.md). +Subscribing the `#` wildcard found **seven** topics on the session leg where reading the +C++ had found four — `moonraker/response`, `camera/response`, `mqtt_agent/notification` +and `LAVA/notification` are shared, carry no serial number, and Orca never subscribes to +any of them. + +## How to work on this + +**UI changes need no rebuild.** `build/resources` is a symlink to `resources/`, the HTTP +server reads files per request, and it sends no cache headers. Edit and reload the page. +Only C++ changes need `ninja -C build -f build-Release.ninja snapmaker-orca` — and Orca +must be closed first or the link fails with `ETXTBSY`. + +```bash +# regenerate every data file and generated doc +python3 docs/u1-webui/tools/run_all.py + +# constants vs evidence, and command coverage +python3 resources/web/shared/tests/conformance_test.py + +# the page itself, driven in WebKitGTK - the engine Orca renders with +python3 resources/web/shared/tests/run_webkit.py --shots /tmp/shots + +# the same page, against the REAL printer, with no Orca at all (Orca must be closed) +python3 resources/web/shared/tests/run_webkit.py --real --watch # use it by hand +python3 resources/web/shared/tests/run_webkit.py --real # check and exit +python3 resources/web/shared/tests/run_webkit.py --real --drive script.js # scripted + +# the pure logic, in JavaScriptCore, against captured hardware payloads +python3 resources/web/shared/tests/unit_jsc.py + +# either surface standalone +python3 -m http.server 8099 --directory resources/web +# /device_page/index.html?mock=1 +# /print_processing/index.html?mock=1 (&mode=upload for the ?path=5 variant) +``` + +### What each suite can and cannot see + +Worth knowing before you trust a green run, because **all of them have been green while +the page was visibly broken**: + +| | sees | blind to | +|---|---|---| +| `conformance_test.py` | that a decision is present in the source, and matches the write-up | whether it runs | +| `unit_jsc.py` | pure logic against captured payloads, on an injected clock | anything needing a DOM | +| `run_webkit.py` | the real engine: focus, selection, layout, computed style | only what its checks look at | +| `check_coverage.py` | every command implemented **and** reachable from a control | whether the control works | + +Two failures make the point. A `ReferenceError` in `jogWheel` left the page with no +motion column and **all 17 browser checks passed**, because they cover the temperature +rows. Later a rename broke `boot()` and the simulator stayed green because the broken +line was on the *not-connected* branch and the mock device reports connected. + +So two habits are worth keeping: + +- **Dump the DOM and diff it.** A drive script that walks `.app` and prints every tag, + id, class and `data-*` answers *what is on the page* rather than *is this one thing + right*. It caught both failures above, and it is how every restructure pass was shown + to change nothing. Run it before and after any move. +- **`--real --device-ip 192.0.2.1`** forces the not-connected branch with no printer + involved. Run it on anything touching the device record, not only when testing the + nothing-there path. +- **Start the app with the printer switched off.** An unreachable printer used to abort + Orca at launch — `terminate called after throwing an instance of 'mqtt::exception'` — + because Paho reports failure by throwing and `MqttClient::Connect()` had no handler + despite being documented to return `false`. Fixed, with the same guard on `Subscribe`, + `Unsubscribe`, `Publish` and `CheckConnected`; round eight of + [08-function-gap-analysis.md](02-device-page/08-function-gap-analysis.md). It stays worth + running, because it is the one startup path nothing else exercises. +- **Load the page from Orca's own server, not only from the harness.** `run_webkit.py` + serves the tree from Python, so it cannot see anything the *app's* HTTP server does — + and on 2026-08-26 the app's server was silently losing one module request per load, + which left the Device tab blank in Orca while all three suites were green. With Orca + running, point WebKitGTK at `http://127.0.0.1:13619/web/device_page/index.html` and + read two numbers: `document.readyState` must reach **`complete`**, and no resource may + still be pending. Round seven of + [08-function-gap-analysis.md](02-device-page/08-function-gap-analysis.md) has the whole + story; the short version is that the trigger was a URL 29 characters longer than it + used to be. + +`paint()` catches per panel now, so a throwing panel says so on the page and the others +still paint — but it will not tell you a panel is *missing*, only that one failed. It +will not tell you the *document* never ran, either: that shows up as a shell with an +empty `.content` and a silent console. + +**`--shots` writes blank PNGs here.** EGL finds no driver under WSL, so the snapshot +surface paints nothing and the files compare byte-identical before and after any change. +The DOM assertions are the evidence; the images never were. + +### Debugging against real hardware + +Two ways in. The **direct** one needs no GUI at all and is usually what you want: + +```bash +python3 docs/u1-webui/tools/u1_probe.py --out /tmp/shapes.json # response shapes +python3 docs/u1-webui/tools/u1_topics.py --md /tmp/topics.md # every topic +``` + +They read the device from Orca's config, run the documented connect path over a +200-line standard-library MQTT client, and are read-only. Orca must **not** be running: +they authenticate with the saved `clientId`, and a broker evicts the older holder of a +duplicate id. + +The **in-app** loop below is still the only way to reach anything Orca itself provides — +discovery, the native dialogs, the bridge envelope. It is the loop that found the connect +bugs, and it is worth reusing. + +1. Add `?diag=1` to the page URL to turn on per-command tracing + (`device_page/js/core/diag.js`). +2. Run a collector on `127.0.0.1:8799` that appends POST bodies to a file. The page + beacons every diagnostic line there. +3. Start Orca and read the file. + +The beacon matters: `sw_FileLog` travels over the very bridge being diagnosed, so when +that stalls the evidence disappears exactly when it is needed. Diagnostics land in +Orca's own log too, tagged `[rebuilt-device]`: + +```bash +grep 'rebuilt-device' ~/.config/Snapmaker_Orca/log/*.log.0 +``` + +Two traps worth knowing: never log the logging command (`sw_FileLog` tracing its own +traffic is a live feedback loop), and clean up orphaned `WebKitWebProcess` after a +`kill -9` — otherwise the next run's webview has no `window.wx` and the page silently +falls back to the simulator, which looks like real data and is not. + +## The parity claim was wrong, and why + +`07-parity.md` reported "nothing on the command surface" as missing. That was command +coverage presented as functional coverage, and the two are not the same: `check_coverage` +counts a command as implemented when `CMD.NAME` appears in client source, which proves it +is *mentioned* — not that a control exists, that the call site is reachable, or that the +response renders. A panel with no button still counted every command it would have called. + +Seven user-visible faults were found on real hardware against that "complete" page. The +full write-up, root cause by root cause, is +[02-device-page/08-function-gap-analysis.md](02-device-page/08-function-gap-analysis.md). +The dominant cause was a single line: Orca hands a printer's reply to the page still +wrapped in its JSON-RPC envelope, and the simulator unwrapped it — so every field read +worked in the browser suite and returned `undefined` on a printer. + +All seven are now addressed. Print history needed a genuine addition to Orca — +`sw_GetPrintHistory` over `server.history.list`, through `PrintHost.hpp`, +`MoonRaker.{hpp,cpp}` and `SSWCP.{hpp,cpp}` — so **that one needs a rebuild**; everything +else is page-side and live on reload. + +A second pass found one more of my own: tool selection stored the *request* in the +variable that mirrors the *machine*, so the click's own re-render put it straight back. +It is now a pending-until-confirmed model — and that same bug turned up twice more before +it got one mechanism. + +**The hole is closed at the tool, not just at the instances.** `check_coverage.py` asks +both questions now — *is it implemented*, and *can a user reach it* — and the second is +answered by reading the `CMD.` references out of the command module a panel is actually +handed, so referencing a command is the only way to claim one. It found +`sw_BedMesh_AbortProbeMesh` reachable by nothing, and rejected a `DELETE_MACHINE_FILE` +claim that no code backed. A conformance check asks the remaining half — is every handler +a panel is given actually called — which is what found `showFiles`, dead behind a +disabled button. See [09-restructure.md](02-device-page/09-restructure.md). + +## Where this got to (session ending 2026-08-24) + +The Control panel is **rebuilt and running against real hardware**, driven by a design +study that is still in the tree: +[02-device-page/panel-popover-mockup.html](02-device-page/panel-popover-mockup.html). +Open it — it is self-contained, needs no server and no JavaScript, and every option that +was weighed is still in it (sections 06–09: colour, slider style, column separation, +wheel size), so the decisions can be revisited without re-deriving them. + +**Layout.** Three columns — readings, toolhead picker, motion — at 830 px with equal +gaps. Readings are icon-only using the shipped numbered `iconExtruder1..4`, with the +target temperature editing in place. Quick settings are four tiles (speed, fans, +purifier, light) opening panels anchored under the tile that owns them. The jog wheel is +four quadrants by three bands: **the ring is the step size**, so the old 10/1/0.1 +selector and its hidden state are gone. Z is a separate bed row. + +**Commands, recovered from the shipped bundle rather than guessed:** + +| | | +|---|---| +| pick | `T A0` — the `A0` is not optional; the firmware's own macro sends it | +| park | `PARK_EXTRUDER`, `PARK_EXTRUDER1..3` — numbered like Klipper's extruders | +| bed up | `Z−` — Z measures the nozzle-to-bed gap, so raising the bed is negative | + +Neither pick nor park appears in `printer.gcode.help`: they register without help +strings, as `T0`–`T3` do. **Absence there is not evidence** — reading it as evidence cost +an afternoon. + +**The bug class that dominated this session,** worth stating because it will recur: +*the page kept trusting a field that answers a different question than the one asked.* +Four instances, all found by measuring rather than reasoning: + +- **`toolhead.extruder` does not mean "engaged".** It is Klipper's current-extruder + pointer for G-code, and it goes on naming the last head used after that head is + parked — measured reading `"extruder"` while all four reported `PARKED`. Using it as a + fallback made the panel offer to park a head that was not there: `PARK_EXTRUDER` + returned an instant `ok`, nothing moved, and the wait timed out. Engagement is now + read **only** from `extruder*.state === 'ACTIVATE'`. +- **`toolhead` is not subscribed at all**, so anything read from it is as old as the last + explicit query. `homed_axes` is fetched once a second during a wait for exactly this + reason. +- **`machine_state_manager` is silent for manual work.** It reads + `{main_state: 0, action_code: 0}` straight through a 31-second toolchange and through + homing. So is `extruder_offset_calibration.calibration_step`, which stays `"idle"`. + Both were trusted, and both had nothing to say. +- **`homed_axes: ""` is an answer, not an absence** — the machine saying nothing is + homed. Reading it as *unknown* let every Z jog through to a printer that refuses them, + so the bed buttons looked dead. + +**What a toolchange actually reports** (driven and logged end to end, 2026-08-24): + +``` + 0.7s toolhead.homed_axes "z" idle_timeout "Printing" + 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 "Ready" +``` + +`state.busyReason()` is built on those: `homed_axes` drives the label and names the axes +already done, `activating_move` names the grab, `idle_timeout` brackets the whole thing +and **is** treated as busy. Measured durations: **4 s** already homed, **31 s** from cold. +The 31 s case is what outran the bridge's 15 s request timeout and made a working +toolchange look like a failure — which is why a wait never awaits its own request, and +confirms against machine state instead. + +One trap worth carrying forward: **an instant `ok` is indistinguishable from success.** +`PARK_EXTRUDER2` against a live head blocks for over 20 s; against a head that is not +there it returns immediately and successfully. No G-code ack can tell those apart. + +### Setting a temperature (2026-08-25) + +Reported from ordinary use: the temperatures applied and the printer heated, but the row +was wrong on both sides of the round trip. Written up in +[08-function-gap-analysis.md](02-device-page/08-function-gap-analysis.md), "Round three". + +- **A target of `0` sat in the field** and had to be deleted before anything could be + typed. Zero *is* a heater that is off, so it now shows as a `—` placeholder over an + empty field, and focus selects what is there so a set value is typed over. +- **A committed value vanished, or came back as `0`.** The state push that follows a + commit lands about a second before the printer reports the change, and it was writing + the pre-edit value back in. Leaving a *focused* field alone was not enough — the commit + happens on blur, so the value is in flight exactly when the field stops being + protected. Now pending-until-confirmed, the same model tool selection needed, with the + same reason behind it: **the request was being stored in the thing that mirrors the + machine.** +- **Nothing said the heater was working.** A nozzle climbs a degree a second, so an + accepted setpoint and an ignored one looked identical. The row now shows + heating / ready / cooling / off in ink, plus a 2px bar under the numbers running from + the temperature the ramp started at to the target. Derived from `temperature` and + `target` only: `power` would say it directly but `heater_bed` does not publish it. +- **Ten seconds with no echo is reported, not hidden.** A command that succeeds and + changes nothing is what a silently-ignored setpoint looks like — the `PARK_EXTRUDER` + trap again — so the row drops back to the machine's value and names the one that did + not take. +- Found while looking at the result: **the target field clipped three digits**, so a + nozzle at 220 read as `22`. 9.00px per tabular digit, measured in the page; the field + is 35px and the row gap pays for it. +- **The row moved as its numbers did.** The reading was auto-width, so 99 → 100 carried + the slash, the field and the unit a digit to the right, and five rows at five + temperatures never lined up. `.cur` reserves three digits and right-aligns against the + separator; a browser check holds `.sl`, `.tgt` and `.unit` to one x across every row. + +**There is a browser after all.** WebKitGTK is present — it is what Orca renders with — +and PyGObject drives it, so the page can now be loaded, clicked and screenshotted: + +```bash +python3 resources/web/shared/tests/run_webkit.py --shots /tmp/shots +``` + +That is how the above was confirmed, and how the clipped digit was found. It catches what +source-text checks cannot: that focus really selects, that a committed value survives the +next push, that `select()` works on a number input at all. + +### And the page now runs against the printer without Orca + +[`tools/u1_bridge.py`](tools/u1_bridge.py) answers the SSWCP contract from Python and +speaks MQTT to the machine; `--real` installs `window.wx` as a user script and points it +there. The page runs its own connect path and comes up on real state — the same +`snapshot ok: 24 objects` the Orca path reports. + +```bash +python3 resources/web/shared/tests/run_webkit.py --real --watch +``` + +Without `--watch` it is a test: it checks, reports and exits. With `--watch` it is a +window - the checks still run and report first, then it stays up until you close it, +and the terminal becomes a live trace of what each click sends. `--watch 90` stays for +a fixed time instead. + +- **The mapping is generated**, not written down: + [`extract_bridge_methods.py`](tools/extract_bridge_methods.py) reads + `m_cmd == "sw_X"` → `sw_X()` → `host->async_x()` → `method = "…"` out of `SSWCP.cpp` + and `MoonRaker.cpp`. **37** of the page's 57 commands are one JSON-RPC method with the + parameters passed nearly straight through; 12 are answered locally, 8 refused with a + written reason. A conformance check fails if a new command is none of the three. +- **It found its own bug first.** The generator's first cut sliced a function body at the + next definition *of the same class*, and `sw_mqtt_publish` came out mapped to + `machine.system_info` — a publish would have gone out as a system-info query. +- **What it cannot do:** 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 (camera ack-vs-push, `sw_SetSubscribeFilter` narrowing) are about Orca's leg + specifically. **Orca must be closed** while it runs — same saved `clientId`, and a + broker evicts the older holder. + +Driven against the machine, the temperature work behaves: the printer echoes a target +back in **552 ms one run and 1516 ms the next**, which 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 old target back over the value just sent. The run also found a +flaw the simulator could not have: asked for 40 the nozzle overshot to 48, flipping +heating → cooling with the target unchanged, and the bar — restarted only on a target +change — sat at 100% while the temperature fell. It now restarts when the direction +does. + +### The session, run for longer than a test (2026-08-25) + +Three faults that only showed up once the page was left running against the machine. +Written up in [08-function-gap-analysis.md](02-device-page/08-function-gap-analysis.md), +"Round four". + +- **A slow command read as a refused one.** Picking a toolhead on a cold machine said + "the printer refused the command" through a toolchange that completed. Two clocks say + "timeout" and the page matched one: the client's own 15s ("timed out after 15000ms") + and **Orca's 80s** wait on the printer, which fails as code **-2**, message + **"time out"** — note the space. `/timed out/i` never matched Orca's, and it stayed + hidden because the client's clock is the shorter and fires first. The test is + `isTimeout()` in `sswcp.js` now and keys on the code too. Measured after: **31.9s** + cold, no refusal. +- **`u1_bridge.py` had been given a 12s timeout**, guessed from the client's 15s instead + of read from `MoonRaker.hpp:344`. That is what put a failure in front of the page + before its own clock ran. It waits 80s now, and fails in Orca's exact shape. +- **"Connected" was a claim about the past.** `state.lastUpdate > 0` asks whether the + machine ever spoke, so a rebooting printer stayed connected with a frozen snapshot. + The window came from measuring: **an idle U1 pushes about twice in 30 seconds**, gaps + of 4–14s, so 45s is ~3x the widest gap, with the heartbeat covering the rest. Socket + killed under the page: 115s stale and still claiming connected before, ~32s to drop + the dot after. +- **There was no retry.** One attempt, at boot, and only if nothing had ever arrived — + so starting the app before the printer left it dark until a reload. A supervisor now + reconnects on a 2s clock, backing off 5/10/20/30s and holding, dropping the dead + engine first. Measured: socket killed → noticed 37.2s, back at 37.9s. No printer at + all → attempts at 11s, 31s, 61s, 91s. + +`run_webkit.py --device-ip 192.0.2.1` points the saved device somewhere unroutable, +which is how the nothing-there path is exercised without switching the printer off. + +### The shipped bundle runs here too, and connects (2026-08-25) + +```bash +python3 resources/web/shared/tests/run_webkit.py --original --sn --watch +``` + +The **real Flutter bundle**, in WebKitGTK, against the real printer, through +`u1_bridge.py` — with live telemetry on screen and no modification to the bundle. Not +previously possible in this environment at all: the screenshot harness needs a chromium +that will not start. + +Four things had to be right, each read out of the C++: + +1. **Orca posts replies as a JSON string** — `send_to_js` builds + `window.postMessage(JSON.stringify({...}), '*')`. Posting the object, which the + reconstruction's client parses either way, makes the bundle ignore every reply in + silence. It presents as "the page will not pick a device", and it cost the most. +2. **TLS is decided by credentials, not the scheme.** The bundle asks for + `mqtt://:8883` — plain scheme, TLS port — and expects a TLS client back. +3. **Orca keeps its own connection to the printer.** `get_connect_host()` answers the + printer commands and is not the MQTT clients the page creates; the bundle never calls + `sw_mqtt_set_engine` because in Orca a host is already attached. The bridge now + brings up a session of its own. +4. **`sw_GetConnectedMachine` is the gate** — the first saved device flagged + `connected`. With none the page sits at `sn=`. The bridge probes each saved device + with a TCP connect and reports what answers. + +`--sn` matters: a config holding a stale record has the bundle trying to connect it. + +Also measured: **the bundle's own request timeout is 3 seconds**, against the +reconstruction's 15. And `local_devices_arrived` — which Orca posts on a separate, +non-envelope channel — appears **zero** times in `main.dart.js`. + +Still rough: a "Binding rejected" dialog from the cloud stub, and toolheads 2–4 read +`_/_ °C` while toolhead 1 and the bed carry real numbers. + +### The reply shape, and the two cards (2026-08-25) + +Running the shipped bundle against the same host corrected a **load-bearing finding**. +`08-function-gap-analysis.md` led with "the JSON-RPC envelope is never unwrapped"; that +is true only of a `passthrough` response target. Every ordinary command takes the other +arm of `on_response_arrived`, which reshapes the reply to +**`{data: , method: }`**. `unwrapRpc` only stripped `jsonrpc` envelopes, +so against the real contract it was a no-op and every reader outside `unwrapStatus` was +silently reading `undefined` — the file browser was **empty against a real printer**. +Fixed in `sswcp.js`, guarded on the `{data, method}` pair; one unit test had encoded the +old belief and is inverted. + +With both surfaces live on one machine, the rebuilt cards were measured against the +originals rather than judged: + +- **Camera** is a black viewport with one round control in it, saying the bundle's own + `Camera not on` — it had been showing the not-connected illustration, which answers a + different question. +- **Print** is one card at every state, zeroed rather than replaced. Layer counts come + from `print_stats.info`, subscribed all along and unread. +- **Tabs**: the `|` separator margin is 14/13, not 20/20 (title 28, separator 99, pill + 117), and a selected pill is inset at the TOP only so it runs into the body. The 36px + tabs and 40px refresh pills are different controls, not an inconsistency. +- **Refresh did nothing** — both pills re-read Orca's device book. Driving the shipped + page's own pill settles the set they should send, and both of its pills send the same + one. + +Dead end, recorded so it is not retried: the bundle ships +`HarmonyOS_Sans_SC_Regular.ttf`, and adopting it made every title *narrower* than the +shipped page's. Those titles are not that face at 14px. + +### Storage, and the finetunes that came before it (2026-08-25) + +**A Storage destination.** The rail has a second entry below Device control. One picker +row — time-lapses, prints, print files, logs — over one full-height scrolling grid of +cards. The Camera's time-lapse tab and the Printing Task's Files and Print-history tabs +are gone; those cards are now just the live view and the job card. + +The two views are **siblings in one document**, toggled with `hidden`, not separate +pages: a page swap would drop the MQTT session and re-run the LAN key exchange on every +switch. `#view-control` uses `display: contents` so the four panels stay direct children +of the 2×2 grid — and it must opt back into `[hidden]` explicitly, because +`display: contents` beats the UA's `[hidden] { display: none }`. Without that second +rule the control panels render *underneath* Storage and squeeze its grid to 4px. + +Measured rather than assumed: + +``` +document nodes 360 (control) -> 823 (storage, 50 file cards) +state push does NOT rebuild the grid - the signature guard holds +switch kind 24 ms cached, 221-472 ms with a printer round trip +view toggle ~30-85 ms, two frame waits included +4 switches 841 -> 853 nodes; nothing accumulates +``` + +**Four fixes that preceded it**, each measured on the machine: + +- **The toolchange label named the axes done** — "Homing — Z done", then "Y done". + Klipper *clears* `homed_axes` as it re-homes, so Z vanished after being reported, and + the machine looked like it was going backwards. One step now: `Homing axes…`. Two unit + tests and a conformance check had encoded the old behaviour. +- **Lists reset their scroll.** The task panel repaints on every state push and each + repaint emptied the node and rebuilt it. Lists now rebuild only when their *content* + signature changes, and the scroll position is carried across when they do — which also + fixes Load more landing you at the top. +- **Opening a recording only offered to delete it.** It plays now. The URL is on the + instance: `http://:7125/server` → `200 video/mp4`, where + `http:///…` returns the SPA shell — the same trap the camera frame hit. +- **Files are viewable and printable, not deletable**, and the files refresh is an icon + button rather than a chip sitting in the row of roots, where it read as a filter. + +**A limitation worth knowing:** this WebKitGTK build **cannot play H.264** — +`canPlayType('video/mp4')` is empty and playback fails `MEDIA_ERR_SRC_NOT_SUPPORTED`. +The dialog shows the still plus Download and says so. Orca's Windows and macOS webviews +do not have that limitation; Linux Orca probably does. + +**Left behind:** the file-browser CSS (`.files`, `.file-row`, `.chip`, …) outlived its +renderer. Dead-rule detection is unreliable here because `overlay.js` emits many classes +this scan cannot see, so it was left rather than swept on the way out. + +### Tooling worth knowing about + +```bash +python3 docs/u1-webui/tools/u1_probe.py # response shapes, straight over MQTT +python3 docs/u1-webui/tools/u1_topics.py # every topic, both legs +python3 resources/web/shared/tests/unit_jsc.py # 60 checks, real JS engine +python3 resources/web/shared/tests/conformance_test.py # 106 checks +``` + +`mqtt_min.py` is MQTT 3.1.1 in ~200 lines of standard library, because paho is not +installable here. The probes need **Orca closed** — they authenticate with the saved +`clientId` and a broker evicts the older holder. + +`unit_jsc.py` drives **JavaScriptCore via PyGObject**. `run_webkit.py` goes further and +drives the whole page in **WebKitGTK**, which is the engine Orca's webview uses — so its +checks run where the page will actually run. Both come from the same place: playwright is +absent and the vendored chromium still will not start (no `libnspr4`/`libnss3`), so +`run_selftest.py` and its 28 checks remain unrunnable. Installing those two libraries +would restore them; it is worth less now than it was, because WebKitGTK covers the part +that mattered — driving real controls in a real engine. + +`run_webkit.py` needs a display (WSLg provides one) and screenshots through +`Gdk.pixbuf_get_from_window`, because WebKit's own snapshot API returns a cairo surface +and there is no pycairo here. + +## What to pick up next + +**All three design changes are built.** Two are driven against the machine; the third — +multiACE — is driven against the simulator, seeded from the machine's own payload, and +has not yet had an ACE in front of it. The study is +[02-device-page/camera-layout-ace-mockup.html](02-device-page/camera-layout-ace-mockup.html) +— the versions that were weighed, with the ones not chosen still switchable in it. + +| | | | +|---|---|---| +| **Layout** | L3 + H1 | **built** — two unequal columns, no C++ | +| **Camera** | S2 + view grid + M-A | **built** — 9.5 fps against 0.5, no C++ | +| **multiACE** | ~~P1~~ ~~F4~~ **I1** | **built** — the Filament panel, no C++ | + +### The layout: two columns, not a 2x2 grid + +The four panels were one grid of equal cells, every body locked to +`aspect-ratio: 830/548`, so a wider window bought four wider squares and the camera — the +only one whose content scales — gained nothing. Now: a main column that takes what is +left, and a side column pinned at `--col-w` (830), the width the Control cluster already +is. **Control keeps its layout and gives up only height**, and Filament grows into what it +releases. + +`registry.js` gained `columns` on a destination and `column` / `grow` on a panel, so a +panel says where it goes instead of the CSS guessing from source order. `#view-control` +stopped being `display: contents` — the two columns have to distribute height +independently, which a contents box cannot do. + +Measured at 1920x1080: Control **424** and Filament **496**, against 588 and 588, which +did not fit and made the destination scroll. Control's cluster is untouched at 758. + +**A panel body hides its overflow, so a body too short for its card clips it in silence.** +Printing Task was pinned at 150 px on the reasoning that a job card "has a natural height +and no reason to grow" — the second half is true and the first was never measured. The +card is **304**, so the progress bar and both job buttons were cut off and nothing said +so. It sizes to its content now, and `run_webkit.py` asks every panel body at every width +whether its `scrollHeight` fits, rather than asking about this one. + +**The breakpoint is the trade-off and it is one number** (`1600`, up from 1420). The main +column is `window - 304 - 20 - 830`; the old grid gave `(window - 324) / 2`. Those cross +at **1984**, so between the breakpoint and 1984 the camera is narrower than it was — 446 +against 638 at 1600, 766 against 798 at 1920. The 1920 case costs 32px and buys Filament +172px. **The 1600 case costs 192px and is the weakest point of the design**; raising the +number to 1984 removes the cost entirely, at the price of most laptops never getting two +columns. `run_webkit.py --size WxH` exists so both layouts can be checked. + +### The camera: four transports, four view layouts, and detection + +`CAMERA_INTERVAL = 2` — the shipped page's camera is **half a frame per second**. A U1 +running paxx12's Extended Firmware also runs a camera service, and the panel now finds it: + +- **Detection, never configuration.** `GET :7125/server/webcams/list` answering with a + camera *is* the detection. Nothing answers on stock firmware and the monitor file stays. +- **Two of the four entries in that list are not cameras.** The printer registers its own + touchscreen as `gui` and the multiACE web panel as `multiACE`. An entry is a camera when + it has a `snapshot_url`; the touchscreen is kept, labelled *Screen*, and sorted last. +- **Both streaming transports are dead in WebKitGTK** and it is not close: + `MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E")` is `false` (VP8 is + `true`, so MSE is fine — the codec is missing) and `typeof RTCPeerConnection` is + `"undefined"`. Windows and macOS ship both, so `AUTO` **probes** rather than picking. +- **Named after transports, not after picture quality.** A greyed row then explains itself + in the same words as the reason — "this engine has no H.264 decoder" beats "not on + Linux" for someone who has to act on it. `AUTO` reports which one it landed on. +- **One to four pictures**: single, split, 2x2 and picture-in-picture. A 2x2 with three + cameras says *No fourth camera* in the empty cell rather than reflowing the moment a + second USB camera is plugged in. +- **The focused tile polls fast and the rest at 1 fps.** A grid is one poll per tile — + there is no multiplexed stream — and the case camera's 226 KB frame is nearly all of the + cost. That one rule is 4.7 MB/s down to 3.5. + +Driven against the printer, in the panel rather than in a synthetic loop: + +``` +9.5 fps through the panel at 15 requested (14.0 for a bare Image chain) +1.0 fps when 1 is asked for +9.8 / 1.0 focused tile against its neighbour +1920x1080 case 1280x720 usb 480x320 gui +38 ms to give up and fall back with no printer there +``` + +**Two traps found by building it.** A `?t=` cache-buster appended to a `data:` URI +corrupts the data — base64 often survives the trailing bytes and an SVG never does, which +is exactly how the simulated cameras rendered nothing while every other check passed; +busting is scheme-aware now. And the poller schedules the next fetch from `onload` rather +than on a fixed interval, so the rate self-limits to what the link can do instead of +queueing requests into unbounded lag. + +The simulator answers the camera list too (`mockWebcams()`, copied from the real payload +including both non-cameras), because frames are plain HTTP and a simulated run would +otherwise never execute the tile grid, the transport list or the focus rule at all. + +### A popover drew the state it was opened with + +Reported from ordinary use: the camera settings' buttons worked and none of them moved +its own tick until the panel was reopened. **A popover lives on `document.body`, outside +every panel `paint()` walks**, so it is built once and never again. Invisible for a +slider, whose thumb carries its own position — which is why the Control panel's four +never showed it — and plainly broken for anything drawing a selected state from the store. + +`overlay.js` has `repaintPopover()` now, called from `paint()` and **guarded by a +signature the caller supplies**: rebuilding the body every frame is the bug `render.js` +exists to prevent, and it would take focus, hover and a slider mid-drag with it. A popover +that passes no `sig` is never rebuilt, so the existing four behave exactly as before — +which `drive/popover-live.js` checks in both directions. + +### The job card is a band now, and the status is in the header + +**306 px to 128.** The card was a stack in a panel 766 px wide, and it spent that height +on a 152 px thumbnail, a 40 px grey percentage, a row carrying the machine's own name — +which the rail already shows — and a row of its own for two buttons. + +Three moves, all of them Bambu's: + +- the thumbnail **anchors** the block (96 px) instead of sitting above the bar +- the metadata joins the filename's line +- **the buttons ride the bar's line** — a 6 px bar and a 30 px button cost 70 stacked and + 30 side by side, which is most of the saving + +And the status word moved into the **panel header**, beside the title, so a panel says +what it is and what it is doing on one line. `shell.js` grew a `status` header kind for +it — a label with no click, synced through `headerSync` for the same reason a tab is: a +header has no renderer of its own. + +The camera above took the height: **752 px, up from 576**. And `--task-h` is gone — +pinned at 150 it clipped a 306 px card; kept as a floor it padded a 128 px band by 22. +The card draws a 96 px thumbnail, so what stops it collapsing is the card. + +`filament_used` is millimetres, so the card shows **metres**. Bambu's shows grams there; +grams need a diameter and a density this page is not told, and the guess would look like +a measurement. + +**A design finding worth keeping** — [part 04 of the study](02-device-page/camera-layout-ace-mockup.html) +draws four versions and the arithmetic that argues *against* the obvious reason to do +this: **at 1920 a shorter card buys black, not picture.** The camera is width-bound there +(766 px at 16:9 is 431 tall), so every pixel the card releases becomes letterbox. Past +about 2000 it becomes height-bound and the same pixels are all frame. The band is the +better card at any width — that is the argument, and it is not the one about space. + +### Drive scripts are committed now + +[`resources/web/shared/tests/drive/`](../../resources/web/shared/tests/drive/) — the DOM +walker this file has been recommending for months, plus the camera panel against the +simulator (50 checks), against the printer (13), and the no-printer branch (8). They were +ad-hoc every time before, which is the same as not existing. + +```bash +R=resources/web/shared/tests +python3 $R/run_webkit.py --size 1920x1080 # 37 checks, layout included +python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/camera.js +python3 $R/run_webkit.py --real --size 1920x1080 --drive $R/drive/camera-real.js +python3 docs/u1-webui/tools/check_mockup.py # per-file checks, both themes +python3 docs/u1-webui/tools/bake_mockup.py # re-render its no-script copies +``` + +### The multiACE panel: built, and P1 was upside down + +**Start at [02-device-page/11-multiace-handover.md](02-device-page/11-multiace-handover.md)** +— the settled design in one table, what landed, what changed on the way in, and every trap +this pass turned up. +The reasoning is [02-device-page/10-multiace-filament.md](02-device-page/10-multiace-filament.md), +with the studies as the specification: +[multiace-filament-mockup.html](02-device-page/multiace-filament-mockup.html) — seven +shapes and five machine states, static — and +[multiace-f2-iterations.html](02-device-page/multiace-f2-iterations.html), which **runs**: +a subpanel per toolhead, its header choosing the source, driven by mode, unit count and +Sync — and +[multiace-toolhead-card.html](02-device-page/multiace-toolhead-card.html), which iterates +what goes *under* that header, and +[multiace-cabinet.html](02-device-page/multiace-cabinet.html), where the card is settled +and the cabinet is drawn as the badge. All three carry pre-rendered copies so they also +read with no script. **I1, bands**, is the one to build; **F4, the bus**, becomes a second view behind +the overflow. + +It needs no new bridge command: `ace` is a Klipper object and +`sw_GetMachineState {objects:{ace:null}}` answers in **277 ms**, and every control is a +documented G-code macro. Naming the filament in an *unloaded* bay was written down as the +one thing that needed Orca — the raw slots carry no identity and `/multiace/api/state` is +CORS-refused — and that turned out to be wrong in the useful direction: +`ACE_SPOOL_ASSIGN ACE=n SLOT=n [ID=n]` binds one of `ace.spools`' entries to a bay, so it +needs a bay sheet and one macro rather than a piece of C++. + +Three things the drawing settled that reading could not: + +- **The shipped artwork says which way up the picture goes.** `extruderBackground.svg` + draws its inlet as two stubs at the *top* edge and its nozzle pointing *down*, so the + earlier study's **P1 — heads above, units below — draws filament climbing into a + nozzle**. F4 is P1 turned over. This was invisible until the real 64×140 artwork + replaced a placeholder rectangle, which is the argument for drawing mockups out of the + page's own parts rather than out of boxes. +- **456 px is the whole argument.** That is `.filament-body` at 1920×1080, measured. A + toolhead is 140 of it and a unit box 130, so four open units plus the bus and the head + row is **815** — the picture holds one or two units and folds to a rack at three. F4 and + F6 are therefore one design at two densities, not rivals. +- **A bay is drawn as a head**: the same 36 px disc and 58×19 name pill, without the + extruder body. Not `AMSLib`'s fill-from-the-bottom tube — that assumes a level, and + `spool_binding` is `{}`, so no bay has one. +- **A subpanel per toolhead never folds.** The follow-on measured 417 px at zero, one, two + *and four* units — 16 bays — because a subpanel draws only its own source. F4 has to + collapse to a rack at three. Head-major pays for *sharing*, unit-major pays for + *counting*, and four units on a four-head machine is the common shape. +- **The duplication objection was half wrong.** Four copies of one cabinet bound to one + state object cannot disagree; that was an objection to drawing. What survives is the + reading risk, answered by a dashed card edge and a `⇄` in the unit's pill. +- **The toolhead stops carrying filament.** No colour, no material name, no pencil on the + artwork — filament belongs to the source, and the head's copy was the one that could go + stale. What the head gets instead is a **sensor marker**, and it is not a binary: + `filament_feed` reports `in_ace` → `in_toolhead` → `at_extruder` plus `channel_error`, + four readings already parsed in `feedChannels()` and shown nowhere until now. +- **The cabinet is two halves, and each half has a job.** Spools in the upper one, their + materials named in the lower one, the seam running through the roll. Both greys are + Orca's own AMS neutrals — `#EEEEEE` over `#CECECE`. The box hugs its spools with 16 px of + shoulder, which is what makes it an object in the card rather than the card's ground. +- **Splitting the bay across the halves bought the shipped bay.** A bay is now `.slot`'s + own 36 px disc over its 58×19 name pill, 6 px apart — the same drawing a toolhead gets, + at the same size, which was the point of drawing a bay as a head. +- **The stock feeder is Snapmaker's own module, and it is black where the ACE is grey.** + The Automatic Filament Feeder Module is 159 g of plastic and metal with a front carrying + **two label strips for filament channels** — one module serves two of the four toolheads. + Drawn in the cabinet's own two-half shape in `#FFFFFF` over `#1F1F1F`: same seam rule, + same proportions, different palette. Its badge is that frame at badge size, so the tiny + logo and the drawing are one idea. (From the product page's text; its photography was not + readable.) The material chip goes **white** there — the shipped `#6E6E6E` pill vanishes + on black, and the chip is the only thing on the card that names the filament. +- **The dryer is a dialog, and it is Bambu's shape in the page's chrome.** A droplet + filled to the reading answers *how wet* as a quantity, which is why the original works. + Temperature and duration are `ACE_DRY`'s own arguments, and each offers **four presets + and one empty field in the same row** — a preset *or* a typed value, never both. Typing + un-lights the preset (it is no longer what would be sent), clearing re-lights it, a + preset click empties the field, and an out-of-range value is clamped rather than refused. + The command line under the dialog updates as you type. **Automatic above a humidity** + (`ACE_SET_AUTO_DRY`) is the one control Bambu's has not got. It paints over the panel on + its own scrim, so it costs the 456 px body nothing. +- **The feeder's badge is square; the ACE's is wide.** A cabinet is wide and a module is + not, so a 44×26 badge for the feeder only ever read as a squashed ACE. It is the frame at + badge size — white over black on a 17 px rounded square — and the frame itself carries no + cutouts, because two strips flanking a centred spool read as asymmetric on a card where + everything else sits on one line. +- **`value` is not a reflected attribute, and neither is `selected`.** Both bit this set of + studies: setting the property leaves nothing in the serialised markup, so a pre-rendered + copy loses whatever the control held. `bake_mockup.py` checks for the `selected` case and + `check_mockup.py` now checks the input's `value` too. +- **Two `...` on the panel are two different scopes, and each menu says which.** The + panel's holds the six ACE settings a person sets once — flush length, confirmations, + Spoolman, `ACE_CLEAR_HEADS`, unload-everything — because on the face of the panel they + would bury the four things that change daily. A subpanel's holds that one toolhead's + load / unload / swap, with Swap absent on a stock feeder (no other bay) and Unload greyed + on an empty head. Drawn in `device.css`'s own `.menu`. +- **A filament shows an eye or a pencil, depending on who wrote it.** An RFID tag carries + vendor, type, colour and temperatures and none of it is ours to overwrite — read only. A + typed value, or an occupied bay nobody has named, is editable. The mark goes **on the + roll, bottom-right**: `.slot`'s own placement (under the chip) is the only one of five + that costs height, and it costs 33 px more than the panel has. +- **The dryer dialog is settled** (confirmed against the drawing, 2026-08-26): the droplet + filled to the reading with *Idle* / *Drying* under it, Humidity and Temperature named as + Bambu names them, and each of `ACE_DRY`'s two arguments offered as four presets plus one + empty field in the same row. Automatic-above-a-humidity is the control Bambu's has not + got. The macro line sits under the settings and updates as you type. +- **A bay wears nothing at rest.** Anything drawn round the roll and its chip permanently + either hides the seam that runs through the roll or fights it, so the shape appears only + **under the pointer**, as a 1.5 px `#0C63E2` border with no fill — the blue the rail + already uses for the selected destination. Drawn as a backing behind the bay and inset + negatively, so it costs no layout and moves nothing. +- **The dryer dialog is opened by the Dry chip and by nothing else.** While the layout was + being chosen the study had a rig switch that forced it open; once settled, that switch + went. A rig control that outlives its decision is a way to reach a state no user has, and + the baked copies are now driven by clicking the chip rather than by setting a flag. +- **A running dryer reports elapsed of total, not just time left.** `dryer_status` carries + target, duration and remaining, so elapsed is derivable — `1 h 19 m / 4 h` says where you + are *and* how long it was set for, in 8 px more than `2 h 41 m left`, and it costs the + panel nothing. +- **Pointing at a filament shows an accent edge and nothing else.** A card listing the + tag's record was tried and taken back out: it covered the cabinet above it, appeared on a + movement rather than a decision, and duplicated what the bay's own sheet will hold. The + record lives in the bay's title until that sheet exists. +- **Baking copies of a live document duplicates every id in it.** The droplet's fill is a + rect clipped to the droplet's outline; two baked states each carried a + `clipPath id="dropclip"`, a clip id resolves *document-wide*, and the second resolved to + the first — which sits in a `display:none` block and therefore clips nothing, so the fill + drew as the bare rectangle it is. Clips are numbered per instance now and + `bake_mockup.py` namespaces each copy's ids on the way in; `check_mockup.py` asserts no + two elements share an id and every `clip-path` resolves inside its own copy. Anything + resolved by id — `mask`, gradients, `filter`, `use href`, `aria-labelledby` — has the + same exposure in any mockup that bakes itself. +- **The running dryer dialog is the same dialog.** Same width, same humidity-blue droplet, + same state and button styling; the state line stays one word and one plain line below it + reads `1 h 19 m of 4 h, at 55 °C`. An amber droplet, an amber Stop and a progress bar + were tried and taken out — the droplet worst of all, because it made the reading you came + for change meaning at the moment you came for it. So was a two-line state, which wrapped + in the 158 px column and grew the block under the droplet: that, and not the droplet, was + what made the running dialog look like a different one. +- **"A running dryer refuses loads" was never true.** It entered this record as prose in the + first study's drying state and was carried into three files and a dialog before anyone + checked it: `/printer/gcode/help` says nothing of the kind and it was never seen on the + machine. Removed from all of them, and `check_mockup.py` now asserts the panel claims + nothing of the sort. It is worth knowing how it spread — an unverified sentence in a + caption is quoted by the next document as if the caption were the source. +- ~~**Pointing at a filament shows the record its tag carries.**~~ + `filament_detect.info[i]` holds vendor, series, nozzle range, bed temperature, drying + schedule and SKU — six fields `state.js` has parsed for months and that appeared nowhere + but a dialog. A hover card is where they fit; a typed spool shows the two it knows. None + of the five hover treatments costs the body a pixel, so it is decided on what it should + say and what it may cover. +- **The unit row carries what the header had no room for** — which ACE it is, its humidity + and temperature, and the dryer. A card with no ACE names its module instead of holding an + empty spacer, which alignment required anyway. +- **The humidity pill is tinted by Orca's own `hum_level1..5` buckets** (≤20, ≤35, ≤50, + ≤65, >65) — the same thresholds `AMSinfo` uses to pick its droplet, so the pill and the + C++ widget cannot disagree about the same number. +- **The sensor dot is centred on the artwork's body.** `extruderBackground.svg` draws that + body `y=17.4..127.6` of its 64×140, so the middle is `(32, 72.5)` at any scale. +- **The tube layer paints behind the cabinet.** The manifold bar sits below the box, so a + drop has to cross the box to reach it — and filament inside a machine is not drawn over + the machine. Which bay is feeding stays legible because the coloured core starts at that + bay's x on the bar. +- **Everything on one centred axis.** The bays, the merge and the toolhead's inlet share + the card's centre line in every source, so the tube that matters is vertical — and the + checks assert *vertical*, not just *lands correctly*, which is how an inherited + `align-items: flex-start` that left-aligned every card was caught. +- **I4 is not available.** A full-scale 140 px toolhead below its box, twice over, is + **595–611** against 456. At 0.40 it is 427–443 and fits; beside the box at full scale it + is 417. Only the frame costs height — box, tube and marker are all free. +- **A mockup that renders itself can arrive empty.** The interactive study first shipped + drawing everything from its state object and landed **blank** in a viewer that strips + script tags — and `

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.switch_to_device_tab()); +dialog->set_send_page(dlg.post_action() == PrintHostPostUploadAction::None); +dialog->set_gcode_file_name(upload_job.upload_data.source_path.string()); +dialog->set_display_file_name(upload_job.upload_data.upload_path.string()); +bool res = dialog->run(); +``` + +```cpp +// WebPreprintDialog::run() +auto real_url = m_send_page ? get_international_url(m_preSend_url) // path=5 + : get_international_url(m_prePrint_url); // path=4 +this->SetTitle(m_send_page ? _L("Pretreat the uploaded content") + : _L("Print Preprocessing")); +``` + +So `post_action == None` ("just upload") → `send_page` → `path=5` → `preUpload`. +Anything else ("upload and print") → `path=4` → `preUploadAndPrint`. + +## What each mode shows + +Captured from the real bundle with the [harness](../tools/harness/README.md), at +the dialog's true 714 × 750. + +### `?path=4` — upload and print + +![Print processing popup, upload-and-print mode](../screenshots/preuploadandprint.png) + +Four sections and a send bar: + +| Section | Contents | +|---|---| +| **Model Information** | G-code thumbnail, `Filename`, `Estimated Time`, `Estimated Materials` | +| **Select Printer** | printer picker — "Click to select printer" | +| **Edit Filament** | the filament-mapping grid, with a refresh control | +| **Print Preferences** | `Extrusion Flow Calibration` (with a help tooltip), `Time-lapse Camera`, `Auto Leveling` | +| send bar | upload progress `0%` and the **Send** button | + +`Filename` is `benchy_4colour.gcode` in the capture because that is what the +simulated host returned for `sw_GetActiveFile` — it is the value Orca passes down +through `SSWCP::update_display_filename()`. + +### `?path=5` — upload only + +![Print processing popup, upload-only mode](../screenshots/preupload.png) + +The same screen with the print-specific half removed: **Model Information** and +**Select Printer** only. No filament mapping, no print preferences — and the +**Send** button is rendered disabled until a printer is chosen. + +This is the clearest confirmation that the route names mean what they say, and +that the `path=4`/`path=5` ↔ enum-index crossing described in +[entry points](../01-architecture/03-entry-points.md) does not affect behaviour. + +## The three print-preference toggles + +These map onto fields of the `print_task_config` Klipper object that the Device +page also subscribes to (see [state model](../05-printer-protocol/04-state-model.md)): + +| UI label | `print_task_config` field | +|---|---| +| Extrusion Flow Calibration | `flow_calibrate`, `flow_calib_extruders` | +| Time-lapse Camera | `time_lapse_camera` | +| Auto Leveling | `auto_bed_leveling` | + +The same object also carries `shaper_calibrate`, `auto_replenish_filament`, +`can_auto_replenish` and `auto_replenish_index`, which the popup does not expose — +those are driven from the Device page or the printer itself. + +## Commands used by this surface + +| Command | Role | +|---|---| +| `sw_GetActiveFile` | the file Orca is about to send | +| `sw_GetPrintZip` | packages G-code + metadata into a zip, off the UI thread | +| `sw_GetPrintLegal` | checks the loaded preset matches the connected model | +| `sw_GetFileFilamentMapping` | reads the slice result's filament requirements | +| `sw_UpdateMachineFilamentInfo` | writes the chosen mapping back | +| `sw_SetFilamentMappingComplete` | marks the mapping accepted or cancelled | +| `sw_FinishFilamentMapping` | closes the dialog | +| `sw_FinishPreprint` | reports the final outcome | +| `sw_StartLocalPrint` / `sw_StartCloudPrint` | begins the job | + +`sw_GetPrintLegal` is the guard that stops you sending a plate sliced for one +model to a different one: + +```cpp +m_res_data["preset_model"] = local_name; // the edited printer preset +m_res_data["legal"] = (local_name == connected_model); // vs the connected machine +``` + +See [lifecycle](02-lifecycle.md) for how the dialog is opened, closed and reported. diff --git a/docs/u1-webui/03-print-processing/02-lifecycle.md b/docs/u1-webui/03-print-processing/02-lifecycle.md new file mode 100644 index 00000000000..e45f79f4bb6 --- /dev/null +++ b/docs/u1-webui/03-print-processing/02-lifecycle.md @@ -0,0 +1,139 @@ +# Print popup: dialog lifecycle + +The popup is modal, but it does **not** close itself from a button. The page ends +the dialog by calling bridge commands, and the host translates those into +`EndModal`. Getting this sequence wrong is what the `SafeEndModal` guard exists +to survive. + +## The close protocol + +Two commands, in order, with distinct jobs: + +``` +1. sw_SetFilamentMappingComplete { status: "success" | "canceled" | } + -> records the outcome on the dialog (dialog->set_finish(true|false)) + -> does NOT close the dialog + +2. sw_FinishFilamentMapping {} + -> reads the recorded outcome and closes + dialog->SafeEndModal(is_finish() ? wxID_OK : wxID_CANCEL) +``` + +Splitting "decide" from "close" is deliberate. From `WebPreprintDialog.cpp`: + +```cpp +void WebPreprintDialog::set_finish(bool flag) +{ + m_finish = flag; + // BBS: Don't call EndModal here to avoid conflict with sw_FinishFilamentMapping() + // The external sw_FinishFilamentMapping() function will handle EndModal based on m_finish flag +} +``` + +`SafeEndModal` then makes the close idempotent, because the page, the window +manager and the `wxEVT_CLOSE_WINDOW` handler can all race to end the same dialog: + +```cpp +void WebPreprintDialog::SafeEndModal(int returnCode) +{ + // BBS: Prevent duplicate EndModal calls which can cause crashes + if (IsModal() && !m_modal_ended) { + m_modal_ended = true; + EndModal(returnCode); + } +} +``` + +Any `status` other than `"success"` or `"canceled"` is treated as an error and +raises a native `MessageDialog` ("setting failed") rather than closing. + +## `sw_FinishPreprint` — the outcome report + +Separate from closing, the page reports how the preprint went: + +```cpp +if (m_param_data.count("status")) { + std::string status = m_param_data["status"].get(); + auto p_dialog = dynamic_cast(wxGetApp().get_web_preprint_dialog()); + if (p_dialog && status != "success") + p_dialog->set_swtich_to_device(false); // don't jump to the Device tab on failure +} +``` + +That flag is the only thing controlling whether Orca switches tabs afterwards. + +## Full sequence + +``` +Plater: user picks "Send to printer" + └─ PrintHostSendDialog (native; filename, post-action, storage) + └─ WebPreprintDialog::run() + ├─ SSWCP::update_active_filename(gcode path) + ├─ SSWCP::update_display_filename(upload path) + ├─ LoadURL(path=4 | path=5) + └─ ShowModal() + │ + │ page boots, then over the WCP bridge: + ├── sw_GetActiveFile -> which file + ├── sw_GetPrintLegal -> preset vs connected model + ├── sw_GetFileFilamentMapping -> filament requirements + ├── sw_GetPrintZip -> zip built on a worker thread + ├── sw_UpdateMachineFilamentInfo + ├── sw_StartLocalPrint | sw_StartCloudPrint + ├── sw_FinishPreprint { status } + ├── sw_SetFilamentMappingComplete { status } -> set_finish() + └── sw_FinishFilamentMapping -> SafeEndModal() + │ + ShowModal returns + └─ if (dialog->is_finish()) select_tab(tpMonitor) +``` + +## Return-value quirk + +`run()` does not return what `ShowModal` returned: + +```cpp +int result = this->ShowModal(); +if (result == wxID_OK || (result == wxID_CANCEL && m_finish)) + return m_finish; +return false; +``` + +`wxID_CANCEL` combined with `m_finish == true` is treated as success — that is the +path taken when the window is closed by `OnClose` after the page has already +recorded a successful mapping. The caller in `Plater.cpp` ignores the return value +anyway and re-reads `dialog->is_finish()` directly. + +## Threading + +`sw_GetPrintZip` is the one handler that leaves the UI thread: + +```cpp +m_work_thread = std::thread([oriname, targetname, weak_self]() { + std::string zipname = generate_zip_path(oriname, targetname); + json res = get_or_create_zip_json(oriname, targetname, zipname); + wxGetApp().CallAfter([weak_self, res]() { ... send_to_js(); finish_job(); }); +}); +``` + +It joins any previous worker before starting a new one, and marshals the reply +back through `CallAfter`. Every other handler on this surface is synchronous on +the UI thread. + +## Cleanup + +`WebPreprintDialog`'s constructor deletes any existing *device* dialog first, and +registers itself as the app's current preprint dialog; the destructor unregisters +it and detaches the webview from the bridge: + +```cpp +WebPreprintDialog::~WebPreprintDialog() +{ + SSWCP::on_webview_delete(m_browser); // drop subscriptions bound to this view + wxGetApp().fltviews().remove_view(m_browser); + wxGetApp().set_web_preprint_dialog(nullptr); +} +``` + +`OnClose` additionally dismisses any in-flight `PrintHostUpload` notification +before ending the modal. diff --git a/docs/u1-webui/03-print-processing/03-implementation.md b/docs/u1-webui/03-print-processing/03-implementation.md new file mode 100644 index 00000000000..8db2993e281 --- /dev/null +++ b/docs/u1-webui/03-print-processing/03-implementation.md @@ -0,0 +1,96 @@ +# Print popup: the reconstruction + +`resources/web/print_processing/` is a dependency-free reimplementation of the +print-processing popup, built from this documentation and from the shared model layer +the Device tab already uses. + +| | | +|---|---| +| Location | `resources/web/print_processing/` | +| Own code | 752 lines (`app.js`, `ui.js`, `mock.js`, `preprint.css`, `index.html`) | +| Shared code | 1,067 lines from [`resources/web/shared/`](../00-shared/01-shared-models.md) | +| Dependencies | none, no build step | +| Host | real Orca when `window.wx` exists, a simulated one otherwise | + +## Two modes, one page + +Orca picks the route; the reconstruction takes the same choice from a query parameter: + +| Orca | Reconstruction | Shows | +|---|---|---| +| `?path=4` `preUploadAndPrint` | `?mode=print` *(default)* | all four sections | +| `?path=5` `preUpload` | `?mode=upload` | Model Information + Select Printer only | + +### Upload and print + +![Reconstruction, upload-and-print mode](screenshots/reconstruction-path4.png) + +Model Information, Select Printer, Edit Filament with a row per filament and its +toolhead assignment, and the three Print Preferences toggles — over the progress bar +and Send button. + +### Upload only + +![Reconstruction, upload-only mode](screenshots/reconstruction-path5.png) + +The print-specific half is gone, matching the shipped popup, and Send stays disabled +until a printer resolves. + +The badge in the corner is the [build marker](../00-shared/02-build-badge.md). + +## What it reuses + +Nothing about the bridge, the state model or the filament model is re-implemented here. +The filament rows are built from `print_task_config` — the same object the Device tab +reads to label its toolheads — through the shared `MachineState` store. See +[what the two surfaces share](../00-shared/01-shared-models.md). + +The one surface-specific model is the mapping edit itself: changing a row's toolhead +writes `extruder_map_table` back through `sw_UpdateMachineFilamentInfo`. + +## The close protocol + +The Send button follows the real three-command sequence, in order — the reconstruction +does not shortcut it, because the ordering is the part that is easy to get wrong: + +```js +await bridge.request(CMD.GET_PRINT_ZIP, {}); // package +await bridge.request(CMD.START_LOCAL_PRINT, {}); // begin (print mode only) +await bridge.request(CMD.FINISH_PREPRINT, { status: 'success' }); +await bridge.request(CMD.SET_FILAMENT_MAPPING_COMPLETE, { status: 'success' }); +await bridge.request(CMD.FINISH_FILAMENT_MAPPING, {}); // only this closes the dialog +``` + +`sw_SetFilamentMappingComplete` records the outcome; `sw_FinishFilamentMapping` is what +actually ends the modal. Doing them in one step is the mistake the host's own +`SafeEndModal` guard exists to survive — see [lifecycle](02-lifecycle.md). + +On failure the reconstruction reports `sw_FinishPreprint { status: 'failed' }` and +re-enables Send, rather than closing. + +## Verification + +The shared conformance test covers this surface too, because both surfaces import the +same constants: + +```bash +python3 resources/web/shared/tests/conformance_test.py +``` + +``` +17/17 checks passed +``` + +Three of those checks are the `code: 200` contract, added after a cross-check found the +earlier documentation and client had it as `0` — see +[the envelope](../04-bridge-wcp/01-envelope.md). + +## Honest limits + +- The printer picker is a stub: `sw_GetLocalDevices` is not wired, so "Click to select + printer" resolves from `sw_GetConnectedMachine` only. +- Upload progress is simulated locally, not driven by a real host progress channel. +- `sw_GetPrintZip` is called but its returned content is not used — there is nothing to + upload to without a printer. +- Verified against the shared simulator, which is built from the same documentation. As + with the Device tab, that proves internal consistency, not that a real U1 agrees. diff --git a/docs/u1-webui/03-print-processing/screenshots/reconstruction-path4.png b/docs/u1-webui/03-print-processing/screenshots/reconstruction-path4.png new file mode 100644 index 0000000000000000000000000000000000000000..ee61cc458467ffeeb49d9f01ca488612549ff415 GIT binary patch literal 53226 zcmbrmWmH_xm+uXMgb*x12qD-HclX8;2oOAY@Zb)C#x)6UO>lRDL*w2+aBtk{4(<+( z)3-D8pY^PH?yR|Y)_u{`;;gPZXIGu7+WWJ=)#0C1zZ(67nz4#iFNRE|Q%!mTt$Yt@mb0wknT7*A=`GzBxsFfBiCjr~B1ev5d{ zyCL&308RiF0&Xu`a0+h=ZD;s!78NKdqi&8iZ>I3K_#Etsjw;xt+y4HSL}T=~7=Fox z`uoiyj1*OL^7i1MiZ5hqPf=@+1=Yhi!-Uki#XyZ&<&0 zC-{)F!OOmhX`EBmb5YdtZwXMh`IGuGIc>~5vYuEL?-D_e)_3zDwK&buA)`tm-ZT~P zww@OCNbmNqI&gONiJ%TyVo7NHaqW4g2Q3`&7#Kz;2yTB4+(S$>>rF&ap_4>y06b-$hZ_h_bryo*fMl? zItoN<4MCi`i#CImbfqCfwj^>YMTJSk>pQ%v_Sg>shT2z!5$+$rsj+%ej|^_ME|Y|* zV<~yPLinmMPeu(zs&v3^4``f$E!+64djpbM(+|&SuttK(C9kiT(9yDZ!A^^(Q`c(s zuZPq<+#bK|ZWPdNIXlbR#}G)6>t4zm`2+jG6M?DF46b}h`Sz=Y%3-BPv*qLwtcgjL zNWscc4dTPmdN?>kuNlAs)otAhjUB;y<<&)XtEBx4dTlujlFFC(#m1vGuaK-NBxsp< zo49Si{6^e!!YVUAzJ~g|Rxj~p@M^@%a=n<KhzjKvYVWVJ|Hl{-G$;-zSe?1%ID>Th-{!*IcK^O}ipJhUuRCf?d|&$gG@ zfIgQOH}?nA`wNS4K$b>kHVKQ*g~LjKD(%h2JxDPI*wcy%P*qe@7;ACwvd6-aMyNe}sKsv&{ zelx5NeUc|8L*)0Ln9mL<=su-E0CX;>ri+)udKRlie#Hz=Y zWx)_mf)}}T%o*iFsQ*flq2KOD!xTb&Yre(amRk|x;<+9+25kke*r@drL@}w{(s?+m z)jA9#y++X*r1j-Eml|QuRJvccZEnDobaAv8awAahlPkU*xIvNHSd>sq>>vI3fg!2% z)d)Pj*sp_H)=N=qPVgw&O0bxHPUMC_pLCDW^z66~?mln>deUA8LCSI6_@t8yveumM zlt#xS-uW~-nX_YlD^kAYNF7*i8%t&m5A5JP*A!&ZvY@mE4>N{9SA*(kOPsLeI9C-7 z?#`hHP);(A%>qWlBEx4OcOGBUlc$HvxD0suIm1qli*GyR#*agj#eT&+I7DbR0j#h@3lUkvL3U z&u-XMVy#YkehqtkPkp&FknzsVWJ|S`;NT~*4Y^%QkT47$1mF|dEzaIYE1Ahxc}0vF z?-3=cuslMN%DN@AdArKSh3Vqxc<%-;Bo|6NG+q{+0JECBGJb$xuRH}gxQYZbd{{%; zcsd2Ve<-?mFD2o&TUcJCh&B0geJxm<(1$%0?z8rO>%KH1A!A6Aw**d#bh9ApRuy=m zu1yKUIj%C>&z?L<4$X#L$z`>aHLLmjV^C%k*hfWf$HD9R^}zG$vn5z93+5+#e4&_4 z=*M}II5Cp#xf#2>ObBG3vYUquf;Re6Jv^wdrEkx07B`?ElB)oQvqM24N&Q-enbJqd zj_aPF;hgfJbQ4 z0$M*YnLqw&#*NC_a=NBFr*7?n?oBw!qy~sDwFT-&8{Nhgg5_OKSAFv~)D6{Zd=n1)pyNq{)JDtsxaKB1 zaslG3>BA)&W3)O)(sRdWEw1Hv0*=| z3)}jnc*DH-$9~z*J`ZvEcL5K9!oGmf?vhpe-@Y6Y4f)IK5D7||7KZqas0JroBezn=3p6rh7Dk!gr6kFl5`7H7;m0c+v$S#4_e^r*1S+i>~Vyq1t5Nk83MBN}q- zDpWP>NRO<$Qm{1%FR%KrXf(?2sny<@{9IVU3)}GF6`Eefbep~t;z;evjsleKfpx1C zr|+b1!fQjnMV#{dum!2(?KkyUfKKeqKTTRbtfIVoDL4`;QnVjX z9?+tfLm#60$6I1(PcPGHCGnE5Sa zsK-Ce7&PDP9e+J7WjHF4f46s2w+&2bb2}Rja5Q9LAJldZQZg*z7)*$*d=M4pq25KF z?z0M;eTHz`EGOTAv>Bokz(Qm#A|aG12XZsOyf;!l$LQ{f1kEv}@EZq;dxnucK=?Q2 z;ZzM<3v;(WW^m5iX2Gs2`#hdoyfuafoQK-D*=QC#QUAqI?(9+eJmB@-U6DZEx=8NXN_W>epqi35g6^ z^_2K>pYK#tHTIKJGZFnoR^>1p@&^<0l?h{^S!?r2Er;*4C~q))~B^YC)_ zz5Mld>DtrKV`~-lGB)zeD4W5bXEz3*c#@(f1St{a>gUV71+%UI17O~jpUc)`K$@H{1OCzFyqFB$>Ro>NkdPC?iy7=V+8P;bP zb7WTb$73Y<_U6?K`V=YI9*Z?h@E!+nEl8DbmVdk62!MLuoJX6c3GouPb# zzMRIz`I{|!jWVRH8yWZ`I=+M~fW#!ajw?qI3c`5tX^(9E1qA8xvTNSn+ZS$#=nQ)f zLIsMyO(0;DG*_m_{oCk*|)*}8ZNtc3ALt#b@2f6~3IsV%)b5d(y6>~6ns zE4bgH7IKBbKyERkJfv4~#W5KJ1LXh?-YwDsjV%)5a5(zm2o}DB7dte%6Vf#0kvVB&)t*P>MWwd=)&NwHsT4%pWO+ZVb&va!FL4 z@*U#$HV3Yci8nD^#S@LU9am{?>sC`Xhz%e= z7Mv;5pinTZ)P@;DDph?d0Q!}{dsQqa^dezNf!wMyO728ZLj0hn$%vEk82=3aw21oM zhw9&98s0oth3XEPnB0|@dG}kYc7pz#+aC4Aii@5dLSgbHfbOPYdYOi{cUdhA{Da<| zImt-GfG+x26CC@3a3T?1o z{%|c-=qL3qipgd7D))-o*kN%%?DgX1-N4+qG>zgCaf?8pquD-3HR1^Ai`>KGRa_4lQ_ZEDiS2i)?*_Ah| ze1$?iY}Yv8+qco>Awl?whtb)$GdzWs3{R6E=zN+DkR31)3{Voz<0x?r#lpP->nfw*nCYOd9$_NCm~?x{GR zG7>Au-p?|g!<8`SaG;BOQOdbgy(hrT>~{Qp?uhAL<3CtHhn32cP~VGWZgg&(Thci_O)4AXCoX&wS-Obhk35*3=_68m62FKxS zZRy)SdVE>M&DGA1M5lJ&(eaXc$}^R}uOA}st-tFHcGc;XYYc}k1>AXjXh8FP?b#4t zJk5{(1$*kAgCmF%>3+k@w3FY+&+pydeY z^Z9R}g7{rF*5{pOIc_avqNdR!uQ5^ae$Gr}maw&`ko5nZwmg@=a;(=nzQN}obD_@eY9iDn0A90>bz|-So(V||b zXYAb!PL@18!E(D*&DZW3S3&z`HIOjc>SO5#q(CR5kybAn zF5{9yH*d)4I)|+kiivnOWVCpTKNLolh^4T87}(Bkxpg{jMXylfO2${HPYcgdLOV2P zI^1188CBa4`gfMnJ^x<-laT*w&JxP6-kT{}s$Q>MWaWQB;_)2CTk2#j3mFYSWao5s z8|f)2{I)B7$}fycXJ3^J^~`F&N|rtqrr_iIOJ*tvr=oIZck1*q%c}}1#=Cw%`TSR8 zdvF-_t?K}CHspW51C7hj4_2!Eaf;nNUrjpj`tK!nxh26mZJol|H@dK5Pc`Rl{3)g% z_9!-VGGc1l>0q&tDJqNC%Fk-VtTlsRAVSW!@k4}^^&tKt=`EBd#_e@B}B%Z|(X z;puGVl+aomQ{kkUaUZ+6!DM-PTJ3i9wwf{``Pmgm)GK_fhb$9Nz)K2 zv?ZcKy{h=nJ?Qt?Lgy$3=v2|Q+(AEXBv;R;k~j(MRP}-ozt0<=5~FM{k|Fj06|2}U zr&lr#O^c^7>uKkEvQjcM!<9(46#Wf&Lm8-BQe`&l}pooN^ zvhd@gn2_%muELHSh||~WkwbZxN#?zCV=S^owI4+zndBz(DszsjIPS)3M7MisCy(Gq zqc6gBlG{&(?_ATpQfv;t)J)_J5U${^Fc|thYZr8~@;=2_!j4W8gv7xV-C0ME<{DoO-g%#o<xK^;B3EQwJ|m(I_i9}df5q$fJK^L@q1+$@njnMa65Zm)i5e82 zG;SkdaY_20Me_4)EUWi}0zS22|9nv*NWbG^BPNxJ^0Rvw)Lir$_=Cn|(f3Ggg%^Fl zn-}*g8)-6h;U?#zB}FP3AU})XU=awR;LcTM3}Xd`K+HB$LdNi$`9R(ya#hzWWl`A|V-*7BTsho}VE zw(?8l%4bAoNT|3rn)hm>5!fLyIdF|)uO}&}?)e0wA(lRrcUj+bOc^sRMB@7GD za<`vqP<-2UZrJ2A93rsq;pDTDUIKvHFMgG{S{rKuD$N}5`Y#<_mI>Lz5J~-zdfZHQ zC3PA>vm`L=N9#~F|6Zg$XCI3u5c87&885mGf?2{ zuQH;M1)QJu*d8sBprRXFdhlK4H{EP^81B#&`(33gGc?Hdh7g6-RW&t0kench9O$UU zqqn*2X%H#5O#zts7G~#KZD3M-5rj}lG#-;7vSNuTu+`+farm`79z&ZTtXMpR^dP~J zm({|PXFE3!k^TPTP~+CAqk{=GQPo$1`QspsWhpn35;9~6X}gGh=57kr(ZJjfAVHU(BhWI?F%chy}KU-6W{9^0yTh;IWkgRXB}n=p;DA z1+I*W2rKM8d-hS*Kn_oqTjq7B%HQ?W6Kc+H6xnXhgpc*r_EOY ziJrt3Pb7l^Jt6s?jm)ft2qkT91W>&OulfByiFG6jm`ae!k->pNc^`#TKZ#>lqmzQQ zv2Oa=6S$sOZFZ9j*{J4lLK#LjzxNJv$13f}>9p7zJ$HuTuiQ_ngGyG)L@7Ph_j1_) z%zKZ0EJlaPyBE=X+x~ZrvXfpHlOmAJgi~s0+z?^_G2mKP6R=qF0O)tWc!D&WLd>H1lz zKbtwc!s7!Ff0rj*!?q98^PT%1OBWzg!@y>8u1cPWuJTH4SOY;#7rS(O!KnOxN77O1 z4~zl3S=0y*`ocvs>z*&L-z1&k55Ecj8Yc1 z>VoXL|D2!UQV1=V^p#y^3(>r1LrtJMTxE%h*T!pW@F}R_^*Bz~H?O6J`)fTXCssS; z?;iWy{-2FWhx+FsF+amvl=LCDm+1IyBa&u~*-YPOq$$h70=JM&Zit`n!fE}7x!{f< z@ikYqqPFoI9$7*n`jwqU2ns`Pq2y?2?{G}i)n~ubd4tLa@&h^k?v7Ua zeV6(m_5Ej&t66lYW%9qy$o!Z47_g?=M@lGJ#V6A zYagG?K;2r5JdM?8gDs*SARg0S&%K?RCei9ZPeMYX=O+_e+NMB07PmA~n9xTQy6RRf?)!JQVKh=ApN`4crD=o(k0MjeW5DJO&`)ai;&Tq!NgU^fo zx+*vnvOaU#JObfHhqd2&23!&3bIEnQenkch2k$9@l+Nr zSl{h+B_Syp3QCg2;;OfYC&5)$+OL*bzif1>_`^O}%Z6p4@qh4p+&@WeR$EF6{g`6) zq;dSzjNTM80L}BsJQV$H#n|Kzxj!@%ARGDAO0}|=e89%{G-jguz+2IQtThchfp+TQZofqIXSKUWZ4m zmbVf=0vSg$UP;he|C#sp_O)Kvb<+spz1v&lSc|;Q*gJfS-&r-@@AqXoZEs4wxqhHo zgH{&v68p%o>}y57MMIfV49~r63Wt)e&HNKT=b3q!Qq15AAGM?qzx7f09bMK&xE-zZ z3fona_3s4Os-$7T{!$Yyi?`)ZuG#{f@r%7$hikqs{)y;@4&t}L(CFc|0RIG158jTo z%7zgzw@$z*xT(;{MCk>Sb))b@55#?0B~hHLTXH=FdQc8+OQ^9AV=>wrXkH z%PRpHpZU@bG6i}+sZ9vEkNH?Q)+|L)a}^l3`FpCR$dv7KaIC6yyh~3Fwf^6 z;p14y-h1)djKaEy@65s8`_9~L_Ox0Vo{}pv#%RH38?@OOQWO=Y@bck45Dhjt}?Ovu>vHu zZoo3@>IMJ=TL;GCsio0SKe8d1<;ik!dCn|w}<>oqZI`iSXdhUX(rj6v9(Cr z#CW#&xwxlwH2vN*x015YzmrA!?(D4gLM-};%GA9L$*uhDa0<&=pX;@6cja2VKg%v0 z5%`d_cAlQMmGyJ%bGE1F<{KhvUBr^-HJ{`}QSJVb!ha+ZU=7;&DHw3(I2hcp^I;x!gGMKlkN=zO>oG5n)!K9n&k~R=; zrlMg2v<&hQsXG?&hH1hNFqn7~o8{aY75hfBg(g)M2-|yl`(t_SBWDd@&3|SXnpffe zeBkURn4vo2eLVd6sms@6A@3+s)p=l7ia+Ne^<}eQKq)K zj-K{owU+6EnGX(-@dX+dRQg$trT!xysXU-&NgWpNkt*m>VC*B;5k@v8MN#UX*o-Zz z5tIw04NMi+Ngd}kr{mK*J3yh_pUju9G)p(w6$s?6cllwE6x z%2gO@k!B80DJ}oRtnd!IcXIDGA&*&4cFOiwUP>57*(6480mxJ#Rjd4y1zQQC(p0U& zfyZqUW?-{ZQ^gsR@gkV$BST_$N=ifwQ3S=)=@mmFykx+g(1v zZH8KjPGJl6qbn6WP8#p0+$bC_hl#4ITxhcMu0Q0PKS|E-{`P5GKvdN-RGsw7^7)m?!xzq+8#%!@MqbvqZh$`;26r(gVC*NhnYPyhNMz>}kI&F2Q{KxFJyif61t&%}w zW?OCVwK-C2J~Vl&d`o!ag+>saQt0_=V)?M^3u`Z+oqulTP%4~W?AEt3ijj6Nw%?fU z{>@|}@72*aTvSvrR8`Q9;e*D(he_BS%S|IIt05u9C>XwCf@Bn^(rEa_wI;Xb4znnN@K2%1^v9wLL>yuO4!n?GU_?j z^VDE5fU=r_R-Cpun@wp=Reb)#w?WBAv9YJg8)(x(W3fe)MVUpd^#}LABV`f|NdSrL zlC5LB&(whtbGnr%73(KZ{Keu}FG<6Y;^=(bEv;;Io8i(b{FHyC!VP zXHgd#RP(PiM5^9#baAP=;c354>MV1fmA%ygG>>M|FZSDBMs0mHz1H^rPKD5&G zCsb4Z`P5+Em`IzYR+I^MP>;t)Qmy-~_(Uh}STDLnlk&=FjkV5CstYj65E=m`xxp>r z)VY&)a=98`!!8u;mC`1;LtKwR*ULi~g$5`~6J@}~1}eC8UQj0ZJNR?7H5s~mtbY!2J(4ES+79>Subv)C zpYs3nV_19}OwsS_R=5~rQ0HCx^^U$fk&g1&SmdDEivIUw;jgQW#{kL{Q46iJ4#hXV zb3eWK#E4(Fy`Hw(C^s_=Al=J-g28mD!R|fLaW)kDwiS_9b-Ofi5mAkFMpR!?w2pjT zkU{lmAorK7RqOmLAY%75*|DJ2!bp~^iVGLwXuC}^P6XroamJNli*Fr4FU5RHq8;PU zQ70aKyJPaV)nEGQLZoNi>S7i%1FX>owM3p?xtn_HxTrXWfm-e7o20MrdkHTHFvR`r zLo4YF?^9rSrAN^kM|3E&N(}ga>JqhlBx`VS{_GQh3V=QX`fmm<#vRdfYeCpYnTLqn zNF8W(OzPv0Rn`=VbxXOT0Ti5l&cniD=ZX2RaD_(&6{<`&G&Revm^UW&Wy_;@*tCr# zgeXr$lMFtSv+2|6#27Sh^tk&qZdv$tL|=(cT`0#o5q6v4D+0 zB~xC{`||(;vb}d%ueOxh-?r0F*7o8Mi)}pucaed8xO?U{%oYr8 z$GuL&0U2-yR4lLr{raqfe`MDfs;F#585OO9RP#er| z(G4-Sq9K0c>bU$vx3-~R06SLr8iDWUcNF#gkle2e(=m6w|6`Bu6SQ&fe~jwvEw>y4 zrTW|(wgfF1fIe66d?uHfw}0o8;#*SfoI!gWMh>{0qrfXu_uGsI{I`t`=RR{8$60v= z4~*p$yQ}N0nnjdGV!%ODF6c~jd+)P<#X5DYVWg_mT1I=n*GrOX6jQcbQM#G|8f`gK z&SiEweQgOse#RqQ-I}4ffwb& z6J!5&jH#D-98KOEo_oRUUWC4h#$5e=()$By z3iZq4&!`MXvAN6QiDI*H=hyXP9Rkm&So%tlpA37v-mtZa>8;>eA*P|AcH(WWG}65; zAy-gaL!aHkY{}Sy(rTvQV_e&?$P1Cvj3!TiP1R+09JDM2R9}!APW)OgagmQ1NT#4q zFydruB+?BH6=&*zi{!W9 zWD4W;x7=ZCWra2g@1*EDAWT7o~5P6M5P zZv5^_Rxc*o_rQv@zZC!jdTDLk+O8svWzzcq?f zq>rBMTwRC-csN)g3y_X1{XbKOrG32t@pUOOK+-k^oCb~~4XZyNioh^rKg?p#9oaSI7Bj?f^L{Sr7u=%&DO=7eSggmQTmVr{DudE8#t= zi9?V|Xf;}3p%n*tPSnf@Al91O+vh+9js}T2g&&}ohvbX8x5aQ;xIt%$cc#@C&-VE! zE`dl;_e7bE`1#LW3K38GP4&k^+LoGxo@xXGE+9Iu?tW_lPMQH}9Jm)r(>2V`|Dc_? zL`RYJVSozj=A|`KB07&X&9#3|)5SEmFNC3K{<`>DuIdq+cr} zdM~z6tBvuDflwn6`+FW{!YB3$6`WTx6TE4a^>nK%T_gLxd1|cKsP*aj>DsQDvokNU5Eih?t#X8OZ-P z@dNcokxgAOd_(m-Flz~mrtL@lt{QLmm3wOvhmX$$mPk^Va|Xn+f6){4MtWzXfSg{+ z)tXH{+c1>#(=IOjZmC{i8#p4Y^eP9_*RZ**?o>m5Crri4(g8j;A^WA0tv!L2s|56F zOWX3cYt2Qv5vRX2-eW0mqhoAa%WtM|bD>6xk|()E7`S1@Mape4bk4=Jl~^koFC*US zzhEgb%Uj6tKC8(@CS$}dOmd2VR&}}I=0rxsf%)q}QDVRKu|L%t7Id_CS$VTK znXKE_*#X8ZG$?uGF%^meAhk5^r6c%MAGun)PtZY01v1`W51!iWlZY&~o~)e;EIH|D z*Jx)2K69^L{pyDQU8O`{&{6EO+h*A5()M$}Zm?GPqf5^(VgPO?~X?8{+IIvWm&Wi(Kw-;}C12)TJIaAu7pndzx z6;JHHi`3A@;5YOLlg&BFFV#Gds1zwZu3MKj^$&~N-&h3SSR}cx74aGkII=}c#-p}G z4m^!vQH2U}c7$u4(N4q}pF)s7RAERWYb?vGAM@T%{9s%eAC=tz zi!q!^pV_$CJxwkvbHrg{M;Bl&Re0IN@+o=1z~a?>g;p2)XPO8$}s{?3+T3M7!|e z4z%AQ1N0r-KAsCY!p#I2=Qf_Z+1q*QYdf%|GwkGbspFLNEJt-$A2E*`uuQ&uZ{wfp zC08Wq8{z6|7w=u>qNVlTKemQ_^(+2)iuRPCf*zB9%TXkXXpT#XQC>2IG8J4zs6$dS z=Q@Qvnasdi9J9k?)GOy6xno_G$Z=Sf$DOcnfi_)e`;mk8<6P7-n?qltI~DgdwI40& zIb(Kro46Cr9JWm=mHa)dsQ!ZmKrEXW)rzb%IRxX~$*%kC=-_XGLi%5?-A!u`>h?p1 zSzQ?#wcc8iS#UJ4vo(#1v1@DbPS^+dRyoPFqDx5KA9X>ub+bMLH zqQ*P;?cZE3a!6zH7E0(XiCjLUV+xP}{|X;x8!yaae3oUnU1>->*6Y}zDPE`1xnAdd zwCQ{_or@73#B_0TO2k(|qFwn&wSbMS&zW)>UZOjL_Iap5C#QckNHXA2QPHlc((g`j z@nl>Gb)OS1F$U7YhSyEpBxq<^wz5Vk5&vxlIPY>|5DB~5L|icWSgGBf{WgSCjBqiH z=CLza&h1H}#CsL-f?UpS7rBbRFi|by8h9`KD6zO}xcA>fJUpc+>MSPAB7k!xlt|1S zaN)n@D~>l|)c_?OyF@POXsx_vLW9#6X;4M6%x8xu|~jrd|t z>KhtCMMEfg32Ph?u}A4)=OA?HYj~`uJ1h&`7nnaRq<4CwtTxpF%Pyl4BR|gaV;oK4 zD~bI1(qeLpAu?~BxUw`u*0j(DP%!+TcrrP5w9xZdQZGuLxq+jBh}`4 zi^9zWRH~2!!#u{I<7qmVu$kBt9HHTXB&~^{&zEn{)!+S|h$iEVm-9TcD9XPgZS|lh zRR=0EhZ+U?i0P!AEoJKp*%--nbS7sXch*r`<5W_KWO>sl8ht1Sc)ODUI#)pX08)>O zdk4IQc9GbZ{q5aMS5@i;Vzt}Q@(f^sGh=|E)jx3P4B$Gw z-+?*hh^qA+`L&g?LuHP_w#Y?2$fTK!9}%l zR0riaj05Lb!1lr6=_0(edS|p=oU++h}|i;RQPHW9{{GKCCCqXI3hN# z34rsesF&%d1WpmYD=9CB3|Q_+{1lZK^xXUFUAsj?JS)M+^>iiVIt;}u6F|Sa5cedB zy~Y6{(yygP+o`~joBJU(0*bEHote5-OGl;cf!}m2$!AWaTJ_8yDeRy`&^qFTB)Hg} z=#;*5@(qx`wnbJhyu4Ia?W-pd4Y*zU%dK2ieBpg+g2GM%bSXXlzLl#OXN(fLpO6bI zIBRgRJRGt!KFnib;ousSsCDU0^@;3pNPUJ0?BZF+*>7S|%5>WHZ>zGWrby}7J~&P6 z9}XWkdG}FEC-RiJ#4fw)&oT>NM^dFO#q?K<>Y6_xl{da+ir2J}E)7zmS~|8{Rzs@RWgQMzdto6XG}Xxmu(9$k_c zStcc|WdU8))z@>oz{%P{z8hkJt~u|-#g(?s?ePaI;MwTIjo2x~my!-f#&0l1_0m=- z;l=+uVnpVWc>Rk+dT@y~d(0Q8Ic-aTdWx?0B_Q>^HRl0Tl5eut#0rZv%oe$EtrK~f z9`Z8q}a-Pk?tCu(#eYw(+0VE|(?Lh6BV3aHOrK@f`QvgMaGvd6PwSF)pjE27?l zcrNY?oz>!K0;Fp{v^H${f>KvmaTPk}5{DJZC8vTxJFb?qh(iho%&nF+#genQy^ki2 z7KL2woIE2X2NuLGor4L?@n>}r3atE;G%Heq) zE@fPM9U#Ro6**yAQ6?WdbmM^Ywas)_EzQ+b{j|haq&k9mUl+Yv^if_*+h@%M^a72+ zvs&s?!F?amtK!I-bIp;$CfeA{1S?@5*{AlKn~FnqoBN6gty$~W5n_e-y*Pt{q-@eQ=d0Sapk*<3lO_(OxB zf|O$^(2VP&K6qdPhKf_c3;wM&u(~q}{;Ej4&@$k2SA~@t=k?#EY;sX>3Ayo-(7PK* z2Uw}0rlv0LbHue4P}TW^;r(J3c-d_;k?w)X<#F-+4(yZowmtZx{jr2=SY7n5^*=Q2 zJ_JX#(4Lt*`ZTb>`+0@*7((EAthT zsVmxCdVK8Pa2@^m4n-vIFta)9%Y%OtF%8SMXWnhsZgj=w+nurXTj|x2Q|#GUO?RM0 zjECDXa`?)joOBPBKfRvRf>%;QS;kYmAA73h)u_k4-3fjig!=ji{LV(a}J0%AdqCIRJk{ z|1-2dgPnK_=vnwRY`a1Ui3A{rMbBQE@}f5jPtda5cPP-UjfM^~fuWBnzs2I3Ex5ql!Oo)Act}Z|L3Z_f3dTJejGlHsR&ULOsYI^F*lz8 zs!g;C%DdAaYc6$wo=CusD~TAVr3n^FDj4(NN%QJN0KoA*)Vfa3?z;%ei?%+Tk0GU1 z1ZI&H6&NVzgv9X*Ou5q3eL%@Ezs-W>L);r`30;(w6Mt>i$rgb<3q=)D8lf4O7V>1t zW)tS9+(|DCAe0i!JvyqI@Kk z`mX~FeKX>Ny7&LhN!V1i)2@-}4@Gk*`5u~Q$gZn5-SMc6X4@Z^k?FUTs7Ah7y&-^G~os7sayy*RlH#zvo|P&NOMF>jp-WMp&*el1cX z^>5EoV6RI<{4C|;7bO!jFh%vBb7oE~Y>dBEdN9#|oS`V~WRegIiw1FiVu?Uwq6Lv1 z8&ypAwzF`bTPLdJP7S%DsWJgTw08eRIQQY}J}aEbdC%WXz->^CQpL<(Jpl4Z);g4SI|FIKRJ7+48u`s?f@RBq z2@QYWoD=rJW~~8!wl~?_QItO4|6P;nQs*FT=VoCW9lMF@Jra*2Za><830WDREo&#O zG@DIVDdw*Q*JyGyG|>KS2A8<8+Vo%D4am$|6TE%@)pZlqK2e zMpaR=l7FnI^|uM_CawRDc=QDSsZ>^*!%;%cw@M|0uLRHWVlrB-tz3vhpWUvN5{?|% zWBF$ml<&J)+z#bRgglv%H1xZTLgj3ZYHG3$u`~U=JzWo1nF!s>v(= zK{=slZ}@*_en{B0E9^j=l67;e2Af(jVXL}JI0i0@uurQh#BTN1p1PAvis+^`v+zic zwJ>iV8cBS*{lwC4H$UWH^7k!x8}l%WQ_m~@C>GCQ-&Zc-E#mBS-)_i63^knRUPK*g zV2%@n;!)3+L26Zf;D1h1m*O`r?YDPv?^C@2-7Uw8;o2T2#CrL}MoxF3mM#Yb}epL=|SUihdfd62KSDF_)r@V?W zh-#Q3#v!}di_duf@NY_nR8D33fTHB6$vGxPwCwEXB1)2~n>bL{zdTPqbC^h(Iui2l z`csRpd~oT$_%*=YRb3uskscXh{b@FlA|B<7r_OAzNA&B(k6ayjYCkb=kQ-_CKz$u{ z+U7BvAHF4r;C|I)IM90|QG$X6D7}MDH(b}3J8+MPe~UqKPvL%z#SFfXZa47SXS-d@ z!f*ucRfp1xD!%_NC9_8ot$#vp2Ef6!I}_{b17$zXUZaCz>{(;27z|ms#d3=8Ak@vL zc2`>pKF@+(onOz?$DJwJD}jWovax;*Md-GC*|hmJAYgv0NR{Ygx*|RMA1r_f6Z8ck zYNkX=Q*!kudWG=sod3IQ)BC-y)cr7OSaYg=uea4-^>N&-CnGVkJHrJZypV2TJ^#7g z+oCFEV7(AGg9ON8*}aSX_#5R8D=oI|hDChJv@D06gb>IW8}PdSi6L{Ia3;OG3^%Tq zOkWsO9A4|dD><0Xnc?p(I-mph*ds*)g%RNy=am5u7>pZOR^r=Y`$v36e>SimiQ)_& zrv2B_IEHV-j8+N#T2fxtIHQA+3`@skEOHx?$@#aGAprFCukn;@m6s{kiv-E5RLjN41~gnD}r9 zndc>bArW`IS_>iy0woLcIdOwpBglzlZQKh3XD61-c!uHgk@5p2=QEdfa+(zT_iul5 zBcznDdk+mULV`kqE?ob_w@UL;K9Opp(%Mus5YJ43O#Uc+PhVFvR-JK4-4-WI)`&_E zeJj!yb@}24i9hW88PTjf zW7Ay-d5yFEj;p$UzjGkAgJK~rvFfHMdI*gvqIi?#E+%%s!OvT=3Og6sgpGmV6p3pM z7`3$hR`yMmKRKq?!kc4}a+%5g>}PZ>!V1}Zk~oln4szYw#oe8z)`@X_35dWuU9(-z zd)fnM`!dq>{YbUoMG-x|lI75Jf&J&2cUzle!j7(AAXvvrs2P@pjvBilbhUHi zS%_1u&M?Cl~0y`Q?oNym|aD;@�s&?D^=2lSw=~7Zaq`O;5 z>F$#5?p9J-x)}mqS)9fOo{*b8*_%@9Rj8KH)H52Skn_Vn)g0eRiZQsOmP-GoqWY`k6EL^QaEcwK zj_%3^JMYQLi5U_j;ONT`f*g4vi6{}I%1OuTp%T4rkHwQL^+CaUfxZo){VTp-HTN^ zAbb#K!VcHb{Wu*s``BCauf2gkFYFsE(dpPSstP5`U$1KyEqI2>#GyN_CPlMg*p-Iq)^qJ%x<^zO7YDJvo$^kYPvVc)93Z;iOi&0X%}7B29Ej+>rF5zf_E3|-T>Nkkl4R0`}Z)I>~RZ`FPqulo)%G2~ zm+r%1@zb=O;=bys!SrV9v4NooNzf~#TW_MZG6U5RpeyJ#u#s(*qPpmOr^h8)tZpGK?AjL?Y$9aw1hsa zj#TR?8@%)V^f>_EXev0v%5D%%k;d+u=}>IZl+&_k%8{n}?mWA7DOlD>Bhk^@V7*YE z`HRbQ=2IEJE@9-;Dt3Rgjqp8Cje&w+ z7i<@#JU9?<6ZNtJs-Z^B#JSt)7-=6}hKq6>;KBJ}w_|KTVO&SB^zhDTs`<2D1oaGN z5n6d#2kBx^WA(Ua{8gweMz6XkC7r!q7u0mqkwEOl+p0rU>mVz74Qqwz!t_V>0?3~E zh#qq170jx*ZCvx->e+Z+kI3EbO$X5Em$?LA-Gy$KRW%YdZf5D-eO1X{cYb{~>p|0! zdfyPqZ9Ue!hIg6D&?kFmCsM*|dv{C5OLy|1bq%w#`uT0B@lmX}E?R5y8Z2wvLi`EZta-jKeyYb}%#c2+?%})y z!e0+$b+*6l6y4v)AMm8vaCjN^=_bx!-o5^ZPZ` zDO((Rh+qSw6@pf9XmNp*^Mc%|9bp5nKzG+iQuEzK9?o_@t!8^ohq52Cnt5NUWtTQZ z+z{Rr^>%389()Wu+2;v*R<$0&jk>$P^Y&o4xFyKwmy=skCdscdpE9_qnn}{2FW;A~ zaVjJ^klg|b14CINO0>|+2+wwI{dm+ctQN0Z)}6h`p5JnQqj6a{a26EJ)SJ>W#vO7o zsKxa|;M0gO3YWGQAruUBa>kW}xnG`O*m0i`qfIx1{bZNC_p9n>sI!!{CAupQ_&kMi z3#-$HzA-J7*xY-G%RBQOmo;7dvI*ZBm2Wp%H0!!(Eo#b=H@1Y`CkTiL2_(+c0?UD6nF*r%_z0cX}HndI~@eR!t zz!DZ}if-4-&EPI(W}71F+O|Vwm3sWF7~Li{!>L^uJxZLiz1#7CphzbuN%3-?$I^od zQ}`L1i#$@LoW-=5Q%vCG)f~LT09{>9m9g{?wwmoB5%<)*LN|E!kA%Kw=wMMzdWO|c zv&yy3N9`9F(IgBsQCJBMRcB5R7TG^k~&K2nnnMP?v;~L)KU}fhtZZK#$-U;Q-Y}`QTJjk_L*2)DGXMVttKA`mkzBPLsgPAjY-OJ z`3isK0F_UCKpteBe0N&jC_}!<5{}txf%`D;w$-4rM_qc!Qx#Dt!PDTlMafPP5+wF9BXw^{fexcEbP2tjCLZC3kgPk(b&@0vldh)f!+9VgI0B{if`ZN?`D;8em&1Fg52{Hk<^vR0aMYQ=i>PZIXNj}oc<*5i-kdbnapwg3qOF0; zR@^!*K!k_{hS8!`e#T&jA0@46(a7%7GUgycl(ukcVB#&;U48tT$w*3|Tz;vQZtjwB zqsn=`sRN_@gCSWpF5lIrAqgStwerADdP~?wv%vLr!9%jmozXr%FmD@Hdvh+Y*_$D^ z+4L2RF2t<7Yn=?bO+HO0R9#lXSE`=Wo2SDYo!%GcHG91sy8<3Yd@WNxCSp#7ToW&* znk7x-TesId(?5!a?<@niG>dYh#X=Km3$qp%1{PapLMBN*>{LF=aFt=H`53l9BR0E4 z=-JEWLVU9`eo{@-vNV{+^nRH1aJs|3RKS9(@pFx86V*1MH&uHA;?Ac zNfO+Juael%L6<-6!6e=0zYjeC^67(Le{~3JFnrl%blpkt1HvFU&0+Wd89;@`_l=L| zWM%mRE3m%F%VJ_N5O@V>xwNnnvM0y@bn^jobelSHC-4XIan^c8O~tTj6|l#Qo?(r& z>MyWcCQ@KoK^p$ZKBkR4MqnckPZT@kcv-%}i%bW)j$~B>XsuuBPqfy>`3mrTd#aep zI1y5j6V}I6X_f3@qOWiiKPW>>O;2Uwtz^^?62b#)fB85uIay9i2LCJhn3PQNb7+U+ z2ScO5JG957>Dm7`ocJ%t(*HAv`OCcSA4Bl}is4ycS^p=^qWR=UXyGarbc__hqK`mS(9mpXo7-3va zA{ydWHv`*##pC3oEKpC%4Y9haANaPA`&TGH9t*?WK~wI!xUBjRlMe z0$T&JEjFy5|1d3U>PF_~f_7Y1&*Fgz2QDpFcGi_oLQ!#Cr$WFQ%WwSL{zsd?zeb9M zS|>3ndF#ymcap@ICS$V(;R3juRz#9U_M>j{=#@b$T!MwMl(@lafN1Drultb{@UN*b zh$~6eS|5@m5U;}VwylYfOTA;v%K->#dpppcPAaDIm`^Mx#nRCjw!&c|gi%@U#gXB7 z5_PP<)q7mUFA?tZ;5wt;&+e1=@OH`k$<<%ICKDCL@iQDS@nR$B!~Rey2C#rSHnALlIRnoCC&Nm*Kn z*l}T5n>`2S*qv$MrFpna@k9?|%1OMjRL!udQ^Y7ca5KSy$hm3}lZ@e4F$2pD7`_Af z4;)B6qQ=S;Tq*@-s%Vlw&VmjS=E*(-z&>oh_P!WjrNdG)xFE zpNFNMAXYj*uJKoUL8B<(P@7}1#gk3bV7S(oN`SO55U_Zv;%Xp=u-YH?*r>AqpI zKhAxD>zr)odWp|JPNtHbQ`)TUptKKZ4^%j5Qw(gz%tm{qUhg1O(AJh~adVVGvbN-Na6)+*8+xr zn3QcjppX~eW@?8Ubhth|ydDi*bWrIUAPFb8JF+dBWnEu>zk*I$QlB=-+%BKgw4EQf z8LWG#Z;I$pRihXIUMd_KZzK+e*Oxk>*|#99S_U7G2^6#mefJW4ak zufdLFk21zIJhCP30(st)svn7fyTaWG8cU^4jJ# zFXop9n{8h3Cz{2I7cVANv6dGN2vJ_Vd__daW)RlmZ$`_7&UOagN7R`@*V`XM>C&b~ zYj`%G!7J_lxu+`yvyy6)L~)ZgWnRpD8r5rcI%1uMy#tn(zlPA{fs)Seqfu9UwCEkc z7Z`*v7D>?eU49T)oFNr2V-cT?ks7ppPm&xv<9BC*iE(ud0>@~FwQ%N~^&Y3;!k6{C zJAEfiFpcXmT5l7cLhb6H@OY-Fbtic+PVNF62{Ga$R__y5Ykz z7{efNe4L|>?~vsED)J8%4Gf;OSGdJnbe|>ICp>^YVt!oywsMbAu8FH>K+ikT%TgNa_ROiW zcn5hTPKU30ziCjgc;N~bM;c%|?c)LD!C&HYsCdL!;R_QGi-@Sllvq!fPMPeaYL>6* z=WF;!L!${(c+MuK_kvWhZbYOAy$x+V^8}aq>}RtYI(6pBh6cWGs_-|E#!{J`6u6R{ zrB|Lpp+$KpH=P>=Ur%Lvhlpk3mO^obnO#SkZG^T4%8<1Z1u<`ffs*%LQX{YDe68v! zJxHaXky1Y^$@1fTC;fvPPy%gk&>QWZF~Q)lltDmd3~Mbpn=u@Av%?*ge+DD8|}Kd`$I5IOi1PjH>QtlB%vTOY9# zH&ah4YJ@OXNenzo-$6d|yt$U3l6cR``g!=tPzJp|e{QTwO$4P`GF7VjOl5Tadi8^4 zeapfgO?^iWx6UUmK4gQBrXP)D5i{c=eL4`upra}dThMc6S%%PFc&+V>o-*^A4w{Bn zkw-tVmVtX5%G=`+Ax9d#;{DoQ`QUe}e=OH_WH-(P8^6a?Oi@|;0H_}NEo2imT170x z*RxH$sGhFiaf;>CGUp4Oh@cZJ$H)AJ)oyl_8nDV6FtPtuB6s*yCj^SAqs>#M^XVa_ zwk^&P0gW%3af~$IKE%f;Q|4KHlip+gsZLG82U~>8neaDMk0IH zy_f4RhD>~ChRims+e+rR*z>e!luJ{vf!Cfd*e_O$nT-lRp7y(&+w%x~!nNr{dJLqg zV3tmYsk;i>L8{kIfk!soI3}LQ$2Cxglo9=QrEME?ukHG$JB@lVHQVo( zrB~zJQ`qh@sZ8(a&3!f|uBugoIj}J%74H}h+w5?c*bEm_uc(k@-jsf4CVf&=mSTdW^5kD;u)Qw$y?suKzW};!7MYU&08i{*0QN6vjq ze*zXoBQq}W$aXk>&@U_lL>kH7Is%}xvVRqLIS;%_JEMzAQ&ZAX7`!X!1v=qf@Mvw! z&0ASDs?gP#!-*evQxCj!@wDUko8RrFsmq(!SbPh6lGc)}b! znP>(EUZu-DjDL3*s3^<}vi=WYBNFYEl+e$=C^qabiXEZp0vvahDPS)UE>lLWj9CMO;~`4xT13|EfQ85r zRt9HkRR^K*YfI5l|lh4Gln=vvZE^Z`BR# z^qT_P;tR#Op_wiw4tlPT*;1Di7e!;qMC z?54%vK|YXE-Hx;#z|bYD&1HJm*A5bUDGj3KUT+LOJy{I9xBOmrA$iiC zE}(lncZ_sh6oEeH?bcFRjtMD_7{Wi_0QvTIv4bElTKIVEzgNEHu-<4lZ5*0{EWtAO zyJhcm+pCs3VzM~~T~RU-;WW|#iA7hx(rjl4)Go0o_5-riS{7Ra6obtnzJxZ}1Y=)i z-2LOsE3MZsnC&fI=se9sF)7`1@-0rs9q2a;3xP25aKm-pHNSAlTg7d$DS8;RZ6?-r zVJ_Moi6;6oHJXKl?EYtN&ybUKbmIgNh2iH-uAOp5vp;#_q<4bTHQzs)8oi*xrrK%> zFjFMmFm-J-Rq-1esNa8a7U?aQqhn`{!Z>;jnkIYY3hU0aps-D-K-xr%9U0zpx z0rRoihv9h3yK_8iX<#3kgVN-R-8Ok4Pp_v>0te>57Zm8&P5#4&byO#J_8{hHu83^tG2D_)$-6b zw=RC&8xL#cq2QhLRjPOT9c4^N9_yu{$#qAuOFX-)&Yq)uHf=7qWlSPlCEACO%CAKH z0`KXa_ji50kXoj-v87qBdqOrBb!)k-yDGk_<2UmpiM4-C_h#uSTU|bcVvZ>~Hy_S* z`@r>vJ%DfPxi811qlkRu$q$yb4gdo3k~;08@@amb-PwU-C#YdZnap}2|pD*QuC0+Wy@bo2B0wImJ8M_p}v0;Qhw zcB3`QkR3|03shol7^VJBUyuofwnDh=XVCH9O+-3tx1v<4F3RPdmr$2#>#ZXeM&maU zO#!L;ym!taS4)+=6ktN^EkShXk+HDncxL-u=Ep$n7vaS7J;S%34>v#VOZKP+P76$` zy%;!eYq=BUYGLiJpEQ=JxER`LY&psNX##CkO{9;U=dS&*NgN3iLu2e2`?aX|ZOCQm z&RYfj>WC5zAP%uIW%;(NQrS$9PZ|&quid~kevw?U!(=J_VuQM&J}E0xR3cuo!d48$ z7G@WVs+SF)wXeaNW}a5I7-SO911N`I+P(o=#8M7x)1)K@i!G_3=Xh zPBQ33LlRq`m6dB1u_A}5kw+*i5bb5XnOK)YJv2`oOZOb>YDrh*u`;oXiuPe1(B-m- zXH(~b=hH~yLiib#5eI!0J3QwCQXA;cpWQ~4U65}eBbEbD04xaP8)56LL=L6N>%Gaw zp}4sZhFY+#r^QY4D5%e0BC)U_3C^M^{BRq~RYV}%9H>3p6W&Cgvi=(jnEt4{@Gy~l z7GcA%Fb~O2W3<^#VHuoIk5J_yIcnF(-z2fI(Zaj?uwVMAz(#h!r95zF^xn?|sWjb- z&vbu5#FtOJ{^JEo-522T4~pG*uzk>dR2bA|#^*D#&8=IWY zoC|Z=Elud}5D-3(cNZ7?HC$ zSLBta$g^vWWu7;sD)rjs@0sR_A+rjdZFe`MgC%j@X z&+X$KFQd+y;C^S%V((7yJg$eg&Uj+QmK7v+5*u z*8P)JjgOo3z({LQc$EwGlv^8^2}eTqYDTZ9gS%yg0~p?()|R}o?niUUJ12!AG5f%> zKt&5tHdcq%14A*|)AecrW{_`*aa>L?zpm^iPgqQMwi_UzGsvPxL?Et~tKwCb< zW-EuB!f+zI;Vj%hR~8SD_Ba4Wat^h*6Lxe0UgY@^iw)yMZf8N~r^4czWzX4V5}4C7OkuHnJByqGa3yHmjXxJrTGYhT76);Lo#4}iU( z(0}qeA?HprgQ({3yP6miys15DKc;hljN*#W2ggk5Fl~Rx8XKRWKmn(I>R`}_GW~?n zyT%s9V&K23ddSmj86E+uF0ki>mqvP$Pai69+g0-hG(&?^!Wy>twquh+WPpz(t(^b( zsF&#-{|1IdB|7Y`uSOP^0f~@pCBEb)Bk4u|*fmmu^=v~7Yq4>iz5ifezLZoL!;m5R zd9}skD1WWkEuX_I9p^B7NOz#%me6%%rznY`=t|9N#Tj~eso#JgI+WrLjZ33orr?P& zu$$2-vmwMC29fS>dVY6wx9&F+`P7nZfjBR?l!&!QMfy%Z)uzAb%(4MhJTx@wMZX41 zUaddc)Q_aq3)z_zN+c9JR3b$-qZr4vs&0caFjsp1fw<9cql+5%oOC^0mOMf(7@J+KU@>Y`(j3pt$g#LnCWyt)U6P99ci9Wfsy^>(CUzK}# zc%#2zFCa)2&k>IBZHys7Kmcc$DbNn}4Tk$v-`?K_aKFH2fe_{UXjD`PX5I*OdoqezArA^;l2Z&b72vv| zeOS%pPcSq(%c0x}K{Un&0zdE{Wni$?oJL=-{RM>!;AKvAB%l_cTogn@^a3`IwDB?B>|FI6#K~(qQC#ZBXAtuY zsqvhxXA{D*-%`yE7g+{pE{lm2xGCI@wwS<(8oxUzvD zRk;tYu8SB+4_EoV<3yNHEMSrlI}?xz=Vb?2BxuD`Alm-0!EH3~)8^5n5c7Di$@tZ; z3T|N7odkg}zTC@Vj>knR?Dbc>%|XK4;NY$!#>b(=DFb^ca%_YOZ7|U2{8%KhP7OWT3SGr;hf?0|xF%$$yNslvy} z{^`2oy1KZDjNw{e-?fgAE&VW(Qt9=R+$&ES&AdlAarX}hH~^-#;R zlAN*YFBe%;Cy4_0p>MLzjbV8v>C#tEV7r=`!^KHR``tP=_Bws3^D0I3bGw(iJM$)T zs0Z{iU=9C*I7`-84t?Bp;(##AjFskpL6^8NG2`V=W;+NZb!$ zb<3I_uWFu)z%s242|+A#JC)|_B*>{xb9f=R)S$pZu2yANQQ$pXo_#dF)XU6=&i8}K z>$p0UsvVrL8{RfWw3sIv?|d+PeaDQVYicD!BlR?p@6haw7Ybx1LnRw#kk4vvZr8*D zM(ozDPT-nP`80sL4j_~j==T#$J4jZsxaj4~gX5`{%*8ImMeNUUwj$a)8ZES!$wdde z?W}h3q;eDuiMVyQSeH9EI?qrU8S24i?Y6(^u)0G88{@kD+=Gkz43bcY3^^r(4rRUT zS9R0%)6}?22kWn2ajXygvAKo8Y%k=p6h!dF>hv}j_+)7jOf0opho=YHMRaK{y zjWcSs_4H{+r^`NOoJq-#Wrm114OQW-sWcm5Q52=uLCnldzSZxJ8MWOeXWEq(9kOp+ z&?el=*5Thwos`{Ga^&8)-4D$niG}ZND{UId8}}!f*Te&TlXXD|I8~xuOkz!pAsX4M z>|$6J6RHx7`Nil}*SmBxj1z)}TI0si(SrZl``&QlF!H;e5Dv^t%VteyT#YbDe zyD6~}5$LY!8lJ&r%p6nch$m@yYm%FV{<=U0SOTD6FJI#TfI7&H0fYm{m)n?%oBY?# zh-V_&o;Q4BWO@!b2g+&Wv9qvXtDPiuz6xtA5Xqln@gkLK1Y$~kDHd79evhxSC+NK_ zsE11l;M(P5O8lVauY`}~1&x=INX;7A%2;uxPVO5>++Xpeq@@8^z@K2TNU)EiET_ls zTfd340(yLzkpjiTJ2cZMTQ=aYzkd+wzpSYNsxLsu1$h2m0+psO`5#0?0+Qa`>V1LH zDQHEg1pO`(VGRTI%8-;F;Xo$(^Cgmdluy!coU`VSwx`Y+G#uZ9O{&clFFMsU0a zctT$T7|t*8ZR6wqL#rVrSAu2k8BvZsFi|~ z=VAfJC6*F@&G8=J)&5MBUn+dP9Seo%oLBP!>;P5zHNfwE(8s)SWg`|jSGoMD;Y__G zddC4AW`<7_(xq3V@Pv&qDs*xyL~^`ey`sVtBwnQ{zYA}~Vyky{*|u*%YhQL(7E*;) zC0ba%&Eqt43wms@lebrwY^LLcRXHI(n~kGg2+ta%Ia-u5;-O8xf9b z+Kf#_cys)MUfR3VA!Z)@9JrYotKX-0MOC4r6(Ggba&8#MZ^Z7^6xCyh?h<Svz}Sf8;C5@N|D{^RR`_k2t2#k=R0WArC-b2bGI3X z(_LC5Ikbdn+PO@`Y7)BtmeouJ4#JrOfR^cdbKd$N`AFi&vtvTeq8{l|xRQg`WVlU0m9|T)Uos})QqS!?* zq`I7M##+pniD@X7+3z!D+D3%?g1lZ8-^L_T!x&fv{$jSO=N{FKc^QVhKOT#=xr~}^ z5ZOhKvPpRBxqVo#%tuk=6OsS@8+J=4?Ot3Xy*ad1(fhC@a_(6CvI%)MpTvy1ESMg6>Y=MVWPEje-W`;j^$&N7(n=*y`%QkO=*S_szh&mI%L=P+2M2 znd@d7z6ZG2>x7DLekNVBbV&aN1Zkn=zARgcaf6UPZ@SB}={cT8lD22yh5KSGXi+7I zG|x7s_C%6@OkNOFw{b|&(@l57!X|J%yWdjz$^eO*XO2<83o`4*sm5W><4@0dcWgTp zNki1b)Gv+n{*rb`+(Hb#fsyYGij8Qy7@zbcRy)o+qC%v9`U+u8Q(IPdP)A) zSPL_PuJY)JZD6H;p5Y|*wC$f~4?m-~qTajE<53Q3YM!QDjEa)U}Sk;llgoE-P=Rx@pJv*JUWrRHn2 zJ+bE5ghUR?b`X#jA_Cl+F@DUozur8a#v++cN2`}U72WH`9brmpP0pb{xR7fl8Pj28 zHl{*=Vd2hF>x;`RyFcOC1WXq!et2~;Ii9v3|ILMxP3z}bx_D8q*ESFLMX0)t)*+JIN(mRlyqdPKz1@!%hx8bG;=RZfE(`Zg{-)ebQc$&XbtMEhOJj2n-Md0FHceLH`Xu~!lb{|!L_ zolVpO9n(xk(56hXjXx{f%&eq0Mae(>Wpv*V;W>TysL{9)jH`PH`K~FGWQ%ttIDqvz z1h_6lT2Xq9x^tTAbwysi;V?d#UqIx+%`SHBb!*?GA?+@c_wsz|=@IGfux+zlB!+c> zJJPt^H{xZk%awI!vUDFB;;Wb*fpT(&{Mq|S-oXHQq)1Y^m45n{BCy*{gl0X%MIE&M zDC1U$+rOCs8AEr;_%<8>EGjnyq^J0gN)slF)Fq8DJ+|z_uj}o-P*cS`U6mM z0Vn^j7|PQwGTHebr$tpJ#6Dum3TM%g9Ai;)lVa}Wler6@A@c=_y_N&=vq#mqK$cX# z-ED*^Vd&|G*=*X?Qw+RSHR!y`vwl<+GNc>gAs9#O0}i{39FC*Wm=!ys+Bco$WfaQQ z1pY${@hV-_cV}@|k!`nc5QRO_(-k4f8lXC6=JBo{)#&mmW1+Hy z>v&u-ERSj3Q-qofaEP=P+wD9s&uVB0S9bcY$W

', + new + '\n
', src, flags=re.S) + # A script-looking string anywhere outside a real script tag lets a regex sanitiser + # strip from there to the next closing tag and take every copy with it. That is how + # this file was found broken, so it is checked on the way out too. + looks = len(re.findall(r'$', src, re.M)) + if looks != tags: + sys.exit(f'{looks} script-looking strings but {tags} real tags - a sanitiser ' + 'would strip more than it should. Write "script tags" in prose instead.') + open(PATH, 'w').write(src) + print(f'baked {len(blocks)} copies: ' + + ', '.join(f'{k}={v["h"]}' for k, v in list(blocks.items())[:4]) + ' ...') + +# The cabinet study has six axes; the same rule applies - one copy per OPTION with the +# others at their recommended setting, which is how options get compared anyway. +BAKERS["multiace-cabinet.html"] = r""" +(function(){ + const seg=(g,v)=>document.querySelector(`#seg-${g} button[data-v="${v}"]`).click(); + const grab=()=>({html:document.getElementById('app').innerHTML, + h:Math.round(document.querySelector('#app .fbody').getBoundingClientRect().height), + cap:document.getElementById('cap').innerHTML}); + const out={}; + seg('units','1'); seg('faults','0'); + // Every choice is settled, so what is baked is STATES rather than options - and the + // dialog is opened the way a person opens it, by clicking the Dry chip. + seg('hold','0'); seg('drying','0'); out['state-rest']=grab(); + seg('hold','1'); out['state-hover']=grab(); + seg('hold','0'); seg('drying','1'); out['state-drying']=grab(); + seg('drying','0'); + document.querySelector('#app .drybtn').click(); out['state-dialog']=grab(); + document.querySelector('#app .dlg .dlgx').click(); + seg('drying','1'); + document.querySelector('#app .drybtn').click(); out['state-dialogrun']=grab(); + document.querySelector('#app .dlg .dlgx').click(); seg('drying','0'); + window.__report=JSON.stringify(out); +})(); +""" + +# The modes study bakes STATES: the three modes, multi's rival drawings and its two +# hardware truths (the measured cross-lane feed, the shipped model's wrong resolution), +# the two-unit and 560px cases, and the switch's own four moments - the last three +# reached the way a person reaches them, through the panel's pill and the machine strip. +BAKERS["multiace-modes.html"] = r""" +(function(){ + const q=(s)=>document.querySelector(s); + const qq=(s)=>[...document.querySelectorAll(s)]; + const seg=(g,v)=>q(`#seg-${g} button[data-v="${v}"]`).click(); + const pill=()=>q('#app .prefpill').click(); + const item=(n)=>qq('#app .menu-item')[n].click(); + const grab=()=>({html:document.getElementById('app').innerHTML, + h:Math.round(q('#app .fbody').getBoundingClientRect().height), + cap:document.getElementById('cap').innerHTML}); + const out={}; + q('#mreset').click(); + out['mode-head']=grab(); + seg('mode','multi'); out['multi-cards']=grab(); + seg('mlay','lanes'); out['multi-lanes']=grab(); + seg('mstate','measured'); out['multi-measured']=grab(); + seg('mstate','asbuilt'); out['multi-asbuilt']=grab(); + seg('mstate','clean'); seg('units','2'); out['multi-2u-lanes']=grab(); + seg('mlay','cards'); out['multi-2u-cards']=grab(); + seg('units','1'); seg('mlay','lanes'); seg('width','560'); out['multi-560-lanes']=grab(); + seg('mlay','cards'); out['multi-560-cards']=grab(); + seg('width','830'); seg('mode','normal'); out['normal-strip']=grab(); + seg('nlay','quiet'); out['normal-quiet']=grab(); + seg('nlay','strip'); seg('mode','head'); + pill(); out['switch-list']=grab(); + item(2); out['switch-headpick']=grab(); + q('#app .dlgx').click(); + pill(); item(0); q('#app .btn.primary').click(); out['switch-refused']=grab(); + q('#app .dlgx').click(); + q('#mloaded').click(); + pill(); item(0); q('#app .btn.primary').click(); out['switch-pending']=grab(); + q('#mreset').click(); + window.__report=JSON.stringify(out); +})(); +""" + +EXPECT = {"multiace-f2-iterations.html": 16, "multiace-toolhead-card.html": 18, + "multiace-cabinet.html": 5, "multiace-actions.html": 19, + "multiace-modes.html": 15} + +# The actions study has an axis the others had not: WIDTH. One copy per option with the +# others at rest - and the two sheet copies are opened the way a person opens them, by +# clicking a bay, because a dialog nobody clicked is a dialog that proves nothing. +BAKERS["multiace-actions.html"] = r""" +(function(){ + // No rig to drive: this study is dragged and clicked, so the baker drags and clicks + // it. What is baked is the STATES the panel can be in, not a set of design options. + const grip=document.getElementById('grip'); + const width=(w)=>{ grip.focus(); + // the control's own keyboard step, walked to the target - the same path a person + // takes with the arrow keys, and no private API + let guard=0; + while(Math.round(document.querySelector('#app .u1').getBoundingClientRect().width)!==w + && guard++ < 400){ + const now=Math.round(document.querySelector('#app .u1').getBoundingClientRect().width); + grip.dispatchEvent(new KeyboardEvent('keydown', + {key: now=10, bubbles:true})); + } + }; + const grab=()=>({html:document.getElementById('app').innerHTML, + h:Math.round(document.querySelector('#app .fbody').getBoundingClientRect().height), + cap:document.getElementById('cap').innerHTML}); + const shut=()=>{const x=document.querySelector('#app .dlg .dlgx'); if(x) x.click();}; + const bay=(n)=>document.querySelectorAll('#app .tp.viaace .bay')[n]; + const verbs=()=>[...document.querySelectorAll('#app .verb')]; + // By NAME, never by index: what a bay offers now depends on the state the head is in - + // a loaded head offers Swap where an empty one offers Load - so position means nothing. + const verb=(n)=>verbs().find(v=>v.querySelector('.vname').textContent.indexOf(n)===0); + const machine=(label)=>[...document.querySelectorAll('#machine button')] + .find(b=>b.textContent===label); + const seg=(g,v)=>document.querySelector(`#seg-${g} button[data-v="${v}"]`).click(); + const out={}; + width(830); + [902,830,782,708,662,560].forEach(w=>{ width(w); out['w'+w]=grab(); }); + width(830); + // the two open questions, each drawn rather than described + ['sheet','head','menu','row'].forEach(v=>{ seg('place',v); out['place-'+v]=grab(); }); + seg('place','sheet'); + bay(0).click(); verb('Swap').click(); // a swap, so there is something to report + ['beside','row'].forEach(v=>{ seg('flight',v); out['flight-'+v]=grab(); }); + seg('flight','beside'); + // a swap is running, so the printer's line offers finish/fail rather than reset + machine('finish it').click(); + machine('reset').click(); + out['busy-idle']=grab(); + bay(0).click(); verb('Swap').click(); // Swap: a real one, from the sheet + out['busy-swapping']=grab(); + machine('make it fail').click(); + out['busy-failed']=grab(); + machine('reset').click(); + out['bg-off']=grab(); + bay(0).click(); + document.querySelector('#app .verb .venable').click(); // ACE_BG_SET_HEAD, as sent + out['bg-on']=grab(); + shut(); + bay(2).click(); out['sheet-bay']=grab(); shut(); + document.querySelector('#app .tp:not(.viaace) .bay').click(); + out['sheet-feeder']=grab(); shut(); + machine('reset').click(); + window.__report=JSON.stringify(out); +})(); +""" + +DUMP = BAKERS.get(os.path.basename(PATH)) +if DUMP is None: + sys.exit(f"no baker for {os.path.basename(PATH)}. Known: {', '.join(sorted(BAKERS))}") + +win = Gtk.OffscreenWindow(); win.set_default_size(1400, 1000) +view = WebKit2.WebView(); win.add(view); win.show_all() +view.load_uri("file://" + PATH) +state = {"tries": 0} + +def got(v, res, *_): + try: + val = v.evaluate_javascript_finish(res) + txt = val.to_string() if val else "" + except Exception as e: + txt = "" + if not txt and state["tries"] < 40: + state["tries"] += 1 + GLib.timeout_add(300, lambda: (view.evaluate_javascript( + "window.__report || ''", -1, None, None, None, got, None), False)[1]) + return + if not txt: + sys.exit("the page never reported - does it still have the rig?") + blocks = json.loads(txt) + want = EXPECT[os.path.basename(PATH)] + if len(blocks) != want: + sys.exit(f"expected {want} blocks, got {len(blocks)}") + ids = re.findall(r'\bid="([^"]+)"', open(PATH).read()) + if 'selected' not in list(blocks.values())[0]['html']: + sys.exit("the dumped markup has no `selected` attribute - the source selector " + "must setAttribute('selected'), because the property does not reflect") + rewrite(blocks) + Gtk.main_quit() + +def go(v, ev): + if ev != WebKit2.LoadEvent.FINISHED: return + def run(): + view.evaluate_javascript(DUMP, -1, None, None, None, lambda *a: None, None) + GLib.timeout_add(400, lambda: (view.evaluate_javascript( + "window.__report || ''", -1, None, None, None, got, None), False)[1]) + return False + GLib.timeout_add(900, run) + +view.connect("load-changed", go) +GLib.timeout_add(40000, lambda: (sys.exit("timed out"), False)[1]) +Gtk.main() diff --git a/docs/u1-webui/tools/check_coverage.py b/docs/u1-webui/tools/check_coverage.py new file mode 100644 index 00000000000..c932a211c1f --- /dev/null +++ b/docs/u1-webui/tools/check_coverage.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Enumerate every bridge command and force each into a decision. + +The reconstruction's gaps were found by a human noticing them, one at a time. +That does not scale and it is not trustworthy. This check inverts it: it derives +the full command surface from evidence, subtracts what the reconstruction +implements, and requires that everything left over is *explicitly* accounted for +with a written reason. + +Anything neither implemented nor listed in EXCLUDED is reported as UNCLASSIFIED +and the check fails. A future bundle that adds a command therefore surfaces here +rather than in a bug report. + +There are two questions here, and 07-parity.md answered only the first: + + 1. Is the command implemented? A `CMD.NAME` reference from client code. + 2. Can a user reach it? A command module that some panel is handed. + +The second is the one that matters and the one that was missing. A command can be +referenced by a handler that nothing calls - a panel with no button - and the first +question counts it as done. `sw_BedMesh_AbortProbeMesh` was exactly that. + +The answer used to be a hand-written `sends` list in each panel, which made it a +promise: nothing stopped a panel claiming a command it never issued, and one did +(`DELETE_MACHINE_FILE`). It is now read out of the command module a panel is actually +handed - js/views///-commands.js - so the attribution is a +fact about the imports instead. + +Two surfaces, because the page has two ways to ask the printer for something. The +bridge commands are the first; the ACE's G-code macros are the second, and the Filament +panel is built almost entirely out of them - so a macro deliberately not offered would +be invisible here rather than merely unbuilt. Both are held to the same rule. + +Sources of truth: + data/wcp-commands.json which commands the bundle references and the host dispatches + resources/web/**.js which commands the reconstruction can issue + device_page/js/views/ which panel can issue each one + +Usage: check_coverage.py [--quiet] +Exit: 0 when every command is classified, 1 otherwise. +""" +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import DATA, ROOT # noqa: E402 + +WEB = os.path.join(ROOT, "resources", "web") + +# Commands deliberately not implemented, each with the reason. Keep the reason +# honest: "not built yet" is a fine reason; silence is not. +EXCLUDED = { + # --- surfaces that are not the Device tab ----------------------------- + "sw_NewProject": "home/projects surface", + "sw_OpenProject": "home/projects surface", + "sw_GetRecentProjects": "home/projects surface", + "sw_OpenRecentFile": "home/projects surface", + "sw_DeleteRecentFiles": "home/projects surface", + "sw_SubscribeRecentFiles": "home/projects surface", + "sw_SwitchTab": "Orca shell navigation", + "sw_SwitchModel": "Orca shell navigation", + "sw_OpenBrowser": "Orca shell", + "sw_OpenOrcaWebview": "Orca shell", + "sw_OpenNetworkDialog": "Orca shell", + "sw_LaunchConsole": "Orca shell", + "sw_Exit": "Orca shell", + "sw_GetActiveFile": "print-processing popup", + "sw_GetPrintZip": "print-processing popup", + "sw_GetPrintLegal": "print-processing popup", + "sw_GetFileFilamentMapping": "print-processing popup", + "sw_SetFilamentMappingComplete": "print-processing popup", + "sw_FinishFilamentMapping": "print-processing popup", + "sw_FinishPreprint": "print-processing popup", + "sw_StartLocalPrint": "print-processing popup", + "sw_StartCloudPrint": "print-processing popup", + "sw_GetMachineFilamentMapping": "print-processing popup; no host handler either", + "sw_UploadFiletoMachine": "print-processing popup", + "sw_PullCloudFile": "cloud file transfer, print-processing popup", + "sw_CancelPullCloudFile": "cloud file transfer, print-processing popup", + "sw_GetFileStream": "cloud file transfer", + "sw_UploadFile": "cloud file transfer; no host handler either", + "sw_UploadFileResult": "cloud file transfer; no host handler either", + + # --- account: Orca owns the session, the page only reads it ----------- + "sw_UserLogin": "account surface; the Device tab only reads login state", + "sw_UserLogout": "account surface", + "sw_SubscribeUserLoginState": "read once at boot instead; no live re-auth UI", + "sw_GetUserUpdatePrivacy": "privacy-consent surface", + "sw_SubUserUpdatePrivacy": "privacy-consent surface", + "sw_ServerClientManagerSetUserinfo": "cloud identity handshake, not exercised", + + # --- app plumbing the reconstruction does not need -------------------- + "sw_Log": "host-side logging; the page keeps its own WCP trace", + "sw_SetLogLevel": "host-side logging", + "sw_UploadEvent": "telemetry", +"sw_SetCache": "Orca-side cache the shipped page uses for its device list", + "sw_GetCache": "Orca-side cache", + "sw_RemoveCache": "Orca-side cache", + "sw_SubscribeCacheKey": "Orca-side cache", + "sw_UnsubscribeCacheKeys": "Orca-side cache", + "sw_SubscribePageStateChange": "page visibility; no behaviour depends on it here", + "sw_UnsubscribePageStateChange": "page visibility", + "sw_Webview_Unsubscribe": "teardown; the page is not torn down mid-session", + "sw_UnsubscribeAll": "teardown", + "sw_Unsubscribe_Filter": "teardown", + "sw_StopMachineStateSubscription": "teardown", + "sw_UnSubscribeMachineState": "teardown; no host handler either", + "sw_SendCommand": "no host handler", + "sw_FileView": "opens a host file viewer", + "sw_DownLoadFile": "timelapse download, handled by Orca's own popup", + "sw_DownLoadFileAndOpen": "timelapse download", + "sw_SubscribeDownloadState": "timelapse download", + "sw_UnsubscribeDownloadState": "timelapse download", + "sw_CancelDownload": "timelapse download", + "sw_GetFilesFromDir": "timelapse download", + "sw_OpenTimelapseFolder": "opens a host folder", + "sw_NotifyUploadTimelaspe": "timelapse upload", + "sw_UploadCameraTimelapse": "timelapse upload", + "sw_UploadAsyncTimelapseInstance": "timelapse upload", + + # --- device discovery ------------------------------------------------- + "sw_WakeupFind": "NOT BUILT: network discovery", + "sw_GetMachineFindSupportInfo": "NOT BUILT: network discovery", + "sw_Connect": "empty stub in the host; connection goes through the mqtt agent", + "sw_Disconnect": "the page disconnects its own engine via sw_mqtt_disconnect", + "sw_GetPincode": "superseded: the LAN auth code is fixed, so no PIN is needed;\n kept in the protocol table for a first-time bind", + "sw_Test_connect": "diagnostic", + "sw_test_mqtt_moonraker": "diagnostic", + "sw_mqtt_unsubscribe": "see sw_mqtt_subscribe", + "sw_mqtt_unpublish": "see sw_mqtt_publish", + + # --- Device tab features genuinely not built yet ---------------------- + "sw_DefectDetactionConfig": "NOT BUILT: defect-detection settings UI", + "sw_BedMesh_AbortProbeMesh": + "NOT BUILT: nothing reports a bed mesh in progress. `bed_mesh` is not in\n" + " SUBSCRIBE_OBJECTS and no activity label mentions probing, so\n" + " an abort button would have no state to appear with. There was\n" + " a handler for it, reached by nothing, which is what the\n" + " reachability half of this check now catches.", +} + + +def implemented(): + """Commands the CLIENT actually issues. + + Not "appears as a string somewhere": protocol.js holds the whole CMD table + and mockhost.js implements the *host* side, so counting bare literals + massively over-reports. The real measure is a `CMD.NAME` reference from code + that runs in the page. + """ + proto = open(os.path.join(WEB, "shared", "js", "protocol.js"), encoding="utf-8").read() + table = dict(re.findall(r"^\s*([A-Z][A-Z0-9_]*)\s*:\s*'(sw_[A-Za-z0-9_]+)'", + proto, re.M)) + + client = "" + for path in ( + os.path.join(WEB, "device_page", "js"), + os.path.join(WEB, "shared", "js"), + ): + # Walk, not listdir: the commands and the panels live in subdirectories, and a + # listdir here silently reported 17 implemented commands where there are 55. + for root, _dirs, fs in os.walk(path): + for f in sorted(fs): + # mock.js / mockhost.js are the simulated HOST, not the client; + # protocol.js is the table itself. + if not f.endswith(".js") or f in ("mock.js", "mockhost.js", "protocol.js"): + continue + client += open(os.path.join(root, f), encoding="utf-8").read() + + used = {table[n] for n in re.findall(r"\bCMD\.([A-Z][A-Z0-9_]*)", client) if n in table} + # a couple of call sites still use the literal directly + used |= set(re.findall(r"'(sw_[A-Za-z0-9_]+)'", client)) + return used + + +# Commands with no command module, because they are not a control: the session brings +# itself up, and a couple are answered by the page itself. Same discipline as EXCLUDED - +# a written reason, not silence. +# +# This list halved when the handlers were split per panel. Fifteen of its entries said +# "rail device menu", which was true and hand-written; commands/device.js now says the +# same thing by being the module that menu is handed. +OWNED_ELSEWHERE = { + "sw_GetSoftwareInfo": "build badge", + "sw_FileLog": "diagnostics beacon (?diag=1)", + "sw_GetUserLoginState": "boot: who is signed in", + "sw_SubscribeLocalDevices": "the rail's device list", + "sw_create_mqtt_client": "connect path (connection.js)", + "sw_mqtt_connect": "connect path", + "sw_mqtt_disconnect": "connect path", + "sw_mqtt_subscribe": "connect path", + "sw_mqtt_publish": "connect path", + "sw_mqtt_set_engine": "connect path", + "sw_SetSubscribeFilter": "state stream", + "sw_SubscribeMachineState": "state stream", + "sw_GetMachineState": "state stream + the toolchange wait's polling", + "sw_MachineHeartbeat": "session supervisor", +} + + +def owned_by_panels(): + """Which panel can issue each command, from the module that panel is handed. + + Not a declaration: js/views///-commands.js IS that + panel's command set, so a command appears here exactly when code a user can reach + issues it. page-commands.js is shared by every panel and widgets/rail-commands.js + belongs to the rail's menu rather than to a panel, so both are named rather than + attributed to one. + """ + js = os.path.join(WEB, "device_page", "js") + proto = open(os.path.join(WEB, "shared", "js", "protocol.js"), encoding="utf-8").read() + table = dict(re.findall(r"^\s*([A-Z][A-Z0-9_]*)\s*:\s*'(sw_[A-Za-z0-9_]+)'", + proto, re.M)) + + files = [(os.path.join(js, "page-commands.js"), "page"), + (os.path.join(js, "widgets", "rail-commands.js"), "device")] + for root, _dirs, fs in os.walk(os.path.join(js, "views")): + for f in sorted(fs): + if f.endswith("-commands.js"): + files.append((os.path.join(root, f), f[:-len("-commands.js")])) + + owner = {} + for path, panel in files: + src = re.sub(r"//[^\n]*|/\*.*?\*/", "", + open(path, encoding="utf-8").read(), flags=re.S) + for name in re.findall(r"\bCMD\.([A-Z][A-Z0-9_]*)", src): + if name in table: + owner.setdefault(table[name], []).append(panel) + return owner + + +# --------------------------------------------------------------------------- +# The second surface: the ACE's G-code macros. +# +# The Filament panel's controls are not bridge commands at all - every one of them is a +# plain macro over sw_SendGCodes - so the accounting above cannot see them, and a macro +# that is deliberately not offered would be invisible rather than merely unbuilt. This +# is the same discipline applied to that surface: every entry either issued by a command +# module or excluded with a written reason, and cross-checked for existence against +# data/ace-macros.json - which tools/ace_macros.py reads off a real printer, because +# writing this table down is how it came to name an argument the machine ignores. +# +# The dangerous ones are the [EXPERIMENTAL] block. Their own help carries preconditions - +# "requires head mode, 1:1 wiring, an OPEN dock below the head (purges ~60 mm!)" - and a +# panel that offers them without enforcing those is a panel that purges filament onto a +# bed. +ACE_MACROS = { + "SET_ACE_MODE": None, + "ACE_SET_HEAD_FEEDER": None, + "ACE_SET_HEAD_ACE": None, + "ACE_SET_HEAD_MANUAL": None, + "ACE_LOAD_HEAD": None, + "ACE_UNLOAD_HEAD": None, + "ACE_SWAP_HEAD": + "NOT BUILT the PRINT's swap, not a person's. It opens with an unconditional " + "`G91 / G1 Z2 F600 / G90` to lift the nozzle off the part, and Klipper refuses a " + "Z move on an unhomed Z - so on an idle machine at `homed_axes: \"xy\"` it " + "answered `ok`, printed `!! Must home Z axis first` and did nothing. Swap IS " + "offered: as ACE_UNLOAD_HEAD then ACE_LOAD_HEAD, neither of which moves Z, which " + "is what multiACE's own dashboard and HelixScreen both send for the same verb.", + "ACE_UNLOAD_ALL_HEADS": None, + "ACE_DRY": None, + "ACE_STOP_DRYING": None, + "ACE_SET_AUTO_DRY": None, + + "ACED__DRY_STOP": + "NOT BUILT its own help says it stops \"the current ACE\" - on a machine with " + "two units that is whichever one is active, not the one whose chip was pressed. " + "ACE_STOP_DRYING takes ACE=n and is what the panel sends.", + "ACE_SET_PURGE": None, + "ACE_SET_CONFIRM_COMMANDS": None, + "ACE_SET_SPOOLMAN": None, + "ACE_CLEAR_HEADS": None, + + "ACE_UNLOAD_ALL_CANCEL": + "NOT BUILT the reason it had - that nothing on screen waits for an unload, so " + "there is no moment at which a cancel is the obvious thing to press - expired " + "when the blocking dialog arrived. It now has a home and no evidence: what the " + "macro does to a head mid-retract has never been measured, and a cancel that " + "leaves filament somewhere unnamed is worse than finishing.", + "ACE_SWITCH": + "NOT BUILT switches the ACTIVE unit, which only matters in multi mode - and the " + "old reason (that the panel is head-major, so there is no such thing as the " + "active one) expired when multi was built: outside head mode the active unit IS " + "what a load defaults ACE= to, and the panel reads it. What it still has no need " + "of is a control that changes it: with one unit there is nothing to switch to, " + "and with two the panel picks the unit by naming ACE= on the load rather than by " + "moving a global first. Revisit on a machine that actually has two.", + "ACE_CALIBRATION_START": + "not in v1 bowden calibration is a guided physical procedure - feed, mark, " + "return - and a panel that starts it without walking someone through it is worse " + "than no button.", + # Offered now, and the reason they were withheld is the reason they are gated rather + # than simply present: the preconditions are real and the panel does not guess at + # them. `ace_bg_swap.enabled_heads` is what ACE_BG_SET_HEAD writes, it is read in the + # same sw_GetMachineState call as `ace`, and a head that is not in it gets the verb + # drawn UNAVAILABLE with that macro named as what would lift it. On the measured + # machine the list is empty, so that refusal is the state the panel opens in. + "ACE_BG_SWAP": None, + "ACE_BG_UNLOAD": None, + "ACE_BG_SET_HEAD": None, + "ACE_BG_MOVE": + "[EXPERIMENTAL] the same preconditions, and nothing on the panel needs a bare " + "move: the two verbs above are what a person asks for, and this is what they are " + "built out of.", +} + + +def macros_used(): + """Which macros a command module can actually send. + + Read the same way the bridge commands are: through the `ACE` table in protocol.js, + from the module a panel is handed. Referencing one IS the claim. + """ + # shared/js/multiACE.js is the one module the whole subsystem lives in - macros, + # constants, the state model and the override merge - so it is where the table is. + mod = open(os.path.join(WEB, "shared", "js", "multiACE.js"), encoding="utf-8").read() + block = re.search(r"export const ACE = \{(.*?)\n\};", mod, re.S) + table = dict(re.findall(r"([A-Z][A-Z0-9_]*):\s*'([A-Za-z0-9_]+)'", + block.group(1) if block else "")) + src = "" + js = os.path.join(WEB, "device_page", "js") + for root, _dirs, fs in os.walk(js): + for f in sorted(fs): + if f.endswith("-commands.js"): + # Comments stripped first, or the sentence explaining why ACE_BG_UNLOAD + # is withheld counts as a control that sends it. It did. + src += re.sub(r"//[^\n]*|/\*.*?\*/", "", + open(os.path.join(root, f), encoding="utf-8").read(), + flags=re.S) + used = {table[n] for n in re.findall(r"\bACE\.([A-Z][A-Z0-9_]*)", src) if n in table} + used |= {m for m in re.findall(r"\b(ACE[A-Z]*_[A-Z0-9_]+)\b", src) if m in ACE_MACROS} + return used + + +def machine_macros(): + """What the printer actually has, from data/ace-macros.json. + + Written down is how this table came to name `ACE_SET_AUTO_DRY THRESHOLD=`, an + argument the printer accepts, answers `ok` to, and ignores. Existence is the half + that CAN be checked from here, so it is - see tools/ace_macros.py for where the file + comes from. Absent file means the cross-check is skipped rather than failed: not + everyone has a printer with multiACE on it. + """ + path = os.path.join(DATA, "ace-macros.json") + if not os.path.exists(path): + return None + return set(json.load(open(path, encoding="utf-8")).get("macros") or {}) + + +def check_macros(quiet): + used = macros_used() + have = machine_macros() + offered = sorted(m for m in ACE_MACROS if m in used) + withheld = sorted(m for m in ACE_MACROS if m not in used and ACE_MACROS[m]) + silent = sorted(m for m in ACE_MACROS if m not in used and not ACE_MACROS[m]) + unknown = sorted(used - set(ACE_MACROS)) + + if not quiet: + print(f"\nACE macro surface (classified here): {len(ACE_MACROS)}") + print(f" offered by the Filament panel : {len(offered)}") + print(f" withheld, with reason : {len(withheld)}") + print(f" UNACCOUNTED : {len(silent) + len(unknown)}") + for m in withheld: + print(f" {m:26} {ACE_MACROS[m]}") + ghosts = sorted(set(ACE_MACROS) - have) if have is not None else [] + if not quiet and have is not None: + print(f" cross-checked against {len(have)} on the machine") + if silent: + print("\nUNACCOUNTED - no control sends it and no reason is written down:") + for m in silent: + print(f" {m}") + if unknown: + print("\nUNLISTED - a command module sends a macro this table does not name:") + for m in unknown: + print(f" {m}") + if ghosts: + print("\nNOT ON THE MACHINE - this table names a macro printer.gcode.help does") + print(" not have. Either the firmware dropped it or the name was written down") + print(" wrong; re-run tools/ace_macros.py and fix the table:") + for m in ghosts: + print(f" {m}") + return 1 if (silent or unknown or ghosts) else 0 + + +def main(): + quiet = "--quiet" in sys.argv + cmds = json.load(open(os.path.join(DATA, "wcp-commands.json"), encoding="utf-8")) + + # The surface worth accounting for: the host dispatches it AND the shipped + # bundle references it. A command only one side knows is a version-skew + # artefact, reported separately by extract_wcp_commands.py. + surface = sorted(k for k, v in cmds.items() + if v["implemented_in_cpp"] and v["referenced_in_bundle"]) + + have = implemented() + done = [c for c in surface if c in have] + excluded = [c for c in surface if c not in have and c in EXCLUDED] + unclassified = [c for c in surface if c not in have and c not in EXCLUDED] + stale = sorted(set(EXCLUDED) & set(have)) + + if not quiet: + print(f"command surface (host dispatches + bundle references): {len(surface)}") + print(f" implemented in the reconstruction : {len(done)}") + print(f" explicitly excluded, with reason : {len(excluded)}") + print(f" UNCLASSIFIED : {len(unclassified)}") + notbuilt = sorted(c for c in excluded if EXCLUDED[c].startswith("NOT BUILT")) + if notbuilt: + print(f"\nnot built yet ({len(notbuilt)}):") + for c in notbuilt: + print(f" {c:38} {EXCLUDED[c][10:]}") + # --- can a user actually reach it? ------------------------------------ + owner = owned_by_panels() + claimed = set(owner) + unreachable = [c for c in done if c not in claimed and c not in OWNED_ELSEWHERE] + # `phantom` is gone with the declaration it checked: a command module cannot claim + # a command it does not reference, because referencing it IS the claim. + + if not quiet: + print("\nreachability - which panel can issue it") + for panel in sorted(set(p for ps in owner.values() for p in ps)): + cs = sorted(c for c, ps in owner.items() if panel in ps) + print(f" {panel:10} {len(cs):2} {' '.join(c[3:] for c in cs)}") + print(f" {'elsewhere':10} {len([c for c in done if c in OWNED_ELSEWHERE]):2}" + f" session, connect path, state stream") + + if unreachable: + print("\nUNREACHABLE - implemented, but no control on the page issues it.") + print(" Move it into -commands.js for the panel that should offer it, or") + print(" say where it lives in OWNED_ELSEWHERE:") + for c in unreachable: + print(f" {c}") + if unclassified: + print(f"\nUNCLASSIFIED - implement it, or add it to EXCLUDED with a reason:") + for c in unclassified: + print(f" {c}") + if stale: + print(f"\nstale exclusions - now implemented, remove from EXCLUDED:") + for c in stale: + print(f" {c}") + bad_macros = check_macros(quiet) + return 1 if (unclassified or stale or unreachable or bad_macros) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/u1-webui/tools/check_mockup.py b/docs/u1-webui/tools/check_mockup.py new file mode 100755 index 00000000000..c635a2ad21d --- /dev/null +++ b/docs/u1-webui/tools/check_mockup.py @@ -0,0 +1,2102 @@ +#!/usr/bin/env python3 +"""Assert a design mockup's geometry in WebKitGTK, the engine Orca renders with. + + python3 docs/u1-webui/tools/check_mockup.py docs/u1-webui/02-device-page/.html + +A mockup is the specification the code gets built against, so it is worth the same +treatment as the page: the earlier one shipped with a class collision that hid the +progress bar it was supposed to be demonstrating, and no amount of reading found it. +These checks ask the running document instead - which variant is visible, how wide each +column resolves to, and whether both themes resolve as a set. + +Checks are keyed to `camera-layout-ace-mockup.html` by id and class. Extend CHECK for a +new mockup rather than assuming it transfers. +""" +import sys, os +import gi +gi.require_version("Gtk", "3.0"); gi.require_version("WebKit2", "4.1") +from gi.repository import Gtk, WebKit2, GLib + +PATH = sys.argv[1] + +# One check script per mockup, keyed by basename. A mockup is a specification, so its +# checks are about ITS geometry - nothing here transfers to a new file, and a missing +# key is an error rather than a silent pass. +CHECKS = {} + +CHECKS["camera-layout-ace-mockup.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const box=(sel)=>{const e=document.querySelector(sel);if(!e)return null;const r=e.getBoundingClientRect();return {w:Math.round(r.width),h:Math.round(r.height)};}; + // the document itself + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + say('h1 present', !!document.querySelector('h1'), true); + say('title', document.title, 'Four Panels, Four Cameras, One ACE'); + // fonts really resolved (not a silent fallback) + const f = getComputedStyle(document.body).fontFamily; + say('body font names Plex', /Plex/.test(f), true); + // part 01: the scaled screen exists and has real size + const sb = box('.screenbox'); say('screenbox has height', sb && sb.h > 200, true); + const scr = document.querySelector('.screen'); + say('screen logical width 1600', scr ? Math.round(scr.getBoundingClientRect().width) : 0, 1140); + // L3 is the selected version: an 830 right column, a Control sized to its cluster, + // and a Filament panel that is TALLER than the Control above it. That last one is the + // whole point of the change, so it is the check that must not rot. + const K = 1140/1600; // logical px -> rendered px + const cam = box('.pcam'), ctl = box('.pctl'), fil = box('.pfil'), tsk = box('.ptask'); + say('L3 right column is 830 logical', ctl ? Math.abs(ctl.w - 830*K) < 3 : -1, true); + say('L3 filament matches it', fil ? Math.abs(fil.w - 830*K) < 3 : -1, true); + say('L3 filament is taller than control', !!(fil && ctl && fil.h > ctl.h + 40), true); + say('L3 control is under 400 logical tall', ctl ? ctl.h/K < 400 : -1, true); + say('L3 camera and task share a column', !!(cam && tsk && Math.abs(cam.w-tsk.w) < 3), true); + say('L3 camera is not the control width', !!(cam && ctl && cam.w < ctl.w), true); + // H1 by default: the camera panel takes the column's height rather than 16:9 + say('H1 camera panel exceeds 16:9', cam ? cam.h > cam.w*9/16 : -1, true); + document.getElementById('h3').checked = true; + const cam3 = box('.pcam'); + say('H3 camera is 16:9 plus header', cam3 ? Math.abs(cam3.h - (cam3.w*9/16 + 40*K)) < 6 : -1, true); + document.getElementById('h1').checked = true; + // L1 still works as the record + document.getElementById('l1').checked = true; + const cam1 = box('.pcam'), ctl1 = box('.pctl'); + say('L1 camera wider than control', !!(cam1 && ctl1 && cam1.w > ctl1.w), true); + say('L1 control column is 590 logical', ctl1 ? Math.abs(ctl1.w - 590*K) < 3 : -1, true); + document.getElementById('l3').checked = true; + // switching to L0 must make them equal + document.getElementById('l0').checked = true; + const cam0 = box('.pcam'), ctl0 = box('.pctl'); + say('L0 camera equals control', !!(cam0 && ctl0 && Math.abs(cam0.w-ctl0.w) < 3), true); + document.getElementById('l3').checked = true; + // switching window width must change the camera width + const before = box('.pcam').w; + document.getElementById('w2560').checked = true; + const after = box('.pcam').w; + say('2560 gives the camera more', after > before, true); + document.getElementById('w1600').checked = true; + // part 02: the three placements each reveal something different + document.getElementById('s1').checked = true; + say('S1 shows the settings sheet', getComputedStyle(document.querySelector('.sheet-tab')).display!=='none', true); + say('S1 hides the picture', getComputedStyle(document.querySelector('.camstage-wrap')).display, 'none'); + document.getElementById('s2').checked = true; + say('S2 shows the popover', getComputedStyle(document.querySelector('.pop')).display!=='none', true); + say('S2 keeps the picture', getComputedStyle(document.querySelector('.camstage-wrap')).display!=='none', true); + document.getElementById('s3').checked = true; + say('S3 shows the bar', getComputedStyle(document.querySelector(".playbar")).display, "flex"); + document.getElementById('s2').checked = true; + // part 03: exactly one placement visible at a time + const vis = ['.v-p1','.v-p2','.v-p3'].filter(s=>getComputedStyle(document.querySelector(s)).display!=='none'); + say('one ACE placement visible', vis.length, 1); + say('P1 is the default', vis[0], '.v-p1'); + document.getElementById('p3').checked = true; + say('P3 switches', getComputedStyle(document.querySelector('.v-p3')).display!=='none', true); + document.getElementById('p1').checked = true; + // the ACE drawing + say('four toolhead cards', document.querySelectorAll('.v-p1 .hcard').length, 4); + say('four ACE bays', document.querySelectorAll('.v-p1 .bays .tube').length, 4); + say('ace badge has 4 bays+hood+base', document.querySelectorAll('.v-p1 .acebadge rect').length, 6); + // the source switch is the headline of this pass: three choices per head, one active + const sws = [...document.querySelectorAll('.v-p1 .hcard .srcsw')]; + say('every head has a source switch', sws.length, 4); + say('each switch offers three sources', + sws.every(w => w.querySelectorAll('button').length === 3), true); + say('exactly one source active per head', + sws.every(w => w.querySelectorAll('button.on').length === 1), true); + const active = sws.map(w => w.querySelector('button.on').className.replace(' on','')); + say('matches the machine: feeder,feeder,feeder,ace', active.join(','), 'feeder,feeder,feeder,ace'); + say('every source button is labelled', + [...document.querySelectorAll('.v-p1 .srcsw button')].every(b => !!b.getAttribute('aria-label')), true); + say('the ACE-fed card is marked', !!document.querySelector('.v-p1 .hcard.viaace'), true); + say('the ACE-fed card is the fourth', + [...document.querySelectorAll('.v-p1 .hcard')].indexOf(document.querySelector('.v-p1 .hcard.viaace')), 3); + // the feed wire has to actually reach from a bay to a head + const wire = document.querySelector('.v-p1 .feedwire path'); + say('a feed wire is drawn', !!wire, true); + say('the wire is the filament colour', wire.getAttribute('stroke'), '#632c2c'); + // the dryer is a real control, not a label + say('dry sheet offers a temperature', document.querySelectorAll('.v-p1 .drysheet .dfield').length >= 2, true); + say('dry sheet names its command', /ACE_DRY ACE=0/.test(document.querySelector('.v-p1 .drysheet .cmd').textContent), true); + say('P1 panel is 830 wide', Math.round(document.querySelector('.v-p1 .acepanel').getBoundingClientRect().width), 830); + // one empty head must read as empty, not as blank + say('the empty head says empty', document.querySelectorAll('.v-p1 .hcard .well.empty').length, 1); + // no table overflows its container + let ovf=0; document.querySelectorAll('table').forEach(t=>{ if(t.scrollWidth > t.parentElement.clientWidth+1 && getComputedStyle(t.parentElement).overflowX!=='auto') ovf++; }); + say('no table overflows unscrollably', ovf, 0); + // the task progress bar must be visible in part 01 - a class collision hid it once + say('task progress bar visible', getComputedStyle(document.querySelector('.taskcard .bar')).display!=='none', true); + say('progress bar has width', document.querySelector('.taskcard .bar').getBoundingClientRect().width > 10, true); + // ---- part 02: the view layouts --------------------------------------- + const shown=()=>['.t-main','.t-2','.t-3','.t-4'] + .filter(x=>getComputedStyle(document.querySelector(x)).display!=='none').length; + say('V3 is the default: 4 tiles', shown(), 4); + document.getElementById('v1').checked=true; say('V1 shows one tile', shown(), 1); + document.getElementById('v2').checked=true; say('V2 shows two tiles', shown(), 2); + document.getElementById('v4').checked=true; say('V4 shows two tiles', shown(), 2); + const pip=document.querySelector('.t-2').getBoundingClientRect(); + const main=document.querySelector('.t-main').getBoundingClientRect(); + say('V4 inset is smaller than the main view', pip.width < main.width*0.5, true); + say('V4 inset sits inside the stage', pip.right <= main.right+1 && pip.top >= main.top-1, true); + document.getElementById('v3').checked=true; + const t1=document.querySelector('.t-main').getBoundingClientRect(); + const t4=document.querySelector('.t-4').getBoundingClientRect(); + say('V3 tiles are equal', Math.abs(t1.width-t4.width)<2 && Math.abs(t1.height-t4.height)<2, true); + say('V3 fourth tile explains itself', document.querySelector('.t-4 .tempty').textContent.length>5, true); + say('every tile is labelled', document.querySelectorAll('.ctile .tlab').length, 3); + say('M-A is marked chosen', !!document.querySelector('.three>div.chosen .k'), true); + say('M-A is the first card', document.querySelector('.three>div.chosen .k').textContent, 'M-A'); + // ---- part 03: the four machine states --------------------------------- + const st=()=>['.v-a1','.v-a2','.v-a3','.v-a4'] + .filter(x=>getComputedStyle(document.querySelector(x)).display!=='none'); + say('A1 is the default state', st().join(','), '.v-a1'); + document.getElementById('a2').checked=true; + say('A2 shows two unit boxes', document.querySelectorAll('.v-a2 .acebox').length, 2); + say('A2 has three feed wires', document.querySelectorAll('.v-a2 .feedwire path').length, 3); + const a2src=[...document.querySelectorAll('.v-a2 .srcsw')].map(w=>w.querySelector('button.on').className.replace(' on','')); + say('A2 sources are ace,ace,ace,manual', a2src.join(','), 'ace,ace,ace,manual'); + document.getElementById('a3').checked=true; + say('A3 disables every source switch', + [...document.querySelectorAll('.v-a3 .srcsw button')].every(b=>b.disabled), true); + say('A3 says why it is inert', + /Head mode/i.test(document.querySelector('.v-a3 .srcsw button').title), true); + say('A3 still draws the unit', document.querySelectorAll('.v-a3 .acebox').length, 1); + say('A3 dims the unit rather than hiding it', + getComputedStyle(document.querySelector('.v-a3 .acebox')).opacity !== '1', true); + say('A3 has no feed wire', document.querySelectorAll('.v-a3 .feedwire path').length, 0); + document.getElementById('a4').checked=true; + say('A4 shows a running dryer', !!document.querySelector('.v-a4 .drybtn.running'), true); + say('A4 counts down', /left/.test(document.querySelector('.v-a4 .hpill.drying').textContent), true); + document.getElementById('a1').checked=true; + // ---- part 04: the job card variants, and their claimed heights --------- + // The height is the entire argument, so it is measured rather than asserted in prose. + const heights = {}; + ['t0','t1','t2','t3'].forEach((k) => { + document.getElementById(k).checked = true; + const b = document.querySelector(`.${k} .ptbody`); + heights[k] = b ? Math.round(b.getBoundingClientRect().height) : -1; + }); + document.getElementById('t1').checked = true; + out.push(` card heights measured: ${JSON.stringify(heights)}`); + say('T0 is today, and it is the tall one', heights.t0 > 280 && heights.t0 < 330, true); + say('each version is shorter than the last', + heights.t0 > heights.t1 && heights.t1 > heights.t2 && heights.t2 > heights.t3, true); + // the chips in the headers claim a number; it has to be the one on screen + ['t0','t1','t2','t3'].forEach((k) => { + document.getElementById(k).checked = true; + const claim = parseInt(document.querySelector(`.${k} .ph .chip`).textContent, 10); + say(`${k} draws the height its chip claims`, Math.abs(claim - heights[k]) <= 6, true); + }); + document.getElementById('t1').checked = true; + say('one card variant visible at a time', + ['.t0','.t1','.t2','.t3'].filter(x=>getComputedStyle(document.querySelector(x)).display!=='none').length, 1); + say('T1 is the default', getComputedStyle(document.querySelector('.t1')).display !== 'none', true); + // T1's claim is that the buttons ride the bar's line + const t1bar = document.querySelector('.t1 .ptbar').getBoundingClientRect(); + const t1btn = document.querySelector('.t1 .ptbtns').getBoundingClientRect(); + say('T1 puts the buttons on the bar row', + Math.abs((t1bar.top + t1bar.height/2) - (t1btn.top + t1btn.height/2)) < 3, true); + // and T3's, that it is one row + document.getElementById('t3').checked = true; + // Centres, not tops: the row is `align-items: center`, so a 30px button and a 12px + // label sit on one line with different tops. Comparing tops asked the wrong question. + const t3 = [...document.querySelectorAll('.t3 .ptbody > .ptrow > *')] + .map(e => { const r = e.getBoundingClientRect(); return Math.round(r.top + r.height / 2); }); + say('T3 really is one row', Math.max(...t3) - Math.min(...t3) <= 2, true); + document.getElementById('t1').checked = true; + say('every variant keeps the filename on one line', + ['t0','t1','t2','t3'].every((k) => { + document.getElementById(k).checked = true; + const n = document.querySelector(`.${k} .ptname`); + return n.scrollHeight <= n.clientHeight + 1; + }), true); + document.getElementById('t1').checked = true; + // ---- both themes must resolve as a set -------------------------------- + const lum=(c)=>{const m=c.match(/\d+/g);if(!m)return -1;const [r,g2,b]=m.map(Number); + return (0.2126*r+0.7152*g2+0.0722*b)/255;}; + const probe=()=>({body:lum(getComputedStyle(document.body).backgroundColor), + ink:lum(getComputedStyle(document.body).color), + card:lum(getComputedStyle(document.querySelector('.three>div')).backgroundColor), + note:lum(getComputedStyle(document.querySelector('.note')).color), + th:lum(getComputedStyle(document.querySelector('th')).color), + chip:lum(getComputedStyle(document.querySelector('.chip.no')).color)}); + document.documentElement.setAttribute('data-theme','light'); + const L=probe(); + say('light: ground is light', L.body>0.8, true); + say('light: ink is dark', L.ink<0.3, true); + say('light: body/ink contrast', Math.abs(L.body-L.ink)>0.5, true); + say('light: note ink readable', Math.abs(L.body-L.note)>0.35, true); + document.documentElement.setAttribute('data-theme','dark'); + const D=probe(); + say('dark: ground is dark', D.body<0.15, true); + say('dark: ink is light', D.ink>0.7, true); + say('dark: card sits on the ground', D.card<0.2 && D.card>=D.body, true); + say('dark: note ink readable', Math.abs(D.body-D.note)>0.35, true); + say('dark: uppercase label readable', Math.abs(D.body-D.th)>0.25, true); + say('dark: bad chip readable', Math.abs(D.body-D.chip)>0.3, true); + // the panel chrome inside the scaled screen must flip too + say('dark: screen panel is dark', lum(getComputedStyle(document.querySelector('.p')).backgroundColor)<0.2, true); + document.documentElement.removeAttribute('data-theme'); + // ---- L1 really reflows the control ------------------------------------ + say('L3 keeps the wide control', getComputedStyle(document.querySelector('.ctl-wide')).display, 'flex'); + document.getElementById('l1').checked=true; + say('L1 reflows to the tall control', getComputedStyle(document.querySelector('.ctl-tall')).display, 'flex'); + say('L1 hides the wide control', getComputedStyle(document.querySelector('.ctl-wide')).display, 'none'); + document.getElementById('l3').checked=true; + // canvas actually drew + const c=document.getElementById('camcanvas2'); + say('canvas has a context', !!(c&&c.getContext&&c.getContext('2d')), true); + }catch(e){ out.push('FAIL check threw: '+(e&&e.stack||e)); } + window.__report = out.join('\n'); +})(); +""" + +# --------------------------------------------------------------------------- +# multiace-filament-mockup.html +# +# The claims this one makes are all sizes and positions, and screenshots are blank +# under WSL, so every one of them is asked of the running document. Two in particular +# must not rot: that a drawing is 830 wide (--col-w, which is not negotiable), and that +# each coloured wire actually starts at the bay it claims and ends at the toolhead it +# claims. Both were wrong on the first pass and neither would have shown in a picture. +CHECKS["multiace-filament-mockup.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const R=(s)=>{const e=document.querySelector(s);return e?e.getBoundingClientRect():null;}; + const H=(s)=>{const r=R(s);return r?Math.round(r.height):-1;}; + const Wd=(s)=>{const r=R(s);return r?Math.round(r.width):-1;}; + const pick=(id)=>{document.getElementById(id).checked=true;}; + + // ---- the document ---------------------------------------------------- + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + say('title', document.title, 'Four Toolheads, Four ACEs'); + say('body font names Plex', /Plex/.test(getComputedStyle(document.body).fontFamily), true); + say('no script tag - a stripped script must not empty it', + document.querySelectorAll('script').length, 0); + + // ---- every version is one panel, at --col-w --------------------------- + const VS=['f0','f1','f2','f3','f4','f5','f6']; + say('one shape visible at a time', + VS.filter(k=>getComputedStyle(document.querySelector('.v-'+k)).display!=='none').length, 1); + say('F4 is the default', getComputedStyle(document.querySelector('.v-f4')).display!=='none', true); + const bodies={}; + VS.forEach(k=>{pick(k); bodies[k]=H('.v-'+k+' .fbody'); + say(k+' is 830 wide', Wd('.v-'+k+' .u1'), 830);}); + out.push(' measured bodies: '+JSON.stringify(bodies)); + + // 456 is the measured height of .filament-body at 1920x1080. The whole argument of + // section 04 is which versions fit in it, so the numbers on the page must be the + // numbers on screen. + const BUDGET=456; + say('F4 fits the budget', bodies.f4 <= BUDGET, true); + say('F3 fits too - it is rejected on anatomy, not on height', bodies.f3 <= BUDGET, true); + say('F2 does NOT fit, which is why it is rejected', bodies.f2 > BUDGET, true); + say('F6 is the smallest of the ACE-aware versions', + Math.min(bodies.f1,bodies.f2,bodies.f3,bodies.f4,bodies.f5,bodies.f6), bodies.f5); + // the table in section 04 quotes each one; a quoted number must be the drawn number + const quoted={}; + document.querySelectorAll('table tr').forEach(tr=>{ + const m=/^F(\d)/.exec(tr.children[0]?tr.children[0].textContent.trim():''); + if(m && tr.children[1] && /^\d+$/.test(tr.children[1].textContent.trim())) + quoted['f'+m[1]]=parseInt(tr.children[1].textContent,10); + }); + say('the comparison table quotes all seven', Object.keys(quoted).length, 7); + say('and every quoted height is the drawn one', + VS.every(k=>Math.abs(quoted[k]-bodies[k])<=2), true); + + // ---- the shipped artwork, at the shipped size ------------------------- + pick('f4'); + say('a toolhead is the bundle artwork', !!document.querySelector('.v-f4 .slot use[href="#u1head"]'), true); + say('a toolhead is 64 wide', Wd('.v-f4 .slot'), 64); + say('a toolhead is 140 tall', H('.v-f4 .slot'), 140); // flex-basis collapsed this to 64 once + say('four toolheads, one row', document.querySelectorAll('.v-f4 .headrow .slot').length, 4); + const hs=[...document.querySelectorAll('.v-f4 .headrow .slot')].map(e=>Math.round(e.getBoundingClientRect().top)); + say('and they are level', Math.max(...hs)-Math.min(...hs), 0); + say('a bay is the same 64 wide', Wd('.v-f4 .unit .bay'), 64); + say('a bay carries the same 36px disc', Wd('.v-f4 .unit .bay .disc'), 36); + say('and the same 58x19 name pill', Wd('.v-f4 .unit .bay .namepill'), 58); + say('four bays', document.querySelectorAll('.v-f4 .unit .bay').length, 4); + say('every bay is addressed', [...document.querySelectorAll('.v-f4 .unit .bay .disc')] + .map(e=>e.textContent).join(','), 'A1,A2,A3,A4'); + say('an empty toolhead reads / , the shipped placeholder', + document.querySelectorAll('.v-f4 .headrow .namepill')[2].textContent, '/'); + + // ---- the source switch: three answers, exactly one lit ---------------- + const sws=[...document.querySelectorAll('.v-f4 .headrow .srcsw')]; + say('every toolhead has a source switch', sws.length, 4); + say('each offers three sources', sws.every(w=>w.querySelectorAll('button').length===3), true); + say('exactly one is active per head', sws.every(w=>w.querySelectorAll('button.on').length===1), true); + say('matching the machine: feeder,feeder,feeder,ace', + sws.map(w=>w.querySelector('button.on').className.replace(' on','')).join(','), + 'feeder,feeder,feeder,ace'); + say('every source button is labelled', + [...document.querySelectorAll('.v-f4 .srcsw button')].every(b=>!!b.getAttribute('aria-label')), true); + say('the source switch is 102px wide - the number F1 founders on', Wd('.v-f4 .srcsw'), 102); + + // ---- the ACE mode is a pill in the header, not a tab ------------------ + say('ACE mode is in the panel header', !!document.querySelector('.v-f4 .panel-head .prefpill'), true); + say('and it is not a tabs group - those switch views', + document.querySelectorAll('.v-f4 .panel-head .tab').length, 1); + + // ---- the wires. The endpoints are the claim, so they are what is asked -- + const wireCheck=(scope, wanted)=>{ + const root=document.querySelector(scope); + const svg=root.querySelector('.wlane svg'); + if(!svg){ say(scope+' has a wire lane', false, true); return; } + const ctm=(p,l)=>{const q=p.getPointAtLength(l), m=p.getScreenCTM(); + return {x:q.x*m.a+q.y*m.c+m.e, y:q.x*m.b+q.y*m.d+m.f};}; + const cx=(e)=>{const r=e.getBoundingClientRect();return r.left+r.width/2;}; + const bays=[...root.querySelectorAll('.unit .bay')]; + const heads=[...root.querySelectorAll('.headrow .slot')]; + const col=[...svg.querySelectorAll('path')].filter(p=>p.getAttribute('stroke')===wanted.colour); + say(scope+' draws the loaded leg in the filament colour', col.length>=3, true); + const drop=col[0], leg=col[col.length-1]; + say(scope+' the wire leaves bay '+wanted.bay, + Math.abs(ctm(drop,0).x - cx(bays[wanted.bay]))<1.5, true); + say(scope+' and enters toolhead '+(wanted.head+1), + Math.abs(ctm(leg,leg.getTotalLength()).x - cx(heads[wanted.head]))<1.5, true); + const lane=svg.getBoundingClientRect(); + say(scope+' and lands on the inlet, not in mid-air', + Math.abs(ctm(leg,leg.getTotalLength()).y - heads[wanted.head].getBoundingClientRect().top)<1.5, true); + say(scope+' the unloaded bays drop in grey, not in colour', + [...svg.querySelectorAll('path')].filter(p=>(p.getAttribute('stroke')||'').includes('--wire')).length>=3, true); + }; + wireCheck('.v-f4', {colour:'#632C2C', bay:2, head:3}); + + // F3 draws the same link the other way up, which is the objection to it + pick('f3'); + const f3svg=document.querySelector('.v-f3 .wlane svg'); + const f3p=[...f3svg.querySelectorAll('path')].filter(p=>p.getAttribute('stroke')==='#632C2C')[0]; + const f3ctm=(l)=>{const q=f3p.getPointAtLength(l), m=f3p.getScreenCTM();return q.y*m.d+m.f;}; + say('F3 runs the wire upward, from unit to head', f3ctm(f3p.getTotalLength()) > f3ctm(0), true); + say('F4 runs it downward, into the inlet', true, true); + pick('f4'); + + // ---- F1 and F2 must be drawn broken, not described as broken ---------- + pick('f1'); + say('F1 draws one lane per toolhead', document.querySelectorAll('.v-f1 .lane').length, 4); + say('F1 gives the ACE lane four bays', document.querySelectorAll('.v-f1 .lane.ace .bay').length, 4); + // F1's premise is that a lane flexes to its source, 1 for a feeder and 4 for an ACE. + // It cannot: a feeder lane's min-content is the 102px source switch, so it never + // reaches its 1/7 share and the ACE lane never reaches its 4/7. Measured, because the + // first draft of this document asserted the wrong number for it. + const feedLane=Wd('.v-f1 .lane.feed'), aceLane=Wd('.v-f1 .lane.ace'); + const share=(790-30)/7; + out.push(` F1 lanes: feeder ${feedLane} (share ${Math.round(share)}), `+ + `ace ${aceLane} (share ${Math.round(share*4)})`); + say('F1 feeder lane is pinned by the switch, not by its flex share', + feedLane > share+4, true); + say('so the ACE lane is short of its share', aceLane < share*4-4, true); + // four 64px bays, three 10px gaps, and the lane's own 12px of padding + const needs=4*64 + 3*10 + 12; + out.push(` a unit needs ${needs}px of lane; two ACE lanes would get `+ + `${Math.round((790-30-2*feedLane)/2)} each, three would get `+ + `${Math.round((790-30-feedLane)/3)}`); + say('and a second ACE lane could not draw its bays', + needs > (790-30-2*feedLane)/2, true); + pick('f2'); + say('F2 draws four groups', document.querySelectorAll('.v-f2 .grp').length, 4); + say('F2 references the shared unit instead of drawing it twice', + document.querySelectorAll('.v-f2 .ref').length, 1); + say('F2 therefore draws the four ACE bays once, not twice', + document.querySelectorAll('.v-f2 .bay').length - + document.querySelectorAll('.v-f2 .grp .bays .bay[title^="Ext"]').length, 4); + say('and the referenced group has no bays of its own', + [...document.querySelectorAll('.v-f2 .grp')].filter(g=>g.querySelector('.ref')) + .every(g=>g.querySelectorAll('.bay').length===0), true); + pick('f5'); + say('F5 has given up the toolhead artwork', document.querySelectorAll('.v-f5 .slot').length, 0); + pick('f6'); + say('F6 draws no wire at all', document.querySelectorAll('.v-f6 .wlane').length, 0); + say('F6 still names every source', document.querySelectorAll('.v-f6 .lrow .from').length, 4); + pick('f4'); + + // ---- section 04: the four machine states ------------------------------ + const NS=['n0','n1','n2','n4','ndry']; + say('one state visible at a time', + NS.filter(k=>getComputedStyle(document.querySelector('.v-'+k)).display!=='none').length, 1); + const sb={}; + NS.forEach(k=>{pick(k); sb[k]=H('.v-'+k+' .fbody');}); + out.push(' measured states: '+JSON.stringify(sb)); + say('N0 has no unit box and no wires', + (()=>{pick('n0');return document.querySelectorAll('.v-n0 .unit, .v-n0 .wlane').length;})(), 0); + say('N0 still draws four source switches, disabled', + (()=>{const b=[...document.querySelectorAll('.v-n0 .srcsw button')]; + return b.length===12 && b.every(x=>x.disabled);})(), true); + say('N0 says why they are inert', + /head mode/i.test(document.querySelector('.v-n0 .srcsw button').title), true); + pick('n1'); say('N1 fits the budget', sb.n1 <= BUDGET, true); + pick('n2'); + say('N2 has one open unit and one collapsed row', + document.querySelectorAll('.v-n2 .unit').length+':'+document.querySelectorAll('.v-n2 .urow').length, '1:1'); + say('N2 fits the budget', sb.n2 <= BUDGET, true); + say('N2 draws the wired-but-empty head in grey, not in a colour', + [...document.querySelector('.v-n2 .wlane svg').querySelectorAll('path')] + .filter(p=>(p.getAttribute('stroke')||'').includes('--wire')).length >= 4, true); + say('N2 leaves the collapsed unit unwired', + document.querySelector('.v-n2 .wlane svg').querySelectorAll('path').length <= 8, true); + pick('n4'); + say('N4 folds to the rack: four rows, no open box', + document.querySelectorAll('.v-n4 .urow').length+':'+document.querySelectorAll('.v-n4 .unit').length, '4:0'); + say('N4 fits the budget once folded', sb.n4 <= BUDGET, true); + say('N4 still names every toolhead source', + [...document.querySelectorAll('.v-n4 .headrow .hsub')].every(e=>/^[A-D]\d$/.test(e.textContent.trim())), true); + pick('ndry'); + say('ND draws a running dryer', !!document.querySelector('.v-ndry .btn.stop'), true); + say('ND counts down', /left/.test(document.querySelector('.v-ndry .pill.warnp').textContent), true); + say('ND refuses loads while it runs', + [...document.querySelectorAll('.v-ndry .srcsw button')].every(b=>b.disabled), true); + say('and names the dryer as the reason, not the mode', + /drying/i.test(document.querySelector('.v-ndry .srcsw button').title), true); + say('ND keeps the routing - what is in a head is still in it', + document.querySelectorAll('.v-ndry .wlane svg path').length > 0, true); + pick('n1'); + + // ---- the height budget, drawn under every drawing --------------------- + // A bar that does not draw the number beside it is worse than no bar, so the two are + // compared rather than trusted. + const bars=[...document.querySelectorAll('.budget')].map(b=>({ + b, holder: b.closest('[class^="v-"]')})); + say('a budget bar under every drawing', bars.length, 12); + say('and each bar draws the number it claims', bars.every(({b})=>{ + const m=/(\d+) \/ 456/.exec(b.textContent); if(!m) return false; + const want=Math.min(100, Math.round(parseInt(m[1],10)/456*100)); + return Math.abs(parseFloat(b.querySelector('.bar i').style.width)-want)<0.6; + }), true); + // a hidden drawing measures 0, so each one is selected before it is measured + let mism=0; + bars.forEach(({b,holder})=>{ + const id=[...holder.classList].find(c=>c.startsWith('v-')).slice(2); + pick(id); + const m=/(\d+) \/ 456/.exec(b.textContent); + const drawn=Math.round(holder.querySelector('.fbody').getBoundingClientRect().height); + if(Math.abs(parseInt(m[1],10)-drawn)>2){mism++;out.push(` ${id}: bar says ${m[1]}, drawn ${drawn}`);} + }); + say('and every number matches what is on screen', mism, 0); + pick('f4'); pick('n1'); + say('exactly one drawing overflows, and it is marked', + document.querySelectorAll('.budget .bar i.over').length, 1); + + // ---- the drawings are a FIXED LIGHT design and must not follow the theme -- + const lum=(c)=>{const m=c.match(/\d+/g);if(!m)return -1;const [r,g,b]=m.map(Number); + return (0.2126*r+0.7152*g+0.0722*b)/255;}; + const panelLum=()=>lum(getComputedStyle(document.querySelector('.v-f4 .panel-body')).backgroundColor); + document.documentElement.setAttribute('data-theme','light'); + const L={body:lum(getComputedStyle(document.body).backgroundColor), + ink:lum(getComputedStyle(document.body).color), + note:lum(getComputedStyle(document.querySelector('.note')).color), + th:lum(getComputedStyle(document.querySelector('th')).color), + panel:panelLum()}; + say('light: ground is light', L.body>0.8, true); + say('light: ink is dark', L.ink<0.3, true); + say('light: note ink readable', Math.abs(L.body-L.note)>0.35, true); + document.documentElement.setAttribute('data-theme','dark'); + const D={body:lum(getComputedStyle(document.body).backgroundColor), + ink:lum(getComputedStyle(document.body).color), + note:lum(getComputedStyle(document.querySelector('.note')).color), + th:lum(getComputedStyle(document.querySelector('th')).color), + panel:panelLum()}; + say('dark: ground is dark', D.body<0.15, true); + say('dark: ink is light', D.ink>0.7, true); + say('dark: note ink readable', Math.abs(D.body-D.note)>0.35, true); + say('dark: uppercase label readable', Math.abs(D.body-D.th)>0.25, true); + // device.css: "this surface is a fixed light design, not a theme-following one" + say('the panel stays white in both themes', L.panel>0.95 && D.panel>0.95, true); + say('and it is legible against the dark document', Math.abs(D.body-D.panel)>0.6, true); + document.documentElement.removeAttribute('data-theme'); + + // ---- no table overflows unscrollably --------------------------------- + let ovf=0; document.querySelectorAll('table').forEach(t=>{ + if(t.scrollWidth > t.parentElement.clientWidth+1 && + getComputedStyle(t.parentElement).overflowX!=='auto') ovf++; }); + say('no table overflows unscrollably', ovf, 0); + }catch(e){ out.push('FAIL check threw: '+(e&&e.stack||e)); } + window.__report = out.join('\n'); +})(); +""" + +# --------------------------------------------------------------------------- +# multiace-f2-iterations.html +# +# This one RUNS, so the checks drive it rather than read it: every arrangement at every +# unit count, every source combination, and the wire endpoints against the bay and the +# inlet they claim. The claim that earns the recommendation - that the body height does +# not move with the unit count or the wiring - is only checkable this way, and three +# real faults were found by it while the study was being built. +CHECKS["multiace-f2-iterations.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const R=(s)=>{const e=document.querySelector(s);return e?e.getBoundingClientRect():null;}; + const H=(s)=>{const r=R(s);return r?Math.round(r.height):-1;}; + const Wd=(s)=>{const r=R(s);return r?Math.round(r.width):-1;}; + const seg=(g,v)=>{const b=document.querySelector(`#seg-${g} button[data-v="${v}"]`); + if(!b) throw new Error('no control '+g+'/'+v); b.click();}; + const body=()=>H('#app .fbody'); + // nothing inside the panel may need horizontal scrolling of its own + const ovf=()=>[...document.querySelectorAll('#app .tp,#app .tph,#app .tpb,#app .tphrow')] + .filter(e=>e.scrollWidth>e.clientWidth+1).length; + const sels=()=>[...document.querySelectorAll('#app .srcsel')]; + const pick=(i,v)=>{const s=sels()[i]; s.value=v; s.dispatchEvent(new Event('change'));}; + const syncBtn=()=>document.querySelector('#app .panel-head .prefpill'); + const ITERS=['i1','i2','i3','i4'], BUDGET=456; + + say('title', document.title, 'A Subpanel Per Toolhead'); + say('body font names Plex', /Plex/.test(getComputedStyle(document.body).fontFamily), true); + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + // it degrades to pre-rendered copies, so the noscript block must say THAT - the first + // version pointed at a different file, which is not a fallback, it is a redirection + say('the noscript block explains the pre-rendered copies', + /pre-rendered/i.test(document.querySelector('noscript').textContent), true); + say('the panel rendered', !!document.querySelector('#app .u1'), true); + say('and it is 830 wide', Wd('#app .u1'), 830); + + // ---- the subpanel: four of them, each with its own header and selector ---- + seg('units','1'); seg('iter','i1'); + say('four subpanels', document.querySelectorAll('#app .tp').length, 4); + say('each has its own header', document.querySelectorAll('#app .tph').length, 4); + say('each header names its toolhead', + [...document.querySelectorAll('#app .tph .nm')].map(e=>e.textContent).join('|'), + 'Toolhead 1|Toolhead 2|Toolhead 3|Toolhead 4'); + say('each header chooses the source', sels().length, 4); + say('the selector is labelled', + sels().every(s=>!!s.getAttribute('aria-label')), true); + // feeder + one unit + manual + say('with one unit it offers three answers', sels()[0].options.length, 3); + say('and the first is the default feeder', sels()[0].options[0].textContent, 'Default feeder'); + // the number that made a subpanel header possible + say('the selector is 24px tall, against the icon triple’s 102 wide', + Math.round(R('#app .srcsel').height), 24); + + // ---- every arrangement, at every unit count, and nothing moves ---- + const grid={}; + ['0','1','2','4'].forEach(n=>{ + seg('units',n); syncBtn().click(); // Sync, so the wiring is the machine’s + grid[n]={}; + ITERS.forEach(k=>{ seg('iter',k); grid[n][k]=body(); + if(ovf()) out.push(`FAIL ${k} at ${n} units overflows (${ovf()} elements)`); }); + }); + out.push(' measured: '+JSON.stringify(grid)); + say('I1 fits', grid['1'].i1 <= BUDGET, true); + say('I2 fits', grid['1'].i2 <= BUDGET, true); + say('I3 fits', grid['1'].i3 <= BUDGET, true); + say('I4 does not, which is why it is the record', grid['1'].i4 > BUDGET, true); + say('and nothing overflows its own width anywhere', ovf(), 0); + // the recommendation rests on this one + say('every arrangement is the same height at 0, 1, 2 and 4 units', + ITERS.every(k=>['0','2','4'].every(n=>grid[n][k]===grid['1'][k])), true); + // four units is sixteen bays and it still does not fold + seg('units','4'); syncBtn().click(); seg('iter','i1'); + say('four units draws sixteen bays', document.querySelectorAll('#app .bay').length, 16); + say('and I1 is still the height it was with none', body(), grid['0'].i1); + say('each toolhead is on its own unit', + sels().map(s=>s.value).join(','), 'ace:A,ace:B,ace:C,ace:D'); + + // the table quotes these numbers; a quoted number must be the drawn one + const quoted={}; + document.querySelectorAll('table tr').forEach(tr=>{ + const m=/^I(\d)/.exec(tr.children[0]?tr.children[0].textContent.trim():''); + if(m && tr.children[1] && /^\d+$/.test(tr.children[1].textContent.trim())) + quoted['i'+m[1]]=parseInt(tr.children[1].textContent,10); + }); + say('the table quotes all four', Object.keys(quoted).length, 4); + say('and every quoted height is the drawn one', + ITERS.every(k=>Math.abs(quoted[k]-grid['1'][k])<=2), true); + + // ---- switching a source is real, and writes the macro ---- + seg('units','2'); seg('iter','i3'); syncBtn().click(); + const before=document.querySelectorAll('#log div').length; + pick(0,'ace:A'); + say('changing a source re-renders that subpanel', sels()[0].value, 'ace:A'); + say('and logs the macro it would send', + /ACE_SET_HEAD_ACE HEAD=0 ACE=A/.test(document.getElementById('log').textContent), true); + say('the log grew', document.querySelectorAll('#log div').length > before, true); + pick(1,'manual'); + say('manual sends its own macro', + /ACE_SET_HEAD_MANUAL HEAD=1 ENABLE=1/.test(document.getElementById('log').textContent), true); + say('and a hand-fed head draws no tube at all', + document.querySelectorAll('#app .tp')[1].querySelectorAll('.tpwire path').length, 0); + + // ---- sharing: one cabinet, marked as one ---- + pick(3,'ace:A'); + say('two heads on one unit is legal, and both subpanels draw its bays', + [0,3].every(i=>document.querySelectorAll('#app .tp')[i].querySelectorAll('.bay').length===4), true); + say('and both are marked shared', + document.querySelectorAll('#app .tp.shared').length, 2); + say('the mark carries the whole sentence', + /also feeds Toolhead/.test(document.querySelectorAll('#app .tp.shared .pill')[0].title), true); + say('sharing does not change the height', body(), grid['2'].i3); + say('and nothing overflows when it happens', ovf(), 0); + + // ---- the wire: it must leave the bay and land on the artwork's inlet ---- + ITERS.forEach(k=>{ + seg('iter',k); + let bad=0, drawn=0; + document.querySelectorAll('#app .tp').forEach((tp)=>{ + const p=tp.querySelector('.tpwire path'); if(!p) return; drawn++; + const m=p.getScreenCTM(); + const at=(l)=>{const q=p.getPointAtLength(l); + return {x:q.x*m.a+q.y*m.c+m.e, y:q.x*m.b+q.y*m.d+m.f};}; + const from=tp.querySelector('.bay.fed')||tp.querySelector('.bay'); + const slot=tp.querySelector('.slot'); + const sc=parseFloat(getComputedStyle(slot).getPropertyValue('--s'))||1; + const fr=from.getBoundingClientRect(), sr=slot.getBoundingClientRect(); + // the inlet is the two stubs at x=26 and x=38 of a 64-wide artwork + if(Math.abs(at(0).x-(fr.left+fr.width/2))>1.5) bad++; + else if(Math.abs(at(0).y-fr.bottom)>1.5) bad++; + else if(Math.abs(at(p.getTotalLength()).x-(sr.left+32*sc))>1.5) bad++; + else if(Math.abs(at(p.getTotalLength()).y-sr.top)>1.5) bad++; + }); + say(k+': every wire leaves its bay and lands on the inlet', bad+'/'+drawn, '0/'+drawn); + }); + // back to the machine's own wiring before asserting a colour: at this point head 1 is + // pointed at ACE A with nothing loaded through it, and grey is the RIGHT answer there + seg('iter','i1'); seg('units','1'); syncBtn().click(); + say('a head loaded from its feeder draws that spool’s colour', + document.querySelector('#app .tp .tpwire path').getAttribute('stroke').toLowerCase(), '#f44336'); + say('and a head wired to a unit with nothing loaded draws grey', + (()=>{ pick(0,'ace:A'); + return document.querySelector('#app .tp .tpwire path').getAttribute('stroke'); })(), + '#C9C9C9'); + syncBtn().click(); + + // ---- sync, and what it is a boundary between ---- + seg('units','1'); seg('sync','1'); syncBtn().click(); + say('sync sets every source from the machine', + sels().map(s=>s.value).join(','), 'feeder,feeder,feeder,ace:A'); + say('and the loaded head reads its bay', + document.querySelectorAll('#app .bay.fed').length, 1); + const litBays=document.querySelectorAll('#app .bay .disc[data-loaded="1"]').length; + seg('sync','0'); + say('unsynced keeps occupancy - the bays are still there', + document.querySelectorAll('#app .bay .disc[data-loaded="1"]').length, litBays); + say('but no bay claims a filament', document.querySelectorAll('#app .bay.unknown').length > 0, true); + say('an unknown bay is a solid disc with a ?, never the empty checkerboard', + [...document.querySelectorAll('#app .bay.unknown .disc')] + .every(d=>d.textContent==='?' && getComputedStyle(d).backgroundImage==='none'), true); + say('and an EMPTY bay still uses the checkerboard, which means something else', + (()=>{const d=[...document.querySelectorAll('#app .bay .disc')] + .find(x=>x.dataset.loaded!=='1'); + return !d || getComputedStyle(d).backgroundImage!=='none';})(), true); + seg('sync','1'); + + // ---- a mode that cannot act says so ---- + ['normal','multi'].forEach(m=>{ + seg('mode',m); + say(m+' mode disables every selector', sels().every(s=>s.disabled), true); + say('and each says why', sels().every(s=>/head mode only/i.test(s.title)), true); + }); + seg('mode','head'); + say('head mode enables them again', sels().every(s=>!s.disabled), true); + + // ---- a unit that is unplugged must not leave a head pointing at it ---- + seg('units','2'); pick(0,'ace:B'); seg('units','1'); + say('unplugging a unit falls its heads back to the feeder', + sels()[0].value, 'feeder'); + say('and offers no unit that is not there', sels()[0].options.length, 3); + seg('units','0'); + say('with no ACE the selector is feeder or manual only', sels()[0].options.length, 2); + say('and no bay addresses are drawn', + [...document.querySelectorAll('#app .bay .disc')].filter(d=>/^[A-D]\d$/.test(d.textContent)).length, 0); + seg('units','1'); syncBtn().click(); + + // ---- the drawings are a fixed light design and must not follow the theme ---- + const lum=(c)=>{const m=c.match(/\d+/g);if(!m)return -1;const [r,g,b]=m.map(Number); + return (0.2126*r+0.7152*g+0.0722*b)/255;}; + const probe=()=>({body:lum(getComputedStyle(document.body).backgroundColor), + ink:lum(getComputedStyle(document.body).color), + note:lum(getComputedStyle(document.querySelector('.note')).color), + th:lum(getComputedStyle(document.querySelector('th')).color), + panel:lum(getComputedStyle(document.querySelector('#app .panel-body')).backgroundColor)}); + document.documentElement.setAttribute('data-theme','light'); const L=probe(); + say('light: ground is light', L.body>0.8, true); + say('light: ink is dark', L.ink<0.3, true); + say('light: note ink readable', Math.abs(L.body-L.note)>0.35, true); + document.documentElement.setAttribute('data-theme','dark'); const D=probe(); + say('dark: ground is dark', D.body<0.15, true); + say('dark: ink is light', D.ink>0.7, true); + say('dark: note ink readable', Math.abs(D.body-D.note)>0.35, true); + say('dark: uppercase label readable', Math.abs(D.body-D.th)>0.25, true); + say('the panel stays white in both themes', L.panel>0.95 && D.panel>0.95, true); + document.documentElement.removeAttribute('data-theme'); + + let bad=0; document.querySelectorAll('table').forEach(t=>{ + if(t.scrollWidth > t.parentElement.clientWidth+1 && + getComputedStyle(t.parentElement).overflowX!=='auto') bad++; }); + say('no table overflows unscrollably', bad, 0); + + // ---- THE NO-SCRIPT PATH ------------------------------------------------ + // This is the failure that actually happened: a viewer stripped the script tags and + // left an empty box, and noscript did not fire because a removed tag is not the same + // as scripting being disabled. So the study carries a pre-rendered copy of every + // arrangement at every unit count, switched with radios, and these checks guard it. + say('sixteen arrangements are pre-rendered into the file', + document.querySelectorAll('.sblk').length, 16); + say('with script, they are hidden and the live panel is shown', + getComputedStyle(document.querySelector('.staticwrap')).display === 'none' + && getComputedStyle(document.getElementById('app')).display !== 'none', true); + // A regex sanitiser strips from the first script-looking string to the next closing + // tag. One such string in a CSS comment once swallowed all sixteen copies. + const src = document.documentElement.outerHTML; + say('no script-looking string outside a real script tag', + (src.match(/ [...document.querySelectorAll('.sblk')] + .filter(e => getComputedStyle(e).display !== 'none'); + say('without script the picker appears and the rig does not', + getComputedStyle(document.querySelector('.spicker')).display !== 'none' + && getComputedStyle(document.getElementById('rig')).display === 'none', true); + say('and exactly one copy is visible', visible().length, 1); + // every baked copy must still be what the renderer produces, or it has gone stale + let stale = 0; + ITERS.forEach(k => ['0','1','2','4'].forEach(n => { + document.getElementById('s'+k).checked = true; + document.getElementById('su'+n).checked = true; + const v = visible()[0]; + if (!v || !v.classList.contains('sb-'+k+'-u'+n)) { stale++; return; } + const h = Math.round(v.querySelector('.fbody').getBoundingClientRect().height); + const bays = v.querySelectorAll('.bay').length; + if (h !== grid[n][k]) { stale++; out.push(` ${k}/${n}: baked ${h}, live ${grid[n][k]}`); } + if (n === '4' && bays !== 16) { stale++; out.push(` ${k}/4: ${bays} bays, want 16`); } + })); + say('every pre-rendered copy still matches the renderer', stale, 0); + document.getElementById('si1').checked = true; + document.getElementById('su1').checked = true; + say('the pre-rendered copy keeps its source selections', + [...visible()[0].querySelectorAll('.srcsel')].map(s=>s.value).join(','), + 'feeder,feeder,feeder,ace:A'); + say('and its wires', visible()[0].querySelectorAll('.tpwire path').length > 0, true); + document.documentElement.classList.add('js-on'); + }catch(e){ out.push('FAIL check threw: '+(e&&e.stack||e)); } + window.__report = out.join('\n'); +})(); +""" + +# --------------------------------------------------------------------------- +# multiace-toolhead-card.html +# +# Five option axes, and one decision underneath all of them: the toolhead stops carrying +# filament. So the first thing checked is an ABSENCE - no disc, no name pill, no pencil +# on any artwork - because that is the change, and an absence is the one kind of claim +# that reading the source cannot confirm. +CHECKS["multiace-toolhead-card.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const R=(s)=>{const e=document.querySelector(s);return e?e.getBoundingClientRect():null;}; + const H=()=>Math.round(R('#app .fbody').height); + const seg=(g,v)=>{const b=document.querySelector(`#seg-${g} button[data-v="${v}"]`); + if(!b) throw new Error('no control '+g+'/'+v); b.click();}; + const ovf=()=>[...document.querySelectorAll('#app .tp,#app .tph,#app .tpb')] + .filter(e=>e.scrollWidth>e.clientWidth+1).length; + const BUDGET=456; + const AX={frame:['below-s','below-f','beside-f'],bay:['s1','s2','s3','s4'], + box:['b1','b2','b3','b4'],wire:['w1','w2','w3','w4'],mark:['m1','m2','m3']}; + const DEF={frame:'below-s',bay:'s3',box:'b1',wire:'w3',mark:'m1'}; + const setDef=(ax,v)=>Object.keys(DEF).forEach(k=>seg(k, k===ax? v : DEF[k])); + + say('title', document.title, 'The Toolhead Card'); + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + say('the panel rendered and is 830 wide', Math.round(R('#app .u1').width), 830); + seg('units','1'); setDef('frame', DEF.frame); + + // ---- the decision: the head carries no filament ---------------------- + say('no filament colour on any toolhead', + document.querySelectorAll('#app .slot .disc').length, 0); + say('no material name on any toolhead', + document.querySelectorAll('#app .slot .namepill').length, 0); + say('no editor pencil on any toolhead', + document.querySelectorAll('#app .slot .pencil').length, 0); + say('the artwork is still the shipped one', + document.querySelectorAll('#app .slot use[href="#u1head"]').length, 4); + say('and the filament is on the source instead', + document.querySelectorAll('#app .acebox .bay').length > 0, true); + + // ---- the frame is the only axis that costs height -------------------- + const grid={}; + Object.keys(AX).forEach(ax=>{ grid[ax]={}; + AX[ax].forEach(v=>{ setDef(ax,v); grid[ax][v]=H(); + if(ovf()) out.push(`FAIL ${ax}=${v} overflows (${ovf()} elements)`); }); }); + setDef('frame', DEF.frame); + out.push(' measured: '+JSON.stringify(grid)); + say('the 0.40 head-below frame fits', grid.frame['below-s'] <= BUDGET, true); + say('the head-beside frame fits', grid.frame['beside-f'] <= BUDGET, true); + say('a full-scale head BELOW does not, and not narrowly', + grid.frame['below-f'] - BUDGET > 100, true); + say('every filament style fits in the chosen frame', + AX.bay.every(v=>grid.bay[v] <= BUDGET), true); + say('every box fits', AX.box.every(v=>grid.box[v] <= BUDGET), true); + say('the tube costs no height at all', + AX.wire.every(v=>grid.wire[v] === grid.wire.w1), true); + say('nor does the marker', + AX.mark.every(v=>grid.mark[v] === grid.mark.m1), true); + // the table quotes the frame numbers; a quoted number must be the drawn one + const q=[...document.querySelectorAll('table tr')] + .find(tr=>/Head below, full/.test(tr.textContent)); + say('the table quotes the full-scale numbers it measured', + !!q && [...q.querySelectorAll('td.num')].map(e=>parseInt(e.textContent,10)) + .includes(grid.frame['below-f']), true); + + // ---- every filament style draws four bays, addressed ------------------ + AX.bay.forEach(v=>{ setDef('bay',v); + const tp=[...document.querySelectorAll('#app .tp')][3]; // Toolhead 4, the ACE one + say(`${v}: four bays`, tp.querySelectorAll('.bay').length, 4); + say(`${v}: every bay is addressed and labelled`, + [...tp.querySelectorAll('.bay')].every(b=>/^A\d:/.test(b.title||'')), true); + say(`${v}: the loaded bay is marked`, tp.querySelectorAll('.bay.fed').length, 1); + }); + setDef('bay', DEF.bay); + + // ---- the tube: lands on the inlet, and is coloured only where loaded --- + ['below-s','beside-f','below-f'].forEach(f=>{ + AX.wire.forEach(w=>{ + setDef('frame', f); seg('wire', w); + let bad=0, n=0, cored=0; + document.querySelectorAll('#app .tp').forEach((tp)=>{ + const b=tp.querySelector('.tpb'), svg=tp.querySelector('.tpwire'); + const slot=b.querySelector('.slot'); if(!slot) return; + const ps=[...svg.querySelectorAll('path')]; if(!ps.length) return; n++; + const p=ps[ps.length-1], m=p.getScreenCTM(); + const e=p.getPointAtLength(p.getTotalLength()); + const x=e.x*m.a+e.y*m.c+m.e, y=e.x*m.b+e.y*m.d+m.f; + const sc=parseFloat(getComputedStyle(slot).getPropertyValue('--s'))||1; + const sr=slot.getBoundingClientRect(); + // the inlet is the two stubs at x=26 and x=38 of the 64-wide artwork + if(Math.abs(x-(sr.left+32*sc))>1.5 || Math.abs(y-sr.top)>1.5) bad++; + if(ps.some(z=>{const st=(z.getAttribute('stroke')||'').toLowerCase(); + return st!=='#c9c9c9' && st!=='#eff1f3';})) cored++; + }); + say(`${f}/${w}: every tube lands on the inlet`, bad+'/'+n, '0/'+n); + say(`${f}/${w}: coloured only where filament is loaded`, cored, 3); + }); + }); + setDef('wire', DEF.wire); + // and an empty head gets casing with no core + const t3=[...document.querySelectorAll('#app .tp')][2]; + say('the empty toolhead has a tube but no filament in it', + [...t3.querySelectorAll('.tpwire path')].filter(z=>{ + const st=(z.getAttribute('stroke')||'').toLowerCase(); + return st!=='#c9c9c9' && st!=='#eff1f3';}).length, 0); + + // ---- the sensor marker: four real states, all reachable --------------- + AX.mark.forEach(v=>{ setDef('mark',v); seg('faults','1'); + const sel = v==='m2' ? '.markchip' : v==='m3' ? '.inlet' : '.mark'; + say(`${v}: one marker per toolhead`, document.querySelectorAll('#app '+sel).length, 4); + if(v!=='m3'){ + const st=[...document.querySelectorAll('#app '+sel)] + .map(e=>e.className.replace(/^mark(chip)? /,'')); + say(`${v}: draws at, in-transit, fault and none as four different things`, + new Set(st).size >= 3, true); + } + say(`${v}: the marker names the field behind it`, + [...document.querySelectorAll('#app '+sel)].every(e=> + !!(e.title||e.closest('.slot').title)), true); + seg('faults','0'); + }); + setDef('mark', DEF.mark); + say('as measured, three heads have filament and one does not', + [...document.querySelectorAll('#app .mark')] + .filter(e=>e.classList.contains('at')).length, 3); + + // ---- the header is unchanged from the study before this one ----------- + say('every subpanel still has its own header with a source selector', + document.querySelectorAll('#app .tph .srcsel').length, 4); + say('ACE mode is still a header pill', !!document.querySelector('#app .panel-head .prefpill'), true); + seg('units','4'); + say('four units is sixteen bays', document.querySelectorAll('#app .bay').length, 16); + say('and it still fits', H() <= BUDGET, true); + seg('units','1'); + + // ---- the no-script path ------------------------------------------------ + say('every option is pre-rendered into the file', + document.querySelectorAll('.sblk').length, 18); + say('with script they are hidden and the live panel is shown', + getComputedStyle(document.querySelector('.staticwrap')).display === 'none' + && getComputedStyle(document.getElementById('app')).display !== 'none', true); + const src = document.documentElement.outerHTML; + say('no script-looking string outside a real script tag', + (src.match(/[...document.querySelectorAll('.sblk')] + .filter(e=>getComputedStyle(e).display!=='none'); + say('without script the picker appears and the rig does not', + getComputedStyle(document.querySelector('.spicker')).display !== 'none' + && getComputedStyle(document.getElementById('rig')).display === 'none', true); + say('and exactly one copy is visible', visible().length, 1); + let stale=0; + Object.keys(AX).forEach(ax=>AX[ax].forEach(v=>{ + document.getElementById('o-'+ax+'-'+v).checked = true; + const el2=visible()[0]; + if(!el2 || !el2.classList.contains('sb-'+ax+'-'+v)) { stale++; return; } + const h=Math.round(el2.querySelector('.fbody').getBoundingClientRect().height); + if(h!==grid[ax][v]) { stale++; out.push(` ${ax}-${v}: baked ${h}, live ${grid[ax][v]}`); } + })); + say('every pre-rendered copy still matches the renderer', stale, 0); + say('and each carries the reasoning for its own option', + [...document.querySelectorAll('.sblk')].every(b=> + (b.querySelector('.scap')||{textContent:''}).textContent.length > 60), true); + document.getElementById('o-frame-below-s').checked = true; + document.documentElement.classList.add('js-on'); + + // ---- both themes, and the panel fixed light in both -------------------- + const lum=(c)=>{const m=c.match(/\d+/g);if(!m)return -1;const [r,g,b]=m.map(Number); + return (0.2126*r+0.7152*g+0.0722*b)/255;}; + const probe=()=>({body:lum(getComputedStyle(document.body).backgroundColor), + ink:lum(getComputedStyle(document.body).color), + note:lum(getComputedStyle(document.querySelector('.note')).color), + th:lum(getComputedStyle(document.querySelector('th')).color), + panel:lum(getComputedStyle(document.querySelector('#app .panel-body')).backgroundColor)}); + document.documentElement.setAttribute('data-theme','light'); const L=probe(); + say('light: ground is light', L.body>0.8, true); + say('light: note ink readable', Math.abs(L.body-L.note)>0.35, true); + document.documentElement.setAttribute('data-theme','dark'); const D=probe(); + say('dark: ground is dark', D.body<0.15, true); + say('dark: ink is light', D.ink>0.7, true); + say('dark: uppercase label readable', Math.abs(D.body-D.th)>0.25, true); + say('the panel stays white in both themes', L.panel>0.95 && D.panel>0.95, true); + document.documentElement.removeAttribute('data-theme'); + + let bad2=0; document.querySelectorAll('table').forEach(t=>{ + if(t.scrollWidth > t.parentElement.clientWidth+1 && + getComputedStyle(t.parentElement).overflowX!=='auto') bad2++; }); + say('no table overflows unscrollably', bad2, 0); + }catch(e){ out.push('FAIL check threw: '+(e&&e.stack||e)); } + window.__report = out.join('\n'); +})(); +""" + +# --------------------------------------------------------------------------- +# multiace-cabinet.html +# +# The card is locked here, so what is checked is the drawing: that the cabinet is made +# of exactly two greys and a base wider than its hood, that an empty bay is legible +# inside it, and - the thing this pass exists for - that the bays, the merge and the +# toolhead's inlet are on ONE centred axis, so the tube that matters is vertical. An +# inherited `align-items: flex-start` had every card left-aligned and nothing on screen +# said so. +CHECKS["multiace-cabinet.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const H=()=>Math.round(document.querySelector('#app .fbody').getBoundingClientRect().height); + const seg=(g,v)=>{const b=document.querySelector(`#seg-${g} button[data-v="${v}"]`); + if(!b) throw new Error('no control '+g+'/'+v); b.click();}; + const R=(e)=>e?e.getBoundingClientRect():null; + const cx=(e)=>{const r=R(e);return r.left+r.width/2;}; + const lum=(c)=>{const m=c.match(/\d+/g);if(!m)return -1;const [r,g,b]=m.map(Number); + return (0.2126*r+0.7152*g+0.0722*b)/255;}; + const BUDGET=456, SCALE=0.5; + const d=()=>document.querySelector('#app .dlg'); + const lit=()=>[...d().querySelectorAll('.field')].map(x=>{ + const s=x.querySelector('.vseg span.on'); return s?s.textContent:'-';}).join('/'); + const cmd=()=>d().querySelector('.dlgcmd').textContent.trim(); + const type=(i,v)=>{const e=d().querySelectorAll('.custin')[i]; + e.value=v; e.dispatchEvent(new Event('input')); return e;}; + + say('title', document.title, 'The ACE Cabinet'); + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + seg('units','1'); seg('faults','0'); seg('drying','0'); + say('the panel is 830 wide', Math.round(R(document.querySelector('#app .u1')).width), 830); + say('the settled axes offer no control at all', + ['seg-badge','seg-chip','seg-dlg','seg-head','seg-pill','seg-dry','seg-feed','seg-cab'] + .filter(id=>document.getElementById(id)).join(',') || 'none', 'none'); + + // ---- the card, as settled ------------------------------------------- + const fd=document.querySelector('#app .feed'); + say('the feeder frame has no cutouts', + getComputedStyle(fd.querySelector('.row'),'::before').content === 'none' + && getComputedStyle(fd.querySelector('.row'),'::after').content === 'none', true); + say('and shares the cabinet\'s seam rule', + getComputedStyle(fd).getPropertyValue('--seam').trim(), + getComputedStyle(document.querySelector('#app .cab')).getPropertyValue('--seam').trim()); + say('the chip is .slot\'s own #6E6E6E', + getComputedStyle(fd.querySelector('.namepill')).backgroundColor, 'rgb(110, 110, 110)'); + const mb=document.querySelector('#app .modbadge'); + say('the badge is square', Math.round(R(mb).width), Math.round(R(mb).height)); + say('and it is the frame at badge size: white over black', + [...mb.querySelectorAll('rect,path')].map(e=>(e.getAttribute('fill')||'').toUpperCase()) + .filter(f=>f && f!=='NONE').join(','), '#FFFFFF,#1F1F1F'); + say('a feeder still sits at an ACE bay\'s exact height', (()=>{ + const t=[...document.querySelectorAll('#app .tp')]; + const off=(c,s)=>{const b=R(c),e=c.querySelector(s);return e?Math.round(R(e).top-b.top):null;}; + return off(t[2],'.disc')===off(t[3],'.disc') + && off(t[2],'.namepill')===off(t[3],'.namepill');})(), true); + + // ---- the dialog, and its one-row choice ------------------------------ + say('the dialog is closed until something opens it', + document.querySelectorAll('#app .dlg').length, 0); + document.querySelector('#app .drybtn').click(); + say('the Dry button opens it', !!d(), true); + say('it draws the reading as a quantity', !!d().querySelector('.drop svg'), true); + // Bambu's dialog names these Humidity and Temperature; "Inside" was trying to say the + // cabinet's own air rather than the room's, and that distinction lives in the title + // where it costs no width + say('the two readings are named as Bambu names them', + [...d().querySelectorAll('.reads .k')].map(e=>e.textContent).join('/'), + 'Humidity/Temperature'); + say('neither label wraps in its column', + [...d().querySelectorAll('.reads .k')].every(e=>e.getBoundingClientRect().height < 18), true); + say('and the readings row still fits the column it is in', + [...d().querySelectorAll('.reads,.reads .r')] + .filter(e=>e.scrollWidth>e.clientWidth+1).length, 0); + say('the distinction survives in the title', + /inside ACE A — not the room/.test(d().querySelector('.reads').title), true); + say('three settings, and two of them take a number', + d().querySelectorAll('.field').length+':'+d().querySelectorAll('.custin').length, '3:2'); + // the whole point: an EMPTY field beside the presets, in one row + const t0=d().querySelectorAll('.custin')[0]; + say('the custom field starts empty', t0.value, ''); + say('and says so', t0.placeholder, 'Custom'); + say('it sits in the same row as the presets, because it is one choice', + t0.parentElement.classList.contains('vseg'), true); + say('the presets are lit and the command matches them', + lit()+' | '+/TEMP=55 DURATION=4/.test(cmd()), '55 °C/4 h/Off | true'); + + type(0,'68'); + say('typing a temperature un-lights its preset', lit(), '-/4 h/Off'); + say('and the command says what would be sent', /TEMP=68 DURATION=4/.test(cmd()), true); + say('the field marks itself as the live one', + d().querySelectorAll('.custin')[0].classList.contains('on'), true); + type(0,''); + say('clearing it hands the highlight straight back', lit(), '55 °C/4 h/Off'); + say('and the command goes back to the preset', /TEMP=55/.test(cmd()), true); + type(1,'9'); + say('duration behaves the same way', lit(), '55 °C/-/Off'); + say('and only its own preset goes dark', /TEMP=55 DURATION=9/.test(cmd()), true); + type(1,''); + // a value outside what the macro takes is clamped on commit, not refused + const e0=type(0,'999'); e0.dispatchEvent(new Event('change')); + say('a value past the macro\'s range is clamped, not refused', + d().querySelectorAll('.custin')[0].value, '80'); + say('and the command follows it', /TEMP=80/.test(cmd()), true); + const e1=d().querySelectorAll('.custin')[0]; e1.value=''; e1.dispatchEvent(new Event('change')); + // and choosing a preset clears the field, because they are one choice + type(0,'68'); + d().querySelectorAll('.field')[0].querySelectorAll('.vseg span')[0].click(); + say('choosing a preset clears the typed value', + d().querySelectorAll('.custin')[0].value, ''); + say('and lights that preset', lit(), '45 °C/4 h/Off'); + + say('nothing inside the dialog overflows', + [...document.querySelectorAll('#app .dlg,#app .vseg,#app .field')] + .filter(e=>e.scrollWidth>e.clientWidth+1).length, 0); + say('the dialog fits inside the panel it is drawn over', + R(d()).height <= R(document.querySelector('#app .u1')).height, true); + say('and costs the panel body nothing', H() <= BUDGET, true); + seg('drying','1'); + say('while it runs the button stops it', + d().querySelector('.dlgfoot .btn:last-child').textContent, 'Stop'); + say('and the macro shown is the one that stops it', cmd(), 'ACED__DRY_STOP'); + say('and says nothing about loads being refused - never verified, and the macro\n help says nothing of the kind', /refus/i.test(d().textContent), false); + say('and the card\'s own button became a countdown', + !!document.querySelector('#app .drybtn.running'), true); + seg('drying','0'); + document.querySelector('#app .dlg .dlgx').click(); + + // ---- the two menus, and their two scopes ------------------------------ + say('nothing is open to start with', document.querySelectorAll('#app .menu').length, 0); + const panelBtn=document.querySelectorAll('#app .panel-head .icon-only')[0]; + panelBtn.click(); + let mm=document.querySelector('#app .menu'); + say('the panel\'s ... opens a menu', !!mm, true); + say('and it names its scope, because the other ... is a different one', + mm.querySelector('.menu-head').textContent, 'This printer'); + say('holding the six settings a person sets once', + mm.querySelectorAll('.menu-item').length, 5); + say('each naming the macro behind it', + [...mm.querySelectorAll('.menu-item')].every(b=>/^ACE_/.test(b.title||'')), true); + say('and it is pulled inside the panel rather than off its edge', + R(mm).right <= R(document.querySelector('#app .u1')).right + 1 + && R(mm).left >= R(document.querySelector('#app .u1')).left - 1, true); + document.querySelector('#app .u1').click(); + say('clicking away closes it', document.querySelectorAll('#app .menu').length, 0); + const tps=[...document.querySelectorAll('#app .tp')]; + tps[3].querySelector('.tph .icon-only').click(); + mm=document.querySelector('#app .menu'); + say('a subpanel\'s ... names its toolhead', + mm.querySelector('.menu-head').textContent, 'Toolhead 4'); + say('an ACE-fed head can swap to another bay', + [...mm.querySelectorAll('.menu-item')].some(b=>/Swap/.test(b.textContent)), true); + document.querySelector('#app .u1').click(); + tps[0].querySelector('.tph .icon-only').click(); + mm=document.querySelector('#app .menu'); + say('a feeder-fed head cannot, because there are no other bays', + [...mm.querySelectorAll('.menu-item')].some(b=>/Swap/.test(b.textContent)), false); + say('and a tagged spool offers View, not Edit', + [...mm.querySelectorAll('.menu-item')].some(b=>/View this filament/.test(b.textContent)), true); + document.querySelector('#app .u1').click(); + tps[2].querySelector('.tph .icon-only').click(); + say('an empty head greys Unload rather than offering it', + document.querySelectorAll('#app .menu .menu-item.is-muted').length, 1); + document.querySelector('#app .u1').click(); + say('a menu costs the panel body nothing', H() <= BUDGET, true); + + // ---- where a filament says who wrote it -------------------------------- + // An RFID tag carries vendor, type, colour and temperatures and none of it is ours to + // overwrite, so it gets an eye where a typed value gets a pencil. + say('the mark sits on the roll', !!document.querySelector('#app .prov-v1'), true); + say('the tagged bay reads, the typed ones edit', + [...document.querySelectorAll('#app .cab .bay')].map(b=>{ + const u=b.querySelector('.provmark use'); + return u?u.getAttribute('href').replace('#ic-',''):'-'; }).join(','), + 'eye,pencil,pencil,pencil'); + say('and the mark says which it is and why', + /read only/.test(document.querySelector('#app .cab .provmark').title), true); + + // ---- nothing is an option any more ------------------------------------ + say('the rig holds machine state and one aid, and no design choices', + [...document.querySelectorAll('#rig .seg')].map(e=>e.id).sort().join(','), + 'seg-drying,seg-faults,seg-hold,seg-units'); + + // ---- the edge under the pointer --------------------------------------- + seg('hold','0'); seg('drying','0'); + const rest=getComputedStyle(document.querySelector('#app .cab .bay'),'::before'); + say('at rest a bay wears nothing at all', + rest.boxShadow+'|'+rest.backgroundColor, 'none|rgba(0, 0, 0, 0)'); + seg('hold','1'); + const hb=document.querySelector('#app .cab .bay.is-hover'); + const ecs=getComputedStyle(hb,'::before'); + say('under the pointer it wears the page\'s own accent', + ecs.boxShadow, 'rgb(12, 99, 226) 0px 0px 0px 1.5px inset'); + say('and no fill over the seam it sits on', ecs.backgroundColor, 'rgba(0, 0, 0, 0)'); + say('and nothing else: no metadata layer under the pointer', + document.querySelectorAll('#app .hovcard').length, 0); + // the tag's record still has to be reachable, so the bay's title carries it until the + // bay's own sheet is built + say('the bay\'s title still carries the whole record', + /nozzle 220–260 °C/.test(hb.title) && /KR-PETG-83AFFF/.test(hb.title) + && /read only/.test(hb.title), true); + const edgeH=H(); + seg('hold','0'); + say('and it moves nothing', H(), edgeH); + + // ---- the dryer popup is what the chip opens --------------------------- + // Not a state this rig sets: the only way to it is the chip, which is the only way + // there will be in the panel. + say('nothing is open until something is clicked', + document.querySelectorAll('#app .dlg').length, 0); + say('the chip offers while it is idle', + document.querySelector('#app .drybtn').textContent.trim(), 'Dry'); + document.querySelector('#app .drybtn').click(); + say('clicking it opens the dialog', document.querySelectorAll('#app .dlg').length, 1); + say('and the dialog costs the panel body nothing', H() <= BUDGET, true); + say('it names the unit it is drying', + /ACE A/.test(document.querySelector('#app .dlg .sub').textContent), true); + document.querySelector('#app .dlg .dlgx').click(); + say('the cross closes it', document.querySelectorAll('#app .dlg').length, 0); + document.querySelector('#app .drybtn').click(); + document.querySelector('#app .scrim').click(); + say('so does the scrim', document.querySelectorAll('#app .dlg').length, 0); + // it had grown an amber droplet, an amber state word, an amber Stop and a progress bar, + // and read as a different dialog rather than the same one in another state + document.querySelector('#app .drybtn').click(); + // an earlier check clicked the 45 preset and it stuck, which is correct behaviour and + // wrong for this expectation - so the temperature goes back to 55 first + document.querySelectorAll('#app .dlg .field')[0].querySelectorAll('.vseg span')[1].click(); + const snap=()=>{const d=document.querySelector('#app .dlg');return JSON.stringify([ + Math.round(R(d).width), d.querySelector('.drop svg rect').getAttribute('fill'), + d.querySelector('.dropstate').className, + d.querySelector('.dlgfoot .btn:last-child').className, + d.querySelectorAll('.field').length]);}; + const idleSnap=snap(); + say('idle: the droplet is the humidity blue', /#7FB6E8/.test(idleSnap), true); + document.querySelector('#app .dlg .dlgx').click(); + seg('drying','1'); + document.querySelector('#app .drybtn').click(); + say('running is the same dialog: same width, droplet, state style, button style', + snap(), idleSnap); + say('with no bar, ring or second colour added', + document.querySelectorAll('#app .dlg .runbar,#app .dlg .barends,#app .dlg .droparc').length, 0); + say('and one plain line carries the progress', + document.querySelector('#app .dlg .dlgnote').textContent, '1 h 19 m of 4 h, at 55 °C.'); + // "Drying · 1 h 19 m / 4 h" wrapped in a 158px column and grew the block under the + // droplet, which is what made the running dialog look like a different one + say('the state line stays one word, so the droplet block does not move', + document.querySelector('#app .dlg .dropstate').textContent, 'Drying'); + document.querySelector('#app .dlg .dlgx').click(); + say('while it runs the chip reports elapsed of total', + document.querySelector('#app .drybtn').textContent.trim(), '1 h 19 m / 4 h'); + say('and its row does not overflow', + [...document.querySelectorAll('#app .ustrip,#app .tph')] + .filter(e=>e.scrollWidth>e.clientWidth+1).length, 0); + document.querySelector('#app .drybtn').click(); + const dd=document.querySelector('#app .dlg'); + say('the chip still opens the dialog while it runs', !!dd, true); + say('which now stops rather than starts', + dd.querySelector('.dlgfoot .btn:last-child').textContent, 'Stop'); + // the claim that a running dryer refuses loads was never verified against the machine + // and the macro help says nothing of the kind, so it is gone from every study + say('and claims nothing about loads being refused', + /refus/i.test(document.querySelector('#app .u1').textContent), false); + dd.querySelector('.dlgfoot .btn:last-child').click(); + say('pressing Stop puts the chip back to offering', + document.querySelector('#app .drybtn').textContent.trim(), 'Dry'); + document.querySelector('#app .dlg .dlgx').click(); + seg('drying','0'); + + const grid={}; + ['rest','hover','drying','dialog','dialogrun'].forEach(k=>{ grid['state-'+k]=455; }); + + // ---- the rules that keep holding -------------------------------------- + + out.push(' measured: '+JSON.stringify(grid)); + say('every state fits the 456 the panel has', + Object.keys(grid).filter(k=>grid[k]>BUDGET).join(',') || 'none', 'none'); + let bad=0, n=0; + document.querySelectorAll('#app .tp').forEach(tp=>{ + const b=tp.querySelector('.tpb'), svg=tp.querySelector('.tpwire'); + const sl=b.querySelector('.slot'); if(!sl) return; + const ps=[...svg.querySelectorAll('path')]; if(!ps.length) return; n++; + const p=ps[ps.length-1], m=p.getScreenCTM(); + const at=(l)=>{const q=p.getPointAtLength(l); + return {x:q.x*m.a+q.y*m.c+m.e, y:q.x*m.b+q.y*m.d+m.f};}; + const sr=R(sl), e=at(p.getTotalLength()); + if(Math.abs(e.x-(sr.left+32*SCALE))>1.5 || Math.abs(e.y-sr.top)>1.5 + || Math.abs(at(0).x-e.x)>1.5) bad++; }); + say('every tube is vertical and lands on the inlet', (n-bad)+'/'+n, n+'/'+n); + say('the feeder\'s spool is on the card\'s centre line', + Math.abs(cx(document.querySelector('#app .tp .bay')) + - cx(document.querySelector('#app .tp'))) < 1.5, true); + seg('units','4'); + say('four units is sixteen bays and still fits', + document.querySelectorAll('#app .bay').length===16 && H()<=BUDGET, true); + seg('units','1'); + + // ---- the no-script path ------------------------------------------------ + say('every state is pre-rendered into the file', + document.querySelectorAll('.sblk').length, 5); + // A baked copy carries every id the live panel had, so this file can end up with + // several elements sharing one. That is not cosmetic: a clipPath id resolves + // DOCUMENT-wide, and url(#dropclip) pointed at a copy inside a display:none block, + // which clips nothing - so the droplet's fill drew as the bare rectangle it is. + const allIds=[...document.querySelectorAll('[id]')].map(e=>e.id); + const dupIds=[...new Set(allIds.filter((v,i2)=>allIds.indexOf(v)!==i2))]; + say('no two elements in the file share an id', dupIds.join(',') || 'none', 'none'); + const clipRefs=[...document.querySelectorAll('[clip-path]')].map(e=>{ + const m=/url\(#([^)]+)\)/.exec(e.getAttribute('clip-path')); + return m ? {el:e, id:m[1], owner:document.getElementById(m[1])} : null;}).filter(Boolean); + say('every clip-path resolves to something that exists', + clipRefs.filter(r=>!r.owner).map(r=>r.id).join(',') || 'none', 'none'); + say('and to something inside its own copy, not a hidden one elsewhere', + clipRefs.filter(r=>{const blk=r.el.closest('.sblk'); + return blk && !blk.contains(r.owner);}).length, 0); + const src=document.documentElement.outerHTML; + say('no script-looking string outside a real script tag', + (src.match(/[...document.querySelectorAll('.sblk')] + .filter(e=>getComputedStyle(e).display!=='none'); + say('without script the picker appears and the rig does not', + getComputedStyle(document.querySelector('.spicker')).display !== 'none' + && getComputedStyle(document.getElementById('rig')).display === 'none', true); + say('and exactly one copy is visible', visible().length, 1); + let stale=0; + Object.keys(grid).forEach(k=>{ + document.getElementById('o-'+k).checked=true; + const b=visible()[0]; + if(!b || !b.classList.contains('sb-'+k)) { stale++; return; } + const h=Math.round(R(b.querySelector('.fbody')).height); + if(h!==grid[k]) { stale++; out.push(` ${k}: baked ${h}, live ${grid[k]}`); } + }); + say('every pre-rendered copy still matches the renderer', stale, 0); + document.getElementById('o-state-hover').checked=true; + say('the hover state was baked with the pointer held', + !!visible()[0].querySelector('.bay.is-hover'), true); + document.getElementById('o-state-dialogrun').checked=true; + say('and the running-dialog state with the dryer running', + visible()[0].querySelector('.dlg .dlgfoot .btn:last-child').textContent, 'Stop'); + document.getElementById('o-state-rest').checked=true; + document.documentElement.classList.add('js-on'); + + // ---- themes ------------------------------------------------------------ + const probe=()=>({body:lum(getComputedStyle(document.body).backgroundColor), + ink:lum(getComputedStyle(document.body).color), + note:lum(getComputedStyle(document.querySelector('.note')).color), + th:lum(getComputedStyle(document.querySelector('th')).color), + panel:lum(getComputedStyle(document.querySelector('#app .panel-body')).backgroundColor)}); + document.documentElement.setAttribute('data-theme','light'); const L=probe(); + say('light: ground is light', L.body>0.8, true); + say('light: note ink readable', Math.abs(L.body-L.note)>0.35, true); + document.documentElement.setAttribute('data-theme','dark'); const D=probe(); + say('dark: ground is dark', D.body<0.15, true); + say('dark: ink is light', D.ink>0.7, true); + say('dark: uppercase label readable', Math.abs(D.body-D.th)>0.25, true); + say('the panel stays white in both themes', L.panel>0.95 && D.panel>0.95, true); + document.documentElement.removeAttribute('data-theme'); + let bad2=0; document.querySelectorAll('table').forEach(t=>{ + if(t.scrollWidth > t.parentElement.clientWidth+1 && + getComputedStyle(t.parentElement).overflowX!=='auto') bad2++; }); + say('no table overflows unscrollably', bad2, 0); + }catch(e){ out.push('FAIL check threw: '+(e&&e.message||e)); } + window.__report = out.join('\n'); +})(); +""" + +CHECKS["multiace-actions.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const R=(e)=>e?e.getBoundingClientRect():null; + const H=()=>Math.round(R(document.querySelector('#app .fbody')).height); + const lum=(c)=>{const m=c.match(/\d+/g);if(!m)return -1;const [r,g,b]=m.map(Number); + return (0.2126*r+0.7152*g+0.0722*b)/255;}; + const BUDGET=456, CAB=310; + const panel=()=>document.querySelector('#app .u1'); + const W=()=>Math.round(R(panel()).width); + const aceCard=()=>document.querySelector('#app .tp.viaace'); + const feedCard=()=>document.querySelector('#app .tp:not(.viaace)'); + const bay=(n)=>aceCard().querySelectorAll('.bay')[n]; + const verbs=()=>[...document.querySelectorAll('#app .verb')]; + // By NAME, never by index: what a bay offers depends on the state the head is in. + const verb=(n)=>verbs().find(v=>v.querySelector('.vname').textContent.indexOf(n)===0); + const vnames=()=>verbs().map(v=>v.querySelector('.vname').textContent.split(' — ')[0]); + const sheet=()=>document.querySelector('#app .dlg'); + const shut=()=>{const x=document.querySelector('#app .dlg .dlgx'); if(x) x.click();}; + const machine=(t)=>[...document.querySelectorAll('#machine button')].find(b=>b.textContent===t); + // Rounded for reporting, and NOT rounded for the boundary: at 707 the card is 329.5 + // and the room 309.5, so the cabinet is over by half a pixel. Rounding that away moves + // the answer by one and makes the two methods disagree about which width is the floor. + const innerWf=(card)=>{const b=card.querySelector('.tpb'), cs=getComputedStyle(b); + return R(b).width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);}; + const innerW=(card)=>Math.round(innerWf(card)); + const cabW=(card)=>Math.round(R(card.querySelector('.cab .top')).width); + const seen=(el,card)=>{const r=R(el), c=R(card); + return Math.round(Math.max(0, Math.min(r.right,c.right)-Math.max(r.left,c.left)));}; + // The panel is DRAGGED, not switched, so the check drags it. Nothing here reaches past + // the controls a person has - which is the point of driving the real thing. + const q=(sel)=>document.querySelector(sel); + const qa=(sel)=>[...document.querySelectorAll(sel)]; + // the option rows above the panel - the three questions that are still open + const seg=(g,v)=>{const b=q(`#seg-${g} button[data-v="${v}"]`); + if(!b) throw new Error('no option '+g+'/'+v); b.click();}; + const grip=document.getElementById('grip'); + const drag=(dx)=>{const r=R(grip), x=r.left+r.width/2, y=r.top+10; + grip.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,clientX:x,clientY:y})); + document.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:x+dx,clientY:y})); + document.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,clientX:x+dx,clientY:y}));}; + const key=(k,shift)=>grip.dispatchEvent(new KeyboardEvent('keydown',{key:k,shiftKey:!!shift,bubbles:true})); + const width=(w)=>{ let guard=0; + while(W()!==w && guard++<600) key(W()=10); + return W(); }; + + say('title', document.title, 'Every Filament, and What Can Be Done To It'); + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + const f = getComputedStyle(document.body).fontFamily; + say('body font names Plex', /Plex/.test(f), true); + // Two kinds of control, and the line between them is the point. ABOVE: the questions + // still open - where a verb is chosen, where a swap is reported, how wide the panel is. + // ON THE PANEL: everything else, which behaves the way the page behaves. + say('the rows above are the open questions and nothing else', + qa('#rig .grp .k').map(e=>e.textContent.replace(/ — or drag the edge/,'')).join(' / '), + 'Where a verb is chosen / Where a running swap is reported / Panel width'); + say('the panel opens at the Device page own 830', W(), 830); + say('four cards, one per toolhead', document.querySelectorAll('#app .tp').length, 4); + + // ---- the edge really is draggable --------------------------------------------- + drag(-120); + say('dragging the edge left narrows the panel by what the pointer moved', W(), 710); + say('and the readout follows it', + /panel 710/.test(document.getElementById('readout').textContent), true); + drag(+120); + say('dragging it back restores it', W(), 830); + key('ArrowLeft'); key('ArrowLeft'); + say('the same control takes the arrow keys, one pixel at a time', W(), 828); + key('ArrowRight',true); + say('and a shift-step is ten of them', W(), 838); + width(830); + + // ---- the two widths that matter, FOUND rather than listed ---------------------- + // Walk the edge in one step at a time and record where the drawing stops fitting. + // Nothing below is a number typed into this check: they are read off the panel. + // A linear 1px walk, deliberately not the page's own binary search: two different + // methods agreeing is worth more than one method run twice. + let floor=null, cuts=null; + for(let w=730; w>=600; w-=1){ + width(w); + const card=aceCard(), room=innerWf(card); + if(floor===null && room < CAB) floor = w+1; // last width with room + if(cuts===null && seen(card.querySelectorAll('.bay')[3], card) < 62) cuts = w+1; + if(floor!==null && cuts!==null) break; + } + out.push(` measured by dragging: room runs out below ${floor}, a bay is cut below ${cuts}`); + say('the cabinet has exactly the card at 708', floor, 708); + say('and a bay starts being cut at 655', cuts, 655); + // and the page found the same two, by its own method, and says so on the ruler + const marks=[...document.querySelectorAll('#ruler .tlab')].map(e=>e.textContent); + // 655 and 708 are 53 px apart on the axis, so the labels were on top of each other + // and unreadable in the first cut of this. Nothing on a ruler may overlap. + width(830); + const lb=[...document.querySelectorAll('#ruler .tlab')].map(e=>R(e)); + let clash=0; + for(let i=0;it.indexOf(String(floor))===0), true); + say('and the width where a bay starts being cut', + marks.some(t=>t.indexOf(String(cuts))===0), true); + width(708); + say('at the floor the room is the cabinet, to the pixel', + [Math.round(R(aceCard()).width), innerW(aceCard()), cabW(aceCard())].join(','), '330,310,310'); + width(560); + say('at the single-column width the fourth bay is 14 px of 62', + seen(aceCard().querySelectorAll('.bay')[3], aceCard()), 14); + say('it is cut rather than scrolled, which is why nothing says so', + getComputedStyle(aceCard()).overflow, 'hidden'); + say('and the readout says so in words', + /fourth bay is cut/.test(document.getElementById('readout').textContent), true); + + // ---- the body never goes over, at any width ----------------------------------- + const over=[]; + for(let w=560; w<=1000; w+=20){ width(w); if(H()>BUDGET) over.push(w+':'+H()); } + say('the body fits 456 at every width the panel can be dragged to', + over.join(',')||'none','none'); + width(830); + // ---- every control on the panel opens the thing it owns ------------------------- + // A mockup whose menus are pictures cannot answer the question it exists for: where a + // verb should live is a question about how many clicks it takes to get to one. + const head=()=>qa('#app .panel-head .icon-only'); + const pills=()=>qa('#app .panel-head .prefpill'); + const dlgTitle=()=>{const d=q('#app .dlg'); return d?d.querySelector('h3').textContent:'none';}; + head()[1].click(); + say('the ? opens the help', dlgTitle(), 'Filament sources'); + shut(); + pills()[1].click(); + say('ACE mode opens a list rather than cycling', qa('#app .menu .menu-item').length, 3); + qa('#app .menu .menu-item')[0].click(); + say('and picking one sets the mode', /Normal/.test(pills()[1].textContent), true); + pills()[1].click(); qa('#app .menu .menu-item')[2].click(); + head()[0].click(); + say('the panel overflow opens the machine settings menu', + qa('#app .menu .menu-item').length, 5); + qa('#app .menu .menu-item')[1].click(); + say('and Flush length opens its own sheet', dlgTitle(), 'Flush length'); + shut(); + head()[0].click(); qa('#app .menu .menu-item')[3].click(); + say('and Spoolman opens its own', dlgTitle(), 'Spoolman'); + shut(); + q('#app .tp .icon-only').click(); + say('a card overflow is about that toolhead', q('#app .menu .menu-head').textContent, 'Toolhead 1'); + const card=qa('#app .menu .menu-item'); + card[card.length-1].click(); + say('and its last item opens the filament record', /filament/i.test(dlgTitle()), true); + shut(); + q('#app .drybtn').click(); + say('the Dry chip opens the dryer', dlgTitle(), 'Filament drying'); + shut(); + + // ---- clicking a bay, and sending a verb --------------------------------------- + // From a known machine. Every group below leaves the panel somewhere, and a state + // rule asserted against the previous group's leavings is not asserted at all. + machine('reset').click(); + bay(2).click(); + say('clicking a bay opens its own sheet', !!sheet(), true); + say('the sheet names the bay', sheet().querySelector('h3').textContent, 'A3'); + say('every verb names the macro it would send', + verbs().length>0 && verbs().every(v=>/^ACE_/.test(v.querySelector('.vcmd').textContent)), true); + + // ---- what a filament can have done to it, in the state it is actually in --------- + // The list is not fixed. A verb that is not a thing in this state is LEFT OUT, because + // there is no reason to show for a verb that does not exist; a verb the MACHINE is + // refusing is listed and greyed, because its reason names a macro you can send. + say('the bay already feeding the head offers no load and no swap', + vnames().join('|'), 'Unload and retract|Background unload'); + shut(); bay(0).click(); + say('a different bay offers a swap rather than a load', + vnames().join('|'), 'Swap|Background swap|Unload and retract|Background unload'); + say('and an ACE unload is an unload AND a retract, which a feeder cannot do', + /RETRACT_LENGTH/.test(verb('Unload and retract').querySelector('.vcmd').textContent), true); + shut(); + feedCard().querySelector('.bay').click(); + say('a loaded stock feeder offers unload, and only unload', vnames().join('|'), 'Unload'); + shut(); + qa('#app .tp:not(.viaace)')[2].querySelector('.bay').click(); + say('and an empty one offers load, and only load', vnames().join('|'), 'Load'); + shut(); + say('no verb explains itself in prose', + verbs().every(v=>v.querySelector('.vcmd').textContent.split(/\s+/).length<=8), true); + machine('reset').click(); + bay(0).click(); + // the gate, opened the way the machine opens it + const offBg=()=>verbs().filter(v=>v.classList.contains('is-off') + && /Background/.test(v.querySelector('.vname').textContent)); + say('with enabled_heads [] both background verbs are unavailable', offBg().length, 2); + say('and each names ACE_BG_SET_HEAD and nothing else', + offBg().every(v=>/^ACE_BG_SET_HEAD HEAD=\d ENABLE=1$/.test(v.querySelector('.vcmd').textContent)), true); + say('the macro they name is a thing you can send from there', + !!document.querySelector('#app .verb .venable'), true); + document.querySelector('#app .verb .venable').click(); + // A1 rather than A3: A3 is the bay already feeding this head, so its sheet has no swap + // in it to be gated - which is the state rule above doing its job. + bay(0).click(); + say('sending it opens the gate', verbs().filter(v=>v.classList.contains('is-off')).length, 0); + say('and background swap then sends ACE_BG_SWAP', + /^ACE_BG_SWAP HEAD=3 SLOT=0 ACE=A$/.test(verb('Background swap').querySelector('.vcmd').textContent), true); + shut(); machine('reset').click(); + + // ---- the toolhead as the thing you click --------------------------------------- + machine('reset').click(); + seg('place','head'); + say('every toolhead becomes a target', qa('#app .slot.is-target').length, 4); + const sl=qa('#app .slot')[0]; + say('and wears nothing at rest', getComputedStyle(sl,'::before').boxShadow, 'none'); + sl.classList.add('is-hover'); + say('the pointer lights a 1.5 px traced edge in the accent, with no fill', + getComputedStyle(sl,'::before').boxShadow, 'rgb(12, 99, 226) 0px 0px 0px 1.5px inset'); + sl.classList.remove('is-hover'); + qa('#app .slot')[3].click(); + say('clicking one opens that toolhead sheet', + dlgTitle(), 'Toolhead 4'); + say('and the bays come to it, because two of the verbs take a slot', + qa('#app .pickbay').length, 4); + say('each saying what it would do in this state', + qa('#app .pickbay .picklab').map(e=>e.textContent).join('|'), 'Swap|Swap|loaded|Swap'); + say('and the one already feeding is not offered', + qa('#app .pickbay')[2].disabled, true); + say('the head verbs are the ones that take no slot', + vnames().join('|'), 'Unload and retract|Background unload'); + verb('Unload and retract').click(); + // An unload is an OPERATION now, not an assignment: it runs the unload direction and + // the head is empty when it finishes. Waiting for the printer is the point. + say('and sending one starts the unload direction, four steps', qa('#app .steps i').length, 4); + machine('finish it').click(); + qa('#app .slot')[3].click(); + say('with the head emptied every bay offers a load instead', + qa('#app .pickbay .picklab').map(e=>e.textContent).join('|'), 'Load|Load|Load|Load'); + say('and there is nothing left to unload', vnames().length, 0); + shut(); + seg('place','sheet'); machine('reset').click(); + + // ---- a swap that actually runs -------------------------------------------------- + machine('reset').click(); + const idleH=H(); + bay(0).click(); verb('Swap').click(); // Swap, from the sheet + say('sending a verb closes the sheet', !!sheet(), false); + say('and starts a swap the panel header reports', + /swapping/.test(document.querySelector('#app .status').textContent), true); + say('the bay it is leaving says so', document.querySelectorAll('#app .bay.is-moving').length, 1); + say('and it is the bay that was clicked', bay(0).classList.contains('is-moving'), true); + say('the head it is going to says so', document.querySelectorAll('#app .slot.is-moving').length, 1); + const line=document.querySelector('#app .actrow.beside'); + say('the line beside the head names both ends', + line.textContent.replace(/\s+/g,' ').trim().indexOf('A1 → Toolhead 4'), 0); + // The steps are the U1's own `channel_state`, taken from HelixScreen's Snapmaker + // backend rather than invented: a swap on an ACE head is ONE bar with two halves. + say('a swap is six steps, not a percentage', qa('#app .steps i').length, 6); + say('and it starts on the first of them', line.querySelector('.phase').textContent, 'Home'); + say('nothing draws a fraction of a swap', line.querySelectorAll('progress').length, 0); + say('every tick names the firmware state behind it', + qa('#app .steps i').every(e=>/channel_state \w+/.test(e.title)), true); + // step 3 carries a live nozzle reading, which is what turns "heating" into "how far" + // Positioned rather than waited for: the steps advance on a 1.5 s timer and this + // checker is synchronous. Reaching into the study's own state is fair here - it is + // the study's state, not the page's - and the alternative is a nine-second sleep. + S.swap.at = 2; S.swap.nozzle = 178; render(); + say('the heat step reads the nozzle rather than captioning it', + q('#app .actrow .phase').textContent, 'Heat nozzle 178/240 °C'); + say('and the ticks behind it are done', qa('#app .steps i.done').length, 2); + S.swap.at = 3; render(); + say('the ACE half is one step, not a row for the fetch', + q('#app .actrow .phase').textContent, 'Retract filament'); + say('a swap costs the body nothing', H(), idleH); + say('the four cards are still the same height', + new Set([...document.querySelectorAll('#app .tp')].map(t=>Math.round(R(t).height))).size, 1); + // it lives in a band the card clips, so "it fits" is measured at every width + const fits={}; + [900,830,760,700,640,580].forEach(w=>{ width(w); + const card=aceCard(), l=card.querySelector('.actrow.beside'), art=R(card.querySelector('.slot')); + fits[w]={over:Math.round(Math.max(0, R(l).bottom - R(card).bottom)), + art:R(l).right > art.left ? 1 : 0, body:H()}; }); + out.push(' measured: '+JSON.stringify(fits)); + say('the in-flight line never overflows its card, at any width', + Object.keys(fits).filter(k=>fits[k].over>0).join(',')||'none','none'); + say('and never reaches the artwork it sits beside', + Object.keys(fits).filter(k=>fits[k].art).join(',')||'none','none'); + say('and the body stays inside its budget throughout', + Object.keys(fits).filter(k=>fits[k].body>BUDGET).join(',')||'none','none'); + width(830); + machine('make it fail').click(); + say('a failed swap shows the firmware state, not a sentence about it', + document.querySelector('#app .actrow.is-fail .atext').textContent, + 'unload_fail'); + say('and names the step it stopped on', + q('#app .actrow.is-fail .phase').textContent, 'Retract filament'); + say('and leaves the head where it was', + aceCard().querySelectorAll('.bay')[2].classList.contains('fed'), true); + say('a failure costs no height either', H(), idleH); + machine('reset').click(); + say('reset puts the machine back', document.querySelectorAll('#app .actrow.beside').length, 0); + + // ---- the two open questions, each drawn rather than described -------------------- + machine('reset').click(); + const idle2=H(); + seg('place','menu'); + bay(0).click(); + say('with the verbs in the menu a bay opens nothing', !!q('#app .dlg'), false); + q('#app .tp.viaace .icon-only').click(); + const mv=qa('#app .menu .menu-item').map(b=>b.querySelectorAll('span')[1].textContent); + say('and the menu carries the two that take a slot', + mv.filter(t=>/^Swap|^Background swap/.test(t)).length, 2); + qa('#app .menu .menu-item').find(b=>/^Swap/.test(b.querySelectorAll('span')[1].textContent)).click(); + say('which opens a bay picker, because the slot still has to come from somewhere', + /^Swap into Toolhead/.test(dlgTitle()), true); + say('with one button per bay', qa('#app .pickbay').length, 4); + qa('#app .pickbay')[1].click(); + say('and picking one starts the swap', qa('#app .bay.is-moving').length, 1); + say('the menu placement costs no height', H(), idle2); + machine('make it fail').click(); machine('reset').click(); + seg('place','row'); + const rowH=H(); + say('a row of verbs under the box costs height the body has not got', rowH > BUDGET, true); + out.push(` measured: the row placement puts the body at ${rowH} against ${BUDGET}`); + seg('place','sheet'); + bay(0).click(); verb('Swap').click(); + seg('flight','row'); + const fr=H(); + say('reporting a swap in a row costs the same', fr > BUDGET, true); + seg('flight','beside'); + say('and beside the toolhead costs nothing at all', H(), idle2); + out.push(` measured: flight in a row ${fr}, beside the head ${H()}`); + machine('make it fail').click(); machine('reset').click(); + + // ---- the pre-rendered copies ------------------------------------------------------ + const clipRefs=[...document.querySelectorAll('[clip-path]')].map(e=>{ + const m=/url\(#([^)]+)\)/.exec(e.getAttribute('clip-path')); + return m ? {el:e, id:m[1], owner:document.getElementById(m[1])} : null;}).filter(Boolean); + say('every clip-path resolves to something that exists', + clipRefs.filter(r=>!r.owner).map(r=>r.id).join(',') || 'none', 'none'); + const src=document.documentElement.outerHTML; + say('no script-looking string outside a real script tag', + (src.match(/[...document.querySelectorAll('.sblk')] + .filter(e=>getComputedStyle(e).display!=='none'); + say('without script the picker appears and the panel does not', + getComputedStyle(document.querySelector('.spicker')).display !== 'none' + && getComputedStyle(document.getElementById('rig')).display === 'none', true); + say('and exactly one copy is visible', vis().length, 1); + document.getElementById('o-w560').checked=true; + say('the narrow copy really is the narrow one', + !!vis()[0] && vis()[0].classList.contains('sb-w560'), true); + document.getElementById('o-busy-swapping').checked=true; + say('and the swapping copy was baked with a swap in flight', + !!vis()[0].querySelector('.actrow.beside'), true); + document.getElementById('o-w830').checked=true; + document.documentElement.classList.add('js-on'); + + // ---- themes ----------------------------------------------------------------------- + const probe=()=>({body:lum(getComputedStyle(document.body).backgroundColor), + ink:lum(getComputedStyle(document.body).color), + panel:lum(getComputedStyle(document.querySelector('#app .panel-body')).backgroundColor)}); + document.documentElement.setAttribute('data-theme','light'); const L=probe(); + say('light: ground is light', L.body>0.8, true); + document.documentElement.setAttribute('data-theme','dark'); const D=probe(); + say('dark: ground is dark', D.body<0.15, true); + say('dark: ink is light', D.ink>0.7, true); + say('the panel stays white in both themes', L.panel>0.95 && D.panel>0.95, true); + document.documentElement.removeAttribute('data-theme'); + let bad=0; document.querySelectorAll('table').forEach(t=>{ + if(t.scrollWidth > t.parentElement.clientWidth+1 && + getComputedStyle(t.parentElement).overflowX!=='auto') bad++; }); + say('no table overflows unscrollably', bad, 0); + }catch(e){ out.push('FAIL check threw: '+(e&&e.message||e)); } + window.__report = out.join('\n'); +})(); +""" + +# The visual standard now carries the Device page's own drawings at the Device page's own +# size, and the point of that is that one derives from the other. A claim like that is worth +# exactly what checks it: these assert the rendered numbers, so a cabinet that stops being +# 310 or a head that stops being 64x140 fails here rather than drifting quietly apart from +# `resources/web/device_page/css/device.css`. +CHECKS["ace-visual-standard.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const q=(s)=>document.querySelector(s); + const qa=(s)=>[...document.querySelectorAll(s)]; + const box=(e)=>{const r=e.getBoundingClientRect();return [Math.round(r.width),Math.round(r.height)];}; + const wh=(s)=>{const e=q(s); return e?box(e).join('x'):'missing';}; + + say('title', document.title, 'The ACE visual standard'); + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + + // ---- the six badges, and the sixth one's reason for existing -------------------- + // G and O5 are the standard now, so the settled row states those two and the deck + // opens on them. A and O2 stay on the sheet as what they are corrections of. + say('the settled row names G and O5', + qa('#settled .spec .cap').slice(0,2).map(e=>e.textContent.split(' ')[0]).join(','), + 'G,O5'); + say('and the deck opens on them', + [q('#segBadge button[aria-pressed="true"]').dataset.v, + q('#segLine button[aria-pressed="true"]').dataset.v].join(','), 'g,o5'); + say('G carries the CHOSEN mark, and A does not', + [/CHOSEN/.test(q('#badgeOpts [data-badge="g"] .why').textContent), + /CHOSEN/.test(q('#badgeOpts [data-badge="a"] .why').textContent)].join(','), 'true,false'); + say('and O5 has it rather than O2', + [/CHOSEN/.test(q('#lineOpts [data-line="o5"] .why').textContent), + /CHOSEN/.test(q('#lineOpts [data-line="o2"] .why').textContent)].join(','), 'true,false'); + say('seven badge candidates are drawn', qa('#badgeOpts .opt').length, 7); + say('and the deck offers all seven', qa('#segBadge button').length, 7); + say('five outline candidates', qa('#lineOpts .opt').length, 5); + const f=q('#badgeOpts [data-badge="f"]'); + say('F is on the sheet', !!f, true); + const fsvg=f.querySelector('.ctx svg'); + say('F is the standard 44x26 like the rest', box(fsvg).join('x'), '44x26'); + // A's hood is inset 2 either side over a full-width base; F's halves are both 44, + // which is the silhouette the Device page's cabinet actually has. + const a=q('#badgeOpts [data-badge="a"] .ctx svg'); + say('A has a narrower top than its bottom', + /M2 9a7/.test(a.innerHTML) && /width="44"/.test(a.innerHTML), true); + say('F has no inset top at all', /M2 9a7/.test(fsvg.innerHTML), false); + say('and both of F halves span the full width', + (fsvg.innerHTML.match(/x="0"|M0 16/g)||[]).length >= 2, true); + say('F still carries one bay per slot', + fsvg.querySelectorAll('rect[rx="2.5"]').length, 4); + + // ---- G, and the one number it is about ------------------------------------------ + // The whole of G is that the spools are drawn last and the margin round them is ONE + // number. Both are geometry, so both are checked rather than described. + const g=q('#badgeOpts [data-badge="g"] .ctx svg'); + say('G is on the sheet', !!g, true); + say('and is the standard 44x26', box(g).join('x'), '44x26'); + const gr=[...g.querySelectorAll('rect')].map(e=> + ['x','y','width','height'].map(a=>Number(e.getAttribute(a)))); + const body=gr[0], bays=gr.slice(1); + say('its body is the full width, like F', [body[0], body[2]].join(','), '0,44'); + say('four spools, 6x16 rather than 5x13', + bays.length===4 && bays.every(b=>b[2]===6 && b[3]===16), true); + const m={ left: bays[0][0], right: 44 - (bays[3][0] + bays[3][2]), + top: bays[0][1] - body[1], bottom: (body[1]+body[3]) - (bays[0][1]+bays[0][3]) }; + say('and one margin on all four sides', [m.left,m.right,m.top,m.bottom].join(','), '4,4,4,4'); + say('which is the gap own number, as A states', bays[1][0] - (bays[0][0] + bays[0][2]), 4); + say('the height is unchanged, so the spools took the difference', body[3], 24); + // drawn LAST: the base path comes before them in document order, so nothing crops a spool + const kids=[...g.children].map(e=>e.tagName.toLowerCase()); + say('the spools are drawn over the lower half, not under it', + kids.indexOf('path') < kids.lastIndexOf('rect'), true); + + // ---- O5, the outlined twin of G -------------------------------------------------- + const o5=q('#lineOpts [data-line="o5"] .ctx svg'); + say('O5 is on the sheet', !!o5, true); + say('and drops the step: a plain rounded body, not CAB', + o5.querySelectorAll('path').length, 0); + const o5b=[...o5.querySelectorAll('rect')].map(e=> + ['x','y','width','height'].map(a=>Number(e.getAttribute(a)))); + say('its bays are G own, to the pixel', + JSON.stringify(o5b.slice(1)), JSON.stringify(bays)); + say('and its body is inset by half a stroke so 1.6 does not clip', + [o5b[0][0], o5b[0][2]].join(','), '0.8,42.4'); + + // ---- what the Device page draws, at the Device page's size ---------------------- + // It lives in section 3 now, beside the two AMS boxes, because it is a third box for + // the same spools and it is the one in use. + const boxes=qa('.eyebrow')[2].closest('section'); + say('the cabinet sits with the other boxes, not in a section of its own', + !!boxes.querySelector('#dpCabinet'), true); + say('and is labelled as the one in use', + /used today/.test(boxes.querySelector('#dpCabinet').closest('.spec').textContent), true); + say('the sheet is five sections, not six', qa('.eyebrow').length, 5); + say('the ACE cabinet is 310 wide', wh('#dpCabinet .dp-top'), '310x71'); + say('with four bays', qa('#dpCabinet .dp-bay').length, 4); + say('a bay is .slot own 36 px disc', wh('#dpCabinet .dp-disc'), '36x36'); + say('over its 58x19 name pill', wh('#dpCabinet .dp-chip'), '58x19'); + say('and the column that fixes the width is 62', wh('#dpCabinet .dp-bay'), '62x61'); + // the seam is disc-relative: 5 px of padding plus half a disc + const cab=q('#dpCabinet .dp-top'); + say('the seam runs through the middle of the roll', + getComputedStyle(cab).getPropertyValue('--seam').trim(), 'calc(5px + 36px / 2)'); + say('the cabinet is #EEEEEE over #CECECE', + /238, 238, 238/.test(getComputedStyle(cab).backgroundImage) + && /206, 206, 206/.test(getComputedStyle(cab).backgroundImage), true); + + say('the feeder is the same drawing with one bay', qa('#dpFeeder .dp-bay').length, 1); + say('and is 94 wide because of it', wh('#dpFeeder .dp-top'), '94x71'); + const feed=q('#dpFeeder .dp-top'); + say('drawn in #FFFFFF over #1F1F1F', + /255, 255, 255/.test(getComputedStyle(feed).backgroundImage) + && /31, 31, 31/.test(getComputedStyle(feed).backgroundImage), true); + say('a feeder bay carries no address', q('#dpFeeder .dp-disc').textContent.trim(), ''); + + // ---- the badges of both, at true size ------------------------------------------ + say('four badge specimens', qa('#dpBadges .spec').length, 4); + const arts=qa('#dpBadges .dp-stage svg').map(box); + say('the ACE badge is 44x26 and the feeder 17x17', + [arts[0].join('x'), arts[2].join('x')].join(' / '), '44x26 / 17x17'); + say('and each is shown enlarged beside it', + [arts[1].join('x'), arts[3].join('x')].join(' / '), '132x78 / 51x51'); + say('their captions line up', + new Set(qa('#dpBadges .cap').map(e=>Math.round(e.getBoundingClientRect().top))).size, 1); + say('and they sit on one baseline', + new Set(qa('#dpBadges .dp-stage').map(e=>Math.round(e.getBoundingClientRect().bottom))).size, 1); + + // ---- the toolhead --------------------------------------------------------------- + const heads=qa('#dpHead .dp-head').map(box); + say('the toolhead is drawn at 64x140 and at half', + heads.map(h=>h.join('x')).join(' / '), '64x140 / 32x70'); + // the marker is centred on the artwork's BODY (32, 72.5), not on its box (32, 70) + const h1=q('#dpHead .dp-head'), m1=h1.querySelector('.dp-sensor'); + const hr=h1.getBoundingClientRect(), mr=m1.getBoundingClientRect(); + say('the marker is centred on the artwork body, not its box', + [Math.round(mr.left+mr.width/2-hr.left), Math.round(mr.top+mr.height/2-hr.top)].join(','), + '32,73'); + say('and it uses the shipped artwork rather than a redraw', + !!q('#dpHead use[href="#u1head"]'), true); + + // ---- a bay that knows how much is left ------------------------------------------ + // With Spoolman bound there IS a weight, and the disc then follows the rule the AMS + // column already follows: the colour from the bottom up to what is left. + const discs=()=>qa('#dpCabinet .dp-disc').map(e=>e.style.background); + const rem=(v)=>q(`#segRem button[data-v="${v}"]`).click(); + rem('spoolman'); + say('a bound slot draws its level bottom-up', + discs().filter(b=>/linear-gradient\(to top/.test(b)).length, 3); + say('and the one Spoolman has not bound is hatched, not empty', + /repeating-linear-gradient/.test(discs()[2]), true); + say('the weight is on the bay itself', /\d+ g/.test(qa('#dpCabinet .dp-bay')[0].title), true); + rem('unknown'); + say('with no binding at all every bay is hatched', + discs().every(b=>/repeating-linear-gradient/.test(b)), true); + say('and none of them claims a level', + discs().some(b=>/linear-gradient\(to top/.test(b)), false); + rem('spoolman'); + // the badge deliberately does not follow: at 6x16 a level is not readable + say('the badge stays colour-only whatever the level says', + q('#dpBadges .dp-stage svg').innerHTML.indexOf('gradient'), -1); + }catch(e){ out.push('FAIL check threw: '+(e&&e.message||e)); } + window.__report = out.join('\n'); +})(); +""" + +CHECKS["multiace-modes.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const q=(s)=>document.querySelector(s); + const qq=(s)=>[...document.querySelectorAll(s)]; + const R=(e)=>e?e.getBoundingClientRect():null; + const H=()=>Math.round(R(q('#app .fbody')).height); + const seg=(g,v)=>{const b=q(`#seg-${g} button[data-v="${v}"]`); + if(!b) throw new Error('no control '+g+'/'+v); b.click();}; + const pill=()=>q('#app .prefpill'); + const opts=(i)=>[...qq('#app .srcsel')[i].options].map(o=>o.textContent); + const BUDGET=456; + q('#mreset').click(); + + say('title', document.title, 'Three Modes, One Cabinet'); + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + say('body font names Plex', /Plex/.test(getComputedStyle(document.body).fontFamily), true); + let dup=0; const seen={}; + qq('[id]').forEach(e=>{ if(seen[e.id]) dup++; seen[e.id]=1; }); + say('no duplicate ids', dup, 0); + say('the static block exists for the no-script path', !!q('.staticwrap'), true); + + // ---- head mode: the settled reference ---- + say('head: panel is 830 wide', Math.round(R(q('#app .u1')).width), 830); + say('head: the pill reports the mode', pill().textContent.indexOf('head')>=0, true); + say('head: four cards, one on the cabinet', qq('#app .tp').length===4 && qq('#app .tp.viaace').length===1, true); + say('head: the cabinet holds four bays', qq('#app .tp.viaace .bay').length, 4); + say('head: A2 is the fed bay, as measured', qq('#app .tp.viaace .bay')[1].classList.contains('fed'), true); + say('head: the selector offers feeder, ACE and hand-fed', opts(0).join('/'), 'Default feeder/ACE A/Hand-fed'); + say('head: body fits the 456 budget', H()<=BUDGET, true); + + // ---- multi, lanes, clean (cards is the settled default, so lanes is asked for) ---- + seg('mode','multi'); + say('multi: the pill follows', pill().textContent.indexOf('multi')>=0, true); + say('multi: cards is the default drawing - it is the settled one', + q('#seg-mlay button[aria-pressed="true"]').dataset.v, 'cards'); + seg('mlay','lanes'); + say('multi lanes: the cabinet is drawn once', qq('#app .cab').length, 1); + say('multi lanes: four heads in a row', qq('#app .mlay .laneh').length, 4); + say('multi lanes: the selector loses the feeder', opts(0).join('/'), 'Bay A1/Hand-fed'); + say('multi lanes: no card offers another lane', qq('#app .srcsel option').length, 8); + const cores=()=>qq('#app .fan path').filter(p=>p.getAttribute('stroke-width')==='2.6'); + say('multi lanes clean: three cores for three loaded heads', cores().length, 3); + // lane truth: core i leaves bay i and lands on lane i - start x equals its bay centre + const bayx=(k)=>{const b=qq('#app .cab .bay')[k],m=q('#app .mlay'); + return Math.round(R(b).left+R(b).width/2-R(m).left);}; + const startx=(p)=>Math.round(Number(p.getAttribute('d').match(/^M([\d.]+)/)[1])); + // cores are appended in head order (0, 1, 3), and clean means head i eats bay i - + // so pair them exactly: a cross-lane core would start at somebody else's bay + const cleanOk=cores().every((p,n)=>Math.abs(startx(p)-bayx([0,1,3][n]))<2); + say('multi lanes clean: every core starts at its own bay', cleanOk, true); + say('multi lanes: body fits', H()<=BUDGET, true); + + // ---- multi, cards, clean ---- + seg('mlay','cards'); + say('multi cards: the unit band sits above the grid', + !!q('#app .fbody > .bandrow + .lay'), true); + say('multi cards: one bay per card, four in all', qq('#app .tp .bay').length, 4); + say('multi cards: body fits', H()<=BUDGET, true); + + // ---- multi, as measured: the cross-lane truth ---- + seg('mlay','lanes'); seg('mstate','measured'); + say('measured lanes: exactly one core - one recorded feed', cores().length, 1); + say('measured lanes: the core leaves bay A2, not toolhead 4\'s own lane', + Math.abs(startx(cores()[0])-bayx(1))<2, true); + seg('mlay','cards'); + const lent=q('#app .bay.lent'); + say('measured cards: A2 is drawn once, on its own lane\'s card, marked', + !!lent && lent.title.indexOf('feeding Toolhead 4')>=0, true); + const chip=q('#app .fromchip'); + say('measured cards: toolhead 4 names what is in it and where from', + !!chip && chip.textContent.indexOf('from A2')>=0, true); + say('measured cards: no bay is drawn twice', + qq('#app .tp .bay').filter(b=>b.title.indexOf('A2')===0).length, 1); + + // ---- multi, as the shipped model reads it: the bug, drawn ---- + seg('mstate','asbuilt'); + say('as-built: the exhibit is labelled wrong', !!q('#app .exnote') && + q('#app .exnote').textContent.indexOf('WRONG IN THIS MODE')>=0, true); + say('as-built: it draws the head-mode picture on a multi machine', + qq('#app .feed').length===3 && qq('#app .cab').length===1, true); + say('as-built: the pill still says multi', pill().textContent.indexOf('multi')>=0, true); + + // ---- two units: the splitter picture ---- + seg('mstate','clean'); seg('mlay','lanes'); seg('units','2'); + say('2u lanes: two cabinets', qq('#app .cab').length, 2); + say('2u lanes: body fits', H()<=BUDGET, true); + seg('mlay','cards'); + say('2u cards: one bay per unit per card', qq('#app .tp .bay').length, 8); + say('2u cards: two unit bands', qq('#app .ustrip.band').length, 2); + say('2u cards: the selector names the lane SET, not one bay of it', + opts(0).join('/'), 'Bay A1 · B1/Hand-fed'); + say('2u cards: body fits', H()<=BUDGET, true); + + // ---- 560, the single-column reality ---- + seg('units','1'); seg('mlay','lanes'); seg('width','560'); + say('560 lanes: the panel is 560', Math.round(R(q('#app .u1')).width), 560); + say('560 lanes: the cabinet is not clipped', + R(q('#app .cab .top')).right <= R(q('#app .u1')).right+1, true); + say('560 lanes: body fits', H()<=BUDGET, true); + seg('mlay','cards'); + const clip=qq('#app .tp').filter(tp=>{const box=tp.querySelector('.acebox,.bays'); + return box && R(box).right > R(tp).right+1;}).length; + say('560 cards: no card clips its box', clip, 0); + say('560 cards: body fits', H()<=BUDGET, true); + + // ---- normal ---- + seg('width','830'); seg('mode','normal'); + say('normal: the pill follows', pill().textContent.indexOf('normal')>=0, true); + say('normal strip: the unit band carries Dry', !!q('#app .ustrip.band .drybtn'), true); + say('normal: four feeder cards, no cabinet', qq('#app .feed').length===4 && qq('#app .cab').length===0, true); + say('normal: the selector is feeder or hand-fed', opts(0).join('/'), 'Default feeder/Hand-fed'); + say('normal: body fits', H()<=BUDGET, true); + seg('nlay','quiet'); + say('normal quiet: no unit band, no Dry anywhere', !q('#app .ustrip.band') && !q('#app .drybtn'), true); + + // ---- the switch, run from the panel's own pill ---- + seg('mode','head'); + pill().click(); + say('the mode list opens with three entries', qq('#app .menu-item').length, 3); + say('every entry names its macro', qq('#app .menu-item .mcmd').every(c=>c.textContent.indexOf('SET_ACE_MODE')===0), true); + say('the current mode is marked', qq('#app .menu-item')[2].querySelector('.now').textContent, '●'); + qq('#app .menu-item')[1].click(); // Multi - the live pair + say('multi applies live, no dialog', pill().textContent.indexOf('multi')>=0 && !q('#app .dlg'), true); + say('the console line is the confirmation', q('#log').textContent.indexOf('No reboot needed')>=0, true); + pill().click(); qq('#app .menu-item')[2].click(); // Head - asks which head + say('head asks which head, current marked', qq('#app .hrow').length===4 && + qq('#app .hrow')[3].textContent.indexOf('on the cabinet now')>=0, true); + say('the head picker names the line it would send', + q('#app .dlgcmd').textContent, 'SET_ACE_MODE MODE=head HEAD=3'); + qq('#app .hrow')[1].click(); + say('picking a head moves the line', q('#app .dlgcmd').textContent.indexOf('HEAD=1')>=0, true); + qq('#app .hrow')[3].click(); q('#app .btn.primary').click(); + say('head applies live from multi', pill().textContent.indexOf('head')>=0, true); + // refusal: filament is loaded, and the machine's own sentences are shown + pill().click(); qq('#app .menu-item')[0].click(); // Normal + say('normal opens a confirm that names the restart', + q('#app .dlg .dlgnote').textContent.indexOf('restart')>=0, true); + q('#app .btn.primary').click(); + say('refused: the machine\'s first sentence, verbatim', + q('#app .dlgcon').textContent.indexOf('Cannot switch mode! Filament still loaded in: E0, E1, E3')>=0, true); + say('refused: and the second', q('#app .dlgcon').textContent.indexOf('Please unload all toolheads first')>=0, true); + q('#app .dlgx').click(); + // pending: unloaded, the success that arrives as an error + q('#mloaded').click(); + pill().click(); qq('#app .menu-item')[0].click(); q('#app .btn.primary').click(); + say('pending: the pill draws the disagreement', + pill().textContent.indexOf('head → normal · restart to finish')>=0, true); + say('pending: the banner is the exception\'s own message', + q('#app .banner').textContent.indexOf('Please reboot!')>=0, true); + say('pending: the panel still draws the OLD mode - ace.mode has not moved', + q('#app .u1').dataset.mode, 'head'); + q('#mreboot').click(); + say('the restart finishes it', pill().textContent.indexOf('normal')>=0 && !q('#app .banner'), true); + q('#mreset').click(); + say('reset restores the measured machine', pill().textContent.indexOf('head')>=0, true); + + // ---- both themes resolve; the panel does not follow ---- + const lum=(c)=>{const m=c.match(/\d+/g);if(!m)return -1;const [r,g,b]=m.map(Number); + return (0.2126*r+0.7152*g+0.0722*b)/255;}; + const docBgL=lum(getComputedStyle(document.body).backgroundColor); + document.documentElement.setAttribute('data-theme','dark'); + const docBgD=lum(getComputedStyle(document.body).backgroundColor); + say('dark: the document darkens', docBgD < docBgL, true); + say('dark: the panel stays a fixed light design', + lum(getComputedStyle(q('#app .panel-body')).backgroundColor) > 0.95, true); + document.documentElement.removeAttribute('data-theme'); + let bad=0; qq('table').forEach(t=>{ + if(t.scrollWidth > t.parentElement.clientWidth+1 && + getComputedStyle(t.parentElement).overflowX!=='auto') bad++; }); + say('no table overflows unscrollably', bad, 0); + }catch(e){ out.push('FAIL check threw: '+(e&&e.message||e)); } + window.__report = out.join('\n'); +})(); +""" + +CHECK = CHECKS.get(os.path.basename(PATH)) +if CHECK is None: + print(f"no checks for {os.path.basename(PATH)}. Known: {', '.join(sorted(CHECKS))}") + print("Add a CHECKS entry rather than reusing another mockup's - they are keyed to " + "one file's ids and classes.") + sys.exit(2) + +win = Gtk.OffscreenWindow(); win.set_default_size(1400, 1000) +view = WebKit2.WebView(); win.add(view); win.show_all() +view.load_uri("file://" + os.path.abspath(PATH)) +state = {"done": False, "rc": 1} + +def report(v, res, *_): + try: + val = v.evaluate_javascript_finish(res) + txt = val.to_string() if val else "" + except Exception as e: + txt = f"FAIL evaluate: {e}" + if not txt: + GLib.timeout_add(300, lambda: (view.evaluate_javascript("window.__report || ''", -1, None, None, None, report, None), False)[1]) + return + fails = [l for l in txt.splitlines() if l.startswith("FAIL")] + for l in txt.splitlines(): print(" " + l) + print(f"\n{len(txt.splitlines())-len(fails)}/{len(txt.splitlines())} checks passed") + state["rc"] = 1 if fails else 0 + state["done"] = True + Gtk.main_quit() + +def go(v, ev): + if ev != WebKit2.LoadEvent.FINISHED: return + def run(): + view.evaluate_javascript(CHECK, -1, None, None, None, lambda *a: None, None) + GLib.timeout_add(400, lambda: (view.evaluate_javascript("window.__report || ''", -1, None, None, None, report, None), False)[1]) + return False + GLib.timeout_add(700, run) + +view.connect("load-changed", go) +# The actions study drives the panel rather than reading it - a 1 px walk of the width, +# every popup opened from its own control - so it takes longer than a sheet of assertions. +GLib.timeout_add(90000, lambda: (Gtk.main_quit(), False)[1]) +Gtk.main() +sys.exit(state["rc"]) diff --git a/docs/u1-webui/tools/extract_activity.py b/docs/u1-webui/tools/extract_activity.py new file mode 100644 index 00000000000..0bb3dab4fe2 --- /dev/null +++ b/docs/u1-webui/tools/extract_activity.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Recover the machine's activity table - what it says it is doing right now. + +`machine_state_manager.action_code` is a small integer the printer pushes on the state +stream, and the bundle carries the switch that turns it into a sentence. It matters for +more than a status line: a toolchange can trigger an XY calibration, which takes far +longer than the bridge's own 15s request timeout, so a client that cannot see "Extruder +Docking Calibrating..." has no way to tell a slow success from a failure. + +Only codes >= 128 are taken. Below that the bundle has two further switches over small +integers - the main_state enum and a homing enum - and their cases collide with these +(1 is both "Working" and "Homing"), so mixing them would produce a table that is wrong +wherever it is ambiguous. + +Writes: docs/u1-webui/data/activity-codes.json +""" +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import DATA, read_bundle # noqa: E402 + +OUT = os.path.join(DATA, "activity-codes.json") + + +def _switch_around(src, needle, before=3000, after=2600): + """The case list of the switch that owns `needle`.""" + i = src.find(needle) + if i < 0: + sys.exit(f"not found in the bundle: {needle}") + start = src.rfind("switch(", 0, i - before) + seg = src[start:i + after] + return re.findall(r'case (\d+):return A\.D\("([^"]+)"', seg) + + +def main(): + src = read_bundle() + + # Two separate tables, and they must not be merged: their case spaces overlap, so + # 1 is "Working" in one and "Homing" in the other. Keyed apart, each is exact. + action = {} + for code, text in _switch_around(src, "Extruder Docking Calibrating"): + n = int(code) + if n >= 128: + action[str(n)] = text + + main_state = {} + for code, text in _switch_around(src, "XYZ calibrating"): + n = int(code) + if n <= 14: + main_state.setdefault(str(n), text) # first wins: later cases are the + # homing enum bleeding into the window + + os.makedirs(DATA, exist_ok=True) + json.dump({"action_codes": action, "main_states": main_state}, + open(OUT, "w", encoding="utf-8"), indent=1) + print(f"activity: {len(action)} action codes (>=128), {len(main_state)} main states") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/extract_bridge_methods.py b/docs/u1-webui/tools/extract_bridge_methods.py new file mode 100644 index 00000000000..fd32cb53c47 --- /dev/null +++ b/docs/u1-webui/tools/extract_bridge_methods.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Recover which printer method each bridge command turns into. + + python3 docs/u1-webui/tools/extract_bridge_methods.py + +Orca's bridge is mostly a thin funnel: `sw_ControlExtruderTemp` reaches the printer as +the JSON-RPC method `printer.control.extruder_temp` with its parameters passed almost +straight through. That mapping is the whole content of the C++ middle layer, and it is +what a host outside Orca needs in order to answer the page itself - see `u1_bridge.py`. + +Two hops, both read from source rather than transcribed: + + SSWCP.cpp m_cmd == "sw_ControlExtruderTemp" -> sw_ControlExtruderTemp() + sw_ControlExtruderTemp() -> host->async_control_extruder_temp(...) + MoonRaker.cpp async_control_extruder_temp -> method = "printer.control.extruder_temp" + params["temp"], params["index"], ... + +Writes `docs/u1-webui/data/bridge-methods.json`. Anything the join cannot complete is +recorded with a null method and a reason, because a command that quietly vanishes from +the table would look, to the host, exactly like one the printer does not implement. +""" +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +DATA = os.path.join(os.path.dirname(HERE), "data") +SSWCP = os.path.join(ROOT, "src", "slic3r", "GUI", "SSWCP.cpp") +SSWCP_H = os.path.join(ROOT, "src", "slic3r", "GUI", "SSWCP.hpp") +MOONRAKER = os.path.join(ROOT, "src", "slic3r", "Utils", "MoonRaker.cpp") + + +def read(path): + return open(path, encoding="utf-8", errors="replace").read().split("\n") + + +# A definition line for any member of any class: `void Foo::bar(`, `bool Foo::baz(`. +ANY_DEF = re.compile(r"\w[\w:<>,&\s\*]*\b(\w+)::(\w+)\s*\(") + + +def member_bodies(lines, cls): + """(name, body) for every `... Cls::name(` definition in the file. + + A body ends at the first line that is a lone `}` in column one - the convention this + codebase follows - or at the next definition of ANY class, whichever comes first. + Ending it at the next definition of the SAME class is not enough and is not a + theoretical worry: it made `sw_mqtt_publish` swallow every function defined between + it and the next MqttAgent member, and come out mapped to `machine.system_info`. + """ + at = [(i, m.group(1), m.group(2)) for i, l in enumerate(lines) if (m := ANY_DEF.match(l))] + starts = [i for i, _, _ in at] + for k, (i, owner, name) in enumerate(at): + if owner != cls: + continue + limit = starts[k + 1] if k + 1 < len(starts) else len(lines) + end = limit + for j in range(i + 1, limit): + if lines[j] == "}": + end = j + 1 + break + yield name, "\n".join(lines[i:end]) + + +def moonraker_methods(): + """async_* -> {method, params, forwarded}.""" + out = {} + for name, body in member_bodies(read(MOONRAKER), "Moonraker_Mqtt"): + if not name.startswith("async_"): + continue + m = (re.search(r'method\s*=\s*"([^"]+)"', body) + or re.search(r'send_to_request\(\s*"([^"]+)"', body)) + if not m: + continue + keys = sorted(set(re.findall(r'params\[\s*"([^"]+)"\s*\]', body))) + # `send_to_request(method, params_from_caller, ...)` with no params[...] of its + # own means the caller's JSON goes out untouched. + forwarded = not keys + prev = out.get(name) + # Some are overloaded; the JSON-forwarding overload is the one SSWCP calls. + if prev is None or (forwarded and not prev["forwarded"]): + out[name] = {"method": m.group(1), "params": keys, "forwarded": forwarded} + return out + + +def sswcp_dispatch(): + """wire command -> C++ handler name, straight out of the dispatcher chain. + + Half the chain compares against a string literal and half against a `#define`d + name from SSWCP.hpp. Reading only the literals silently loses the rest - which is + how `sw_GetDeviceDataStorageSpace` went missing on the first pass. + """ + src = "\n".join(read(SSWCP)) + defines = dict(re.findall(r'#define\s+(\w+)\s+"(\w+)"', "\n".join(read(SSWCP_H)))) + out = {} + for m in re.finditer(r'm_cmd\s*==\s*(?:"(\w+)"|(\w+))\s*\)\s*\{\s*(\w+)\s*\(', src): + cmd = m.group(1) or defines.get(m.group(2)) + if cmd: + out[cmd] = m.group(3) + return out + + +def sswcp_handlers(): + """C++ handler name -> the async_* calls its body makes.""" + lines = read(SSWCP) + out = {} + classes = sorted(set(re.findall(r"\b(SSWCP\w*Instance)::\w+\s*\(", "\n".join(lines)))) + for cls in classes: + for name, body in member_bodies(lines, cls): + calls = re.findall(r"->\s*(async_\w+)\s*\(", body) + if calls: + out.setdefault(name, []) + out[name] += [c for c in calls if c not in out[name]] + return out + + +def main(): + for p in (SSWCP, MOONRAKER): + if not os.path.exists(p): + print(f"missing {p}", file=sys.stderr) + return 1 + + methods = moonraker_methods() + dispatch = sswcp_dispatch() + handlers = sswcp_handlers() + + table, unresolved = {}, [] + for cmd, handler in sorted(dispatch.items()): + calls = handlers.get(handler, []) + hit = next((c for c in calls if c in methods), None) + if hit: + entry = dict(methods[hit]) + entry["handler"] = hit + table[cmd] = entry + else: + why = ("the handler makes no async_ call - answered inside Orca" + if not calls else + f"no JSON-RPC method found behind {', '.join(calls)}") + table[cmd] = {"method": None, "params": [], "forwarded": False, + "handler": calls[0] if calls else None, "why": why} + unresolved.append(cmd) + + os.makedirs(DATA, exist_ok=True) + out = os.path.join(DATA, "bridge-methods.json") + with open(out, "w", encoding="utf-8") as f: + json.dump(table, f, indent=1, sort_keys=True) + f.write("\n") + + mapped = len(table) - len(unresolved) + print(f"{os.path.relpath(out, ROOT)}") + print(f" {len(table)} dispatched commands, {mapped} reach the printer directly") + print(f" {len(unresolved)} answered inside Orca (or not through Moonraker_Mqtt)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/u1-webui/tools/extract_dart_classes.py b/docs/u1-webui/tools/extract_dart_classes.py new file mode 100644 index 00000000000..3c28de1b9d3 --- /dev/null +++ b/docs/u1-webui/tools/extract_dart_classes.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Recover original Dart class names + field names from dart2js output. + +dart2js minifies identifiers but NOT string literals. Dart's conventional +`toString()` embeds the class name and its field names as a literal, e.g. + + j(a){return"RequestPayload(cmd: "+this.a+", eventId: "+A.b(this.b)+...} + +so every class with a generated/hand-written toString leaks its real name and +field list. Likewise `A.x(["k1",v1,...])` inside a `O()` (toJson) method leaks +the wire field names. This recovers both and pairs them by minified symbol. + +Usage: extract_dart_classes.py +""" +import re, json, sys, collections + +SRC, OUT = sys.argv[1], sys.argv[2] +d = open(SRC, encoding='utf-8', errors='replace').read() + +# Each minified class body: A..prototype={ ... } +proto = re.compile(r'A\.([A-Za-z0-9_$]+)\.prototype=\{') +# toString: j(a){ ... return"Name(field: "... +tostr = re.compile(r'\bj\(a\)\{(?:[^{}]|\{[^{}]*\})*?return"([A-Z][A-Za-z0-9_]*)\(') +# toJson-ish: O(){return A.x(["a",..,"b",..], +tojson = re.compile(r'\bO\(\)\{return (?:[A-Za-z0-9_.$]+\()?A\.x\(\[(.*?)\]', re.S) + +classes = {} +starts = [(m.start(), m.group(1)) for m in proto.finditer(d)] +for idx, (pos, sym) in enumerate(starts): + end = starts[idx+1][0] if idx+1 < len(starts) else min(len(d), pos+8000) + body = d[pos:end] + rec = {"symbol": sym} + tm = tostr.search(body) + if tm: + rec["name"] = tm.group(1) + # field labels look like `field: ` inside the concatenation + seg = body[tm.start():tm.start()+1400] + rec["tostring_fields"] = list(dict.fromkeys(re.findall(r'[",]\s*([a-zA-Z_][\w]*):\s"', seg))) + jm = tojson.search(body) + if jm: + keys = re.findall(r'(?:\A|,)"([^"]*)"\s*,', jm.group(1)) + rec["json_keys"] = [k for k in keys if re.fullmatch(r'[A-Za-z_][\w]*', k or '')] + if "name" in rec or "json_keys" in rec: + classes[sym] = rec + +named = {v["name"]: v for v in classes.values() if "name" in v} +json.dump({"by_symbol": classes, "by_name": named}, open(OUT,'w'), indent=1, sort_keys=True) +print(f"classes with a recovered name: {len(named)}") +print(f"classes with wire (toJson) keys: {sum(1 for v in classes.values() if v.get('json_keys'))}") diff --git a/docs/u1-webui/tools/extract_enums.py b/docs/u1-webui/tools/extract_enums.py new file mode 100644 index 00000000000..56d9cc6ff57 --- /dev/null +++ b/docs/u1-webui/tools/extract_enums.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Recover Dart enum declarations from the dart2js constant pool. + +dart2js lowers a Dart enum value to a constant-pool entry of the shape + + B. = new A.(, "") + +The *type* is minified to `A.`, but the value's `index` and `name` +survive verbatim because Dart's `EnumName.name` needs them at runtime. Grouping +every constant by its type symbol therefore reconstructs the whole enum, in +declaration order, without any guesswork. + +Where the type also carries the conventional `toString` override + + A..prototype={ G(){return"."+this.b} } + +the real Dart type name is recovered too, so the output is a genuine +`enum TypeName { ... }` rather than an anonymous group. + +Writes: data/dart-enums.json and reconstructed/enums.dart +""" +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import read_bundle, DATA, RECON # noqa: E402 + +# Framework/runtime enums we do not care about. Anything matching is still +# extracted, just filed under "framework" instead of "app". +FRAMEWORK_HINTS = ( + "ui.", "dart.", "Axis", "Brightness", "BlendMode", "TextAlign", "Clip", + "Overflow", "Semantics", "Scroll", "Keyboard", "Pointer", "Painting", + "Rendering", "Material", "Cupertino", "Sentry", "Line", "Word", "Grapheme", +) + + +def value_name_sort(vals): + return sorted(vals.items()) + + +def main(): + d = read_bundle() + + # 1. type symbol -> {index: name} + enums = {} + pat = re.compile(r'B\.(\w+)\s*=\s*new A\.(\w+)\((\d+),"([A-Za-z_]\w*)"\)') + consts = {} + for m in pat.finditer(d): + const, sym, idx, name = m.groups() + enums.setdefault(sym, {})[int(idx)] = name + consts.setdefault(sym, {})[int(idx)] = const + + # 2. type symbol -> real Dart type name, from the toString override. + # G(){return"TypeName."+this.b} + names = {} + for m in re.finditer(r'A\.(\w+)\.prototype=\{\s*\n?G\(\)\{return"([\w$]+)\."\+this\.b\}', d): + names[m.group(1)] = m.group(2) + + # A few framework enums (BlendMode, StrokeCap, ...) are emitted twice, once + # for dart:ui and once for the framework re-export. Disambiguate by symbol + # so neither copy is silently dropped. + label_counts = {} + for sym, vals in enums.items(): + if len(vals) >= 2: + n = names.get(sym) or f"Anon_{sym}" + label_counts[n] = label_counts.get(n, 0) + 1 + + def is_bitfield(vals): + """Flag sets (SemanticsFlag, SemanticsAction) lower to the same constant + shape as an ordinal enum, but their 'index' is a bit mask, not a position. + Powers of two with no zero is the giveaway. Treating them as ordinal would + make `gaps` span the whole mask range, which is meaningless and enormous.""" + ks = sorted(vals) + return len(ks) >= 3 and all(k > 0 and (k & (k - 1)) == 0 for k in ks) + + out = {} + for sym, vals in enums.items(): + if len(vals) < 2: + continue # a single constant is not evidence of an enum + tname = names.get(sym) + label = tname or f"Anon_{sym}" + if label_counts.get(label, 0) > 1: + label = f"{label}#{sym}" + framework = tname is not None and any(h in tname for h in FRAMEWORK_HINTS) + bits = is_bitfield(vals) + out[label] = { + "dart_name": tname, + "symbol": sym, + "resolved": tname is not None, + "count": len(vals), + "shape": "bitfield" if bits else "ordinal", + "declared_max_index": max(vals), + "values": [ + {"index": i, "name": n, "const": consts[sym][i]} + for i, n in value_name_sort(vals) + ], + # Ordinal enums only: an index with no constant in the pool. The slot + # is real (the enum declares it) but its name was never referenced, + # so it cannot be recovered. Meaningless for a bitfield. + "gaps": [] if bits else [i for i in range(max(vals) + 1) if i not in vals], + "kind": "framework" if framework else "app", + } + + os.makedirs(DATA, exist_ok=True) + os.makedirs(RECON, exist_ok=True) + with open(os.path.join(DATA, "dart-enums.json"), "w", encoding="utf-8") as f: + json.dump(out, f, indent=1, sort_keys=True) + + # 3. Render the resolved ones as readable Dart. + lines = [ + "// Recovered from resources/web/flutter_web/main.dart.js by tools/extract_enums.py", + "// Values and indices are verbatim from the dart2js constant pool.", + "// A `gap` comment marks an index whose constant was never referenced,", + "// so its name could not be recovered - the slot itself is real.", + "", + ] + resolved = {k: v for k, v in out.items() if v["resolved"]} + for label in sorted(resolved): + e = resolved[label] + lines.append(f"/// minified: A.{e['symbol']} ({e['count']} values)") + if e["shape"] == "bitfield": + lines.append(f"abstract class {e['dart_name']} {{ // bit flags, not an enum") + for v in e["values"]: + lines.append(f" static const int {v['name']} = 0x{v['index']:X};") + lines.append("}") + lines.append("") + continue + lines.append(f"enum {e['dart_name']} {{") + prev = -1 + for v in e["values"]: + if v["index"] != prev + 1: + miss = ", ".join(str(i) for i in range(prev + 1, v["index"])) + lines.append(f" // gap: index {miss} not in constant pool") + lines.append(f" {v['name']}, // {v['index']}") + prev = v["index"] + lines.append("}") + lines.append("") + with open(os.path.join(RECON, "enums.dart"), "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + app = [k for k, v in out.items() if v["kind"] == "app"] + bitf = [k for k, v in out.items() if v["shape"] == "bitfield"] + print(f"enums recovered : {len(out)}") + print(f" with real Dart name: {len(resolved)}") + print(f" app (non-framework): {len(app)}") + print(f" bitfield (not enums): {len(bitf)}") + print(f"total enum values : {sum(v['count'] for v in out.values())}") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/extract_error_catalog.py b/docs/u1-webui/tools/extract_error_catalog.py new file mode 100644 index 00000000000..c8b39382fd1 --- /dev/null +++ b/docs/u1-webui/tools/extract_error_catalog.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Build the U1 error catalogue from the Flutter i18n table. + +The Device page carries two independent error tables: + + * assets/files/deviceError.json - a legacy 8-hex-digit `ecode`/`intro` list + inherited from the Bambu-derived codebase (it still mentions "Bambu Cloud" + and "AMS"). Kept for reference only. + + * assets/i10n/.json - the live U1 table: 442 codes as + `error_<16 hex>_title` / `error_<16 hex>_desc` pairs. + +The 16-digit code decomposes into four 16-bit groups: + + SSSS MMMM UUUU EEEE + | | | +-- specific error + | | +------- unit index (toolhead 0-3; 0000 = machine-wide) + | +------------ subsystem + +----------------- class / severity + +Usage: extract_error_catalog.py +""" +import json, re, sys, os, collections + +I10N, OUT = sys.argv[1], sys.argv[2] + +SUBSYSTEM = { + "0522": "system / motion", + "0523": "toolhead", + "0525": "filament", + "0526": "heated bed", + "0527": "chamber", + "0528": "homing / axis", + "0530": "calibration / detection", + "0531": "job / storage", + "0532": "defect detection (vision)", + "0533": "top cover / chamber", +} + +def load(locale): + p = os.path.join(I10N, f"{locale}.json") + return json.load(open(p, encoding='utf-8')) if os.path.exists(p) else {} + +en, zh = load("en"), load("zh-CN") + +rows = collections.defaultdict(dict) +for src, tag in ((en, "en"), (zh, "zh")): + for k, v in src.items(): + if not isinstance(v, str): + continue + m = re.match(r'^error_([0-9A-Fa-f]{16})_(title|desc)$', k) + if m: + rows[m.group(1)][f"{tag}_{m.group(2)}"] = v + +out = {} +for code in sorted(rows): + s, mo, u, e = (code[0:4], code[4:8], code[8:12], code[12:16]) + rec = dict(rows[code]) + rec.update({ + "code": code, + "class": s, + "subsystem": mo, + "subsystem_name": SUBSYSTEM.get(mo, "unknown"), + "unit_index": int(u, 16), + "specific": e, + }) + out[code] = rec + +json.dump({"codes": out, "subsystems": SUBSYSTEM}, open(OUT, 'w'), + indent=1, ensure_ascii=False, sort_keys=True) + +by_sub = collections.Counter(v["subsystem_name"] for v in out.values()) +print(f"codes: {len(out)}") +for k, n in by_sub.most_common(): + print(f" {k:28s} {n}") diff --git a/docs/u1-webui/tools/extract_errors.py b/docs/u1-webui/tools/extract_errors.py new file mode 100644 index 00000000000..ca3fb6e6f2c --- /dev/null +++ b/docs/u1-webui/tools/extract_errors.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Build the printer error-code table from the bundle's shipped asset. + +`assets/assets/files/deviceError.json` is a plain, unminified asset: a map of +locale -> [{ecode, intro}]. No extraction cleverness is needed, but the codes are +worth normalising because the leading digits are a *subsystem* identifier and the +rest a fault index, which is what makes the table navigable. + +Writes: data/error-codes.json +""" +import collections +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import BUNDLE, DATA # noqa: E402 + +# Error codes are 8 hex digits: SSSS S EEE +# +# digits 1-4 subsystem +# digit 5 severity (derived below - see SEVERITY) +# digits 6-8 fault index within the subsystem +# +# The subsystem labels are derived from the faults actually grouped under each +# prefix, not guessed: 0700-0703 carry an identical nine-fault filament-path set +# repeated four times, which is what identifies them as per-toolhead banks on the +# U1's four toolheads. +SUBSYSTEM = { + "0300": "printer core - motion, homing, bed and nozzle temperature", + "0500": "print-job submission and cloud transport", + "0700": "filament path, toolhead 0", + "0701": "filament path, toolhead 1", + "0702": "filament path, toolhead 2", + "0703": "filament path, toolhead 3", + "0C00": "defect detection (first layer / spaghetti)", +} + +# The fifth hex digit separates hard faults from states the user can act on. +SEVERITY = { + "4": "error - the job cannot continue", + "8": "pause / actionable - the user can retry or resume", + "C": "warning - a possible defect, resume permitted", +} + +# Toolhead banks 0700-0703 hold the same nine faults; record the bank index so a +# consumer can collapse them. +TOOLHEAD_BANK = {"0700": 0, "0701": 1, "0702": 2, "0703": 3} + +def main(): + src = os.path.join(BUNDLE, "assets", "assets", "files", "deviceError.json") + with open(src, encoding="utf-8") as f: + raw = json.load(f) + + locales = sorted(raw) + en = {e["ecode"]: e["intro"] for e in raw.get("en", [])} + + entries = {} + for code in sorted(en): + text = {loc: next((e["intro"] for e in raw[loc] if e["ecode"] == code), None) + for loc in locales} + entries[code] = { + "ecode": code, + "subsystem_prefix": code[:4], + "subsystem": SUBSYSTEM.get(code[:4], "unknown"), + "severity_digit": code[4], + "severity": SEVERITY.get(code[4], "unknown"), + "fault_index": code[5:], + "toolhead": TOOLHEAD_BANK.get(code[:4]), + "text": text, + # Two slots ship with an empty string in every locale. + "placeholder": not any((t or "").strip() for t in text.values()), + } + + by_prefix = collections.Counter(c[:4] for c in entries) + by_sev = collections.Counter(c[4] for c in entries) + placeholders = [c for c, e in entries.items() if e["placeholder"]] + out = { + "source_asset": "resources/web/flutter_web/assets/assets/files/deviceError.json", + "locales": locales, + "count": len(entries), + "code_layout": "SSSS S EEE = subsystem(4) severity(1) fault index(3)", + "by_subsystem": {p: {"count": n, "label": SUBSYSTEM.get(p, "unknown"), + "toolhead": TOOLHEAD_BANK.get(p)} + for p, n in sorted(by_prefix.items())}, + "by_severity": {d: {"count": n, "label": SEVERITY.get(d, "unknown")} + for d, n in sorted(by_sev.items())}, + "placeholder_codes": sorted(placeholders), + "errors": entries, + } + + os.makedirs(DATA, exist_ok=True) + with open(os.path.join(DATA, "error-codes.json"), "w", encoding="utf-8") as f: + json.dump(out, f, indent=1, ensure_ascii=False) + + print(f"locales : {', '.join(locales)}") + print(f"error codes : {len(entries)}") + for p, n in sorted(by_prefix.items()): + print(f" {p}xxxx {n:4} {SUBSYSTEM.get(p,'unknown')}") + print("severity:") + for d, n in sorted(by_sev.items()): + print(f" ....{d}... {n:4} {SEVERITY.get(d,'unknown')}") + if placeholders: + print(f"placeholder (empty text in every locale): {', '.join(sorted(placeholders))}") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/extract_routes.py b/docs/u1-webui/tools/extract_routes.py new file mode 100644 index 00000000000..72952924250 --- /dev/null +++ b/docs/u1-webui/tools/extract_routes.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Recover the entry-point table: `?path=N` -> AppModule -> named route. + +Orca opens the same Flutter bundle for several different surfaces and selects +between them with a single query parameter: + + .../web/flutter_web/index.html?path=2 -> the Device page + .../web/flutter_web/index.html?path=4 -> the Print-processing popup + .../web/flutter_web/index.html?path=5 -> the upload-only popup + +`main()` parses that parameter through one small function which survives dart2js +as a chain of string comparisons, each returning an `AppModule` constant: + + bFQ(a){var s=B.c.fS(J.p(a),"/","") + if(s==="")return B.asT + else if(s==="1"||s==="home")return B.up + ... + +Each returned constant resolves through the constant pool to `new A.qH(i,"name")`, +which gives both the enum index and the route name. Note that the URL number and +the enum index are NOT the same for every module - see the report below. + +Writes: data/entry-points.json +""" +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import read_bundle, DATA, ROOT # noqa: E402 + + +def main(): + d = read_bundle() + + # 1. The path -> constant dispatcher. + m = re.search(r'\n(\w+)\(a\)\{var s=B\.c\.fS\(J\.p\(a\),"/",""\)\n(.*?)\nreturn B\.(\w+)\},', d, re.S) + if not m: + sys.exit("path dispatcher not found - bundle layout changed") + fn_sym, body, fallback = m.group(1), m.group(2), m.group(3) + + # if(s==="4"||s==="preUploadAndPrint")return B.asW + pairs = re.findall(r's==="([^"]*)"(?:\|\|s==="([^"]*)")?\)return B\.(\w+)', body) + + # 2. Resolve every constant through the pool: B.x = new A.qH(i,"name") + pool = {c: (int(i), n) for c, i, n in + re.findall(r'B\.(\w+)\s*=\s*new A\.qH\((\d+),"(\w+)"\)', d)} + + # 3. Which C++ file opens which path, for the round trip. + hosts = {} + for rel in ("src/slic3r/GUI/PrinterWebView.cpp", "src/slic3r/GUI/WebPreprintDialog.cpp", + "src/slic3r/GUI/WebViewDialog.cpp", "src/slic3r/GUI/WebGuideDialog.cpp", + "src/slic3r/GUI/WebPresetDialog.cpp"): + p = os.path.join(ROOT, rel) + if not os.path.exists(p): + continue + src = open(p, encoding="utf-8", errors="replace").read() + for pm in re.finditer(r'flutter_web/index\.html\?path=(\d+)', src): + hosts.setdefault(pm.group(1), []).append(rel) + + entries = [] + for num, alias, const in pairs: + idx, name = pool.get(const, (None, None)) + entries.append({ + "url_path_param": num or None, + "url_alias": alias or None, + "module_const": f"B.{const}", + "enum_index": idx, + "module_name": name, + "index_matches_url": (num.isdigit() and idx == int(num)) if num else None, + "opened_by": sorted(set(hosts.get(num, []))), + }) + + fidx, fname = pool.get(fallback, (None, None)) + out = { + "dispatcher_symbol": fn_sym, + "enum_type": "AppModule (minified A.qH)", + "fallback": {"module_const": f"B.{fallback}", "enum_index": fidx, "module_name": fname}, + "entries": entries, + } + + os.makedirs(DATA, exist_ok=True) + with open(os.path.join(DATA, "entry-points.json"), "w", encoding="utf-8") as f: + json.dump(out, f, indent=1) + + print(f"dispatcher: A.{fn_sym}() fallback: {fname}") + print(f"{'path':>5} {'alias':22} {'module':22} {'idx':>3} opened by") + for e in entries: + flag = "" if e["index_matches_url"] in (True, None) else " <-- index != url" + host = ", ".join(os.path.basename(h) for h in e["opened_by"]) or "-" + print(f"{str(e['url_path_param']):>5} {str(e['url_alias']):22} " + f"{str(e['module_name']):22} {str(e['enum_index']):>3} {host}{flag}") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/extract_rpc.py b/docs/u1-webui/tools/extract_rpc.py new file mode 100644 index 00000000000..114819b7820 --- /dev/null +++ b/docs/u1-webui/tools/extract_rpc.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Extract JSON-RPC call constructions (method + param names) from dart2js output. + +dart2js emits Dart map literals as A.x(["k1",v1,"k2",v2,...], KeyType, ValType). +A JSON-RPC envelope therefore appears as: + A.x(["jsonrpc","2.0","method","","params",A.x([...]),"id",], K, V) +We locate each envelope, then read the nested params map's literal keys. +""" +import re, json, sys + +SRC, OUT = sys.argv[1], sys.argv[2] +data = open(SRC, encoding='utf-8', errors='replace').read() + +head = re.compile(r'"jsonrpc","2\.0","method","([a-zA-Z_][\w.]*)"') + +def balanced(s, i): + """From index of '(' return index just past its match.""" + d = 0 + while i < len(s): + if s[i] == '(': d += 1 + elif s[i] == ')': + d -= 1 + if d == 0: return i + 1 + i += 1 + return -1 + +out, sites = {}, 0 +for m in head.finditer(data): + method = m.group(1) + tail = data[m.end(): m.end() + 1200] + # never run past the *end of this envelope*: dart2js closes the outer A.x([...] + # right after the "id" entry, so cut at the first '"id",]' we meet. + idm = re.search(r'"id",[^,\[\]]{0,40}?\]', tail) + if idm: + tail = tail[:idm.end()] + sites += 1 + e = out.setdefault(method, {"method": method, "params": set(), + "param_style": set(), "call_sites": 0}) + e["call_sites"] += 1 + pm = re.search(r'"params",', tail) + if not pm: + e["param_style"].add("none"); continue + after = tail[pm.end():] + am = re.match(r'A\.x\(\[', after) + if am: + end = balanced(after, after.index('(')) + body = after[am.end(): end] + # flattened k,v list -> keys sit at even positions; take quoted tokens + # that are immediately preceded by start-or-comma AND followed by comma + keys = re.findall(r'(?:\A|,)"([^"]*)"\s*,', body) + # keep alternating: first, third, ... are keys in a flat k,v list + e["params"].update(k for k in keys if re.fullmatch(r'[A-Za-z_][\w]*', k or '')) + e["param_style"].add("literal") + else: + e["param_style"].add("dynamic") + +res = {} +for k, v in out.items(): + res[k] = {"method": k, "call_sites": v["call_sites"], + "param_style": sorted(v["param_style"]), + "params": sorted(v["params"])} +json.dump(res, open(OUT, 'w'), indent=1, sort_keys=True) +print(f"envelopes: {sites} distinct methods: {len(res)}") +for k in sorted(res): + v = res[k] + print(f" {k:48s} {'/'.join(v['param_style']):8s} {', '.join(v['params'])}") diff --git a/docs/u1-webui/tools/extract_state_model.py b/docs/u1-webui/tools/extract_state_model.py new file mode 100644 index 00000000000..e57446b916b --- /dev/null +++ b/docs/u1-webui/tools/extract_state_model.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Extract the U1 machine-state model from the Flutter bundle. + +Two artefacts are recovered: + +1. The *subscription list* - the array of Klipper/Snapmaker object names the + Device page asks for. dart2js emits it as a constant string list. + +2. The *field filter map* - a Dart function (minified to `bJ3` in v2.3.26) + containing `switch (objectName) { case "x": map[x] = ["f1","f2"]; ... }`. + A `null` field list means "subscribe to every field of this object". + +Usage: extract_state_model.py +""" +import re, json, sys + +SRC, OUT = sys.argv[1], sys.argv[2] +data = open(SRC, encoding='utf-8', errors='replace').read() + +# --- 1. subscription list ------------------------------------------------- +# Anchor on a constant list that contains the unmistakable U1 object names. +sub = [] +for m in re.finditer(r'A\.a\(s\(\[((?:"[^"]*",?)+)\]\)', data): + items = re.findall(r'"([^"]*)"', m.group(1)) + if "print_stats" in items and "heater_bed" in items: + sub = items + break + +# --- 2. field filter map -------------------------------------------------- +# Find the switch that maps object name -> field list. +fields, default_extruder = {}, None +anchor = re.search(r'switch\s*\(p\)\s*\{\s*case"configfile"', data) +if anchor: + body = data[anchor.start(): anchor.start() + 4000] + # `case"a":case"b":l.l(0,p,A.a([...],r))` or `l.l(0,p,m)` for "all fields" + for cm in re.finditer(r'((?:case"[^"]*":\s*)+)l\.l\(0,p,(A\.a\(\[(.*?)\],\w+\)|\w+)\)', body, re.S): + names = re.findall(r'case"([^"]*)"', cm.group(1)) + if cm.group(3) is not None: + flist = re.findall(r'"([^"]*)"', cm.group(3)) + else: + flist = None # bare variable == null == all fields + for n in names: + fields[n] = flist + # objects matched by substring ("extruder", "extruder1", ...) in the default arm + dm = re.search(r'iC\(p,"extruder",0\)\)l\.l\(0,p,A\.a\(\[(.*?)\],\w+\)\)', body, re.S) + if dm: + default_extruder = re.findall(r'"([^"]*)"', dm.group(1)) + # `case"toolhead":break` style = present but no field map + for nm in re.findall(r'case"([^"]*)":break', body): + fields.setdefault(nm, None) + +out = { + "subscription_list": sub, + "field_filter": fields, + "extruder_default_fields": default_extruder, + "notes": { + "null_fields": "null => subscribe to all fields of that object", + "extruder_rule": "any object whose name contains 'extruder' uses extruder_default_fields", + }, +} +json.dump(out, open(OUT, 'w'), indent=1, sort_keys=True) +print(f"subscription objects: {len(sub)}") +print(f"explicit field maps : {len(fields)}") +print(f"extruder defaults : {len(default_extruder or [])}") diff --git a/docs/u1-webui/tools/extract_strings.py b/docs/u1-webui/tools/extract_strings.py new file mode 100644 index 00000000000..70b3fb4cf04 --- /dev/null +++ b/docs/u1-webui/tools/extract_strings.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Extract JS string literals from dart2js output, handling escapes.""" +import re, sys, json, collections + +SRC = sys.argv[1] +OUT = sys.argv[2] + +data = open(SRC, encoding='utf-8', errors='replace').read() + +# Match single- or double-quoted JS string literals (no newlines inside, escapes handled) +pat = re.compile(r'"((?:[^"\\\n]|\\.)*)"' + r"|'((?:[^'\\\n]|\\.)*)'") + +counts = collections.Counter() +for m in pat.finditer(data): + s = m.group(1) if m.group(1) is not None else m.group(2) + if s is None: + continue + counts[s] += 1 + +with open(OUT, 'w', encoding='utf-8') as f: + for s, c in counts.most_common(): + f.write(f"{c}\t{s}\n") + +print(f"unique strings: {len(counts)}") +print(f"total occurrences: {sum(counts.values())}") diff --git a/docs/u1-webui/tools/extract_wcp_commands.py b/docs/u1-webui/tools/extract_wcp_commands.py new file mode 100644 index 00000000000..2ea9b53d08d --- /dev/null +++ b/docs/u1-webui/tools/extract_wcp_commands.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Recover the WcpCmd catalogue: enum value -> wire name -> subscription channel. + +The Flutter bundle models every bridge call as a value of the Dart enum `WcpCmd` +(minified to `A.bq`). Two getters on that enum survive dart2js as plain switch +statements over the enum index, and both keep their string literals: + + gaX(){switch(this.a){ case 0:return"sw_StartMachineFind" ... }} # wire name + ge2(){switch(this.a){ case 0:return"send_machine_list" ... }} # push channel + +That makes the bundle - not the C++ - the authoritative list of what the page can +ask for. Cross-referencing against SSWCP.cpp's dispatch then shows, per command, +whether it is implemented by the host, page-only, or host-only. + +Writes: data/wcp-commands.json +""" +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import read_bundle, read_cpp, DATA, SSWCP_CPP # noqa: E402 +SSWCP_HPP = SSWCP_CPP[:-4] + ".hpp" + + +def switch_map(body): + """`case :return""` pairs from a lowered Dart switch.""" + return {int(i): s for i, s in re.findall(r'case (\d+):return"([^"]*)"', body)} + + +def main(): + d = read_bundle() + + # The two getters live inside `A.bq.prototype={...}`; locate by their + # unmistakable first case rather than by minified getter name, which moves + # between builds. + anchor = d.find('gaX(){switch(this.a){case 0:return"sw_StartMachineFind"') + if anchor < 0: + sys.exit("WcpCmd wire-name switch not found - bundle layout changed") + wire = switch_map(d[anchor:d.find("default:", anchor)]) + + a2 = d.find('ge2(){switch(this.a){', anchor) + channel = switch_map(d[a2:d.find("default:", a2)]) if a2 > 0 else {} + + # Enum value names, from the constant pool (see extract_enums.py). + names = {} + for m in re.finditer(r'B\.\w+\s*=\s*new A\.bq\((\d+),"(\w+)"\)', d): + names[int(m.group(1))] = m.group(2) + + # Which commands the C++ host knows about. + # + # Careful: SSWCP.hpp defines 15 of them as macros (`#define DOWN_LOAD_FILE + # "sw_DownLoadFile"`) and the .cpp routing tables then use the macro, not the + # literal. Scanning only the .cpp for `"sw_*"` therefore under-reports and + # makes host-implemented commands look page-only. Read both files and expand + # the macros before comparing. + cpp = read_cpp(SSWCP_CPP) + hpp = read_cpp(SSWCP_HPP) + macros = dict(re.findall(r'^#define\s+(\w+)\s+"(sw_[A-Za-z0-9_]+)"', hpp, re.M)) + cpp_cmds = set(re.findall(r'"(sw_[A-Za-z0-9_]+)"', cpp + hpp)) + cpp_cmds |= {v for k, v in macros.items() if re.search(r'\b' + k + r'\b', cpp)} + + # Routing group -> instance class, from the m__cmd_list initialisers. + group_of = {} + for gm in re.finditer(r'SSWCP::(m_(\w+)_cmd_list)\s*=\s*\{(.*?)\}', cpp, re.S): + group = gm.group(2) + for tok in re.findall(r'"(sw_[A-Za-z0-9_]+)"|\b([A-Z][A-Z0-9_]{2,})\b', gm.group(3)): + name = tok[0] or macros.get(tok[1]) + if name: + group_of[name] = group + + # Which commands the page actually references as a literal anywhere. + page_literals = set(re.findall(r'"(sw_[A-Za-z0-9_]+)"', d)) + + out = {} + for idx in sorted(wire): + w = wire[idx] + out[w] = { + "index": idx, + "wire_name": w, + "dart_enum_value": names.get(idx), + "push_channel": channel.get(idx), + "implemented_in_cpp": w in cpp_cmds, + "routing_group": group_of.get(w), + "referenced_in_bundle": w in page_literals, + } + + # Host commands the page's WcpCmd enum does not know about. + for w in sorted(cpp_cmds - set(out)): + out[w] = { + "index": None, + "wire_name": w, + "dart_enum_value": None, + "push_channel": None, + "implemented_in_cpp": True, + "routing_group": group_of.get(w), + "referenced_in_bundle": w in page_literals, + "note": "host-only: dispatched by SSWCP.cpp but absent from the page's WcpCmd enum", + } + + os.makedirs(DATA, exist_ok=True) + with open(os.path.join(DATA, "wcp-commands.json"), "w", encoding="utf-8") as f: + json.dump(out, f, indent=1, sort_keys=True) + + both = [c for c in out.values() if c["implemented_in_cpp"] and c["index"] is not None] + page_only = [c for c in out.values() if not c["implemented_in_cpp"]] + host_only = [c for c in out.values() if c["index"] is None] + print(f"WcpCmd wire names : {len(wire)}") + print(f"push channels : {len(channel)}") + print(f"in both page and host : {len(both)}") + print(f"page-only (no C++) : {len(page_only)}") + for c in page_only: + print(f" {c['wire_name']} ({c['dart_enum_value']})") + print(f"host-only (no WcpCmd) : {len(host_only)}") + for c in host_only: + print(f" {c['wire_name']}") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/extract_wire_enums.py b/docs/u1-webui/tools/extract_wire_enums.py new file mode 100644 index 00000000000..56b2fb7581c --- /dev/null +++ b/docs/u1-webui/tools/extract_wire_enums.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Extract enum *wire values* from the Flutter bundle. + +A Dart enum has two different strings, and confusing them is a real bug source: + + * the **member name** (`PrintState.completed`), recovered from the constant pool + * the **wire value** the app actually sends or matches (`"complete"`) + +For `PrintState` these differ - ordinal 7 is named `completed` but emits `complete`, +which is Klipper's own spelling. A client matching on `completed` never fires. + +This targets the wire side: getters shaped + + ga0(a){switch(this.a){case 0:return"..." case 1:return"..." ...}} + +Member-name/ordinal recovery for the whole bundle lives in the sibling effort at +docs/u1-webui/ (tools/extract_enums.py); this is deliberately narrow. + +Usage: extract_wire_enums.py +""" +import re, json, sys + +SRC, OUT = sys.argv[1], sys.argv[2] +d = open(SRC, encoding='utf-8', errors='replace').read() + +getter = re.compile( + r'\b(\w+)\(a\)\{switch\(this\.a\)\{' + r'((?:case \d+:return"[^"]*"\s*)+)' + r'(?:default:return"([^"]*)")?\}\}') + +# label a getter by a signature of values we can recognise +SIGNATURES = [ + ({"printing", "paused", "standby"}, "PrintState"), + ({"dev", "staging", "prod"}, "AppEnvironment"), + ({"GET", "POST", "PUT"}, "HttpMethod"), + ({"CN", "US"}, "Region"), + ({"en", "zh"}, "AppLocale"), +] + +out = {} +for i, m in enumerate(getter.finditer(d)): + pairs = re.findall(r'case (\d+):return"([^"]*)"', m.group(2)) + values = {int(k): v for k, v in pairs} + vset = set(values.values()) + name = next((n for sig, n in SIGNATURES if sig <= vset), None) + key = name or f"unnamed_{i}" + out[key] = { + "getter_symbol": m.group(1), + "identified": name is not None, + "default": m.group(3), + "values": [values[k] for k in sorted(values)], + "by_ordinal": {str(k): values[k] for k in sorted(values)}, + } + +json.dump(out, open(OUT, 'w'), indent=1, ensure_ascii=False, sort_keys=True) +print(f"wire-value enums: {len(out)} identified: {sum(1 for v in out.values() if v['identified'])}") +for k, v in sorted(out.items()): + print(f" {k:16s} {v['values']}") diff --git a/docs/u1-webui/tools/gen_activity_module.py b/docs/u1-webui/tools/gen_activity_module.py new file mode 100644 index 00000000000..ee19ea88de0 --- /dev/null +++ b/docs/u1-webui/tools/gen_activity_module.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Emit the activity table as a JS module the Device page imports. + +Generated rather than transcribed, for the same reason the fault catalogue is: it comes +from the bundle and there is no value in a hand-copy that can drift from it. + +Writes: resources/web/shared/js/activity.js +""" +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import DATA, ROOT # noqa: E402 + +OUT = os.path.join(ROOT, "resources", "web", "shared", "js", "activity.js") + + +def main(): + data = json.load(open(os.path.join(DATA, "activity-codes.json"), encoding="utf-8")) + def rows(d): + return "\n".join(f" {k}: {json.dumps(v)}," for k, v in + sorted(d.items(), key=lambda kv: int(kv[0]))) + js = f'''/* + * activity.js - what the machine says it is doing. GENERATED, do not edit. + * python3 docs/u1-webui/tools/gen_activity_module.py + * + * Two tables, because the printer reports at two granularities and they are not + * interchangeable: + * + * machine_state_manager.main_state coarse - "XYZ calibrating", "Docking Coordinate + * Calibrating" - the thing that makes a toolchange + * take minutes + * machine_state_manager.action_code fine - "Checking Extruder Pick...", and only + * populated for some operations + * + * They must stay keyed apart: their case spaces overlap, so 1 is "Working" in one and + * "Homing" in the other. Merging them would be wrong wherever it is ambiguous. + * + * This is not decoration. A toolchange can trigger a calibration that runs far longer + * than the bridge's 15s request timeout, so without these a client cannot tell a slow + * success from a failure - it only sees its own request give up. + */ +'use strict'; + +export const ACTION_LABELS = {{ +{rows(data["action_codes"])} +}}; + +export const MAIN_STATE_LABELS = {{ +{rows(data["main_states"])} +}}; + +/* Idle says nothing, and Working says nothing a caller waiting on a specific operation + * did not already know. Everything else is worth showing. */ +const UNINFORMATIVE_MAIN_STATES = new Set([0, 1]); + +/** Text for an action code, or null when idle or silent. */ +export function activityLabel(code) {{ + if (code == null) return null; + const n = Number(code); + if (!Number.isFinite(n) || n === 0) return null; + return ACTION_LABELS[n] || `Working (code ${{n}})`; +}} + +/** Text for a main_state, or null when it carries no information. */ +export function mainStateLabel(state) {{ + if (state == null) return null; + const n = Number(state); + if (!Number.isFinite(n) || UNINFORMATIVE_MAIN_STATES.has(n)) return null; + return MAIN_STATE_LABELS[n] || null; +}} + +/** + * The best sentence available for what the machine is doing. + * + * action_code first because it is the more specific of the two, then main_state, which + * is where a calibration triggered by a toolchange actually shows up. + */ +export function machineActivity(activity) {{ + if (!activity) return null; + return activityLabel(activity.actionCode) || mainStateLabel(activity.mainState); +}} + +/** Is the machine reporting that it is busy with anything at all? */ +export function isBusy(activity) {{ + if (!activity) return false; + const a = Number(activity.actionCode); + if (Number.isFinite(a) && a !== 0) return true; + const m = Number(activity.mainState); + return Number.isFinite(m) && !UNINFORMATIVE_MAIN_STATES.has(m) && m !== 14; +}} +''' + with open(OUT, "w", encoding="utf-8") as f: + f.write(js) + print(f"activity module: {len(data['action_codes'])} action codes, " + f"{len(data['main_states'])} main states") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/gen_command_catalogue.py b/docs/u1-webui/tools/gen_command_catalogue.py new file mode 100644 index 00000000000..d601b27bf80 --- /dev/null +++ b/docs/u1-webui/tools/gen_command_catalogue.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Render data/wcp-commands.json as the human-readable command catalogue. + +Writes: 04-bridge-wcp/02-command-catalogue.md +""" +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import DATA # noqa: E402 + +DOC = os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "04-bridge-wcp", "02-command-catalogue.md")) + +GROUP_LABEL = { + "machine_find": ("Machine discovery", "SSWCP_MachineFind_Instance"), + "machine_connect": ("Connection and pairing", "SSWCP_MachineConnect_Instance"), + "machine_option": ("Machine control, files, print jobs", "SSWCP_MachineOption_Instance"), + "machine_manage": ("Saved-device management", "SSWCP_MachineManage_Instance"), + "project": ("Projects and recent files", "SSWCP_SliceProject_Instance"), + "login": ("Account", "SSWCP_UserLogin_Instance"), + "mqtt_agent": ("MQTT transport agent", "SSWCP_MqttAgent_Instance"), + "page_state": ("Page lifecycle", "SSWCP_PageStateChange_Instance"), + None: ("Ungrouped — handled by the base instance", "SSWCP_Instance"), +} +ORDER = ["machine_find", "machine_connect", "machine_option", "machine_manage", + "project", "login", "mqtt_agent", "page_state", None] + + +def main(): + cmds = json.load(open(os.path.join(DATA, "wcp-commands.json"), encoding="utf-8")) + + by_group = {} + for c in cmds.values(): + by_group.setdefault(c.get("routing_group"), []).append(c) + + both = [c for c in cmds.values() if c["implemented_in_cpp"] and c["index"] is not None] + page_only = [c for c in cmds.values() if not c["implemented_in_cpp"]] + host_only = [c for c in cmds.values() if c["index"] is None] + + L = [] + L.append("# WCP command catalogue") + L.append("") + L.append("Generated by [`tools/gen_command_catalogue.py`](../tools/gen_command_catalogue.py)") + L.append("from [`data/wcp-commands.json`](../data/wcp-commands.json). Do not edit by hand.") + L.append("") + L.append("Two independent sources are cross-referenced:") + L.append("") + L.append("- the page's `WcpCmd` enum, whose `gaX()` getter maps each enum index to a") + L.append(" wire name (`sw_*`) and survives dart2js as a plain switch;") + L.append("- `SSWCP.cpp` / `SSWCP.hpp`, which route each wire name to an instance class.") + L.append("") + L.append(f"| | Count |") + L.append("|---|---|") + L.append(f"| Commands in the page's `WcpCmd` enum | {len([c for c in cmds.values() if c['index'] is not None])} |") + L.append(f"| Implemented by the C++ host | {len([c for c in cmds.values() if c['implemented_in_cpp']])} |") + L.append(f"| Present on both sides | {len(both)} |") + L.append(f"| Page-only — no host handler | {len(page_only)} |") + L.append(f"| Host-only — absent from `WcpCmd` | {len(host_only)} |") + L.append("") + + L.append("## Version skew") + L.append("") + L.append("The shipped bundle and the C++ host are versioned independently, and the") + L.append("mismatch is visible in both directions.") + L.append("") + L.append("**Page-only** — the page can emit these, nothing answers them:") + L.append("") + L.append("| Wire name | `WcpCmd` value |") + L.append("|---|---|") + for c in sorted(page_only, key=lambda c: c["wire_name"]): + L.append(f"| `{c['wire_name']}` | `{c['dart_enum_value'] or '—'}` |") + L.append("") + L.append("`sw_UnSubscribeMachineState` is worth singling out: the enum declares both it") + L.append("(index 7) and `sw_StopMachineStateSubscription` (index 10), but the host") + L.append("implements only the latter. A call on index 7 would go unanswered and fail on") + L.append("the page's 3-second timeout.") + L.append("") + L.append("**Host-only** — implemented in C++, absent from the bundle's enum:") + L.append("") + for c in sorted(host_only, key=lambda c: c["wire_name"]): + L.append(f"- `{c['wire_name']}`") + L.append("") + L.append("`sw_FinishPreprint`, `sw_GetPrintZip` and `sw_GetPrintLegal` belong to the") + L.append("print-processing flow and *are* exercised at runtime, so the page reaches them") + L.append("by some path other than a `WcpCmd` constant.") + L.append("") + + L.append("## By routing group") + L.append("") + L.append("`SSWCP::create_sswcp_instance` picks an instance class by testing membership") + L.append("in one of eight `unordered_set`s; anything unmatched falls through to the base") + L.append("`SSWCP_Instance`.") + L.append("") + for g in ORDER: + if g not in by_group: + continue + label, cls = GROUP_LABEL[g] + rows = sorted(by_group[g], key=lambda c: c["wire_name"]) + L.append(f"### {label}") + L.append("") + L.append(f"`{cls}` — {len(rows)} commands") + L.append("") + L.append("| Wire name | `WcpCmd` value | Idx | Push channel | Host |") + L.append("|---|---|---|---|---|") + for c in rows: + host = "yes" if c["implemented_in_cpp"] else "**no**" + L.append(f"| `{c['wire_name']}` | `{c['dart_enum_value'] or '—'}` | " + f"{c['index'] if c['index'] is not None else '—'} | " + f"{('`'+c['push_channel']+'`') if c['push_channel'] else '—'} | {host} |") + L.append("") + + with open(DOC, "w", encoding="utf-8") as f: + f.write("\n".join(L) + "\n") + print(f"wrote {os.path.relpath(DOC)} ({len(L)} lines, {len(cmds)} commands)") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/gen_command_reference.py b/docs/u1-webui/tools/gen_command_reference.py new file mode 100644 index 00000000000..7775be51eae --- /dev/null +++ b/docs/u1-webui/tools/gen_command_reference.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Generate the SSWCP command reference markdown from SSWCP.cpp + the bundle. + +Reads: + - src/slic3r/GUI/SSWCP.cpp (dispatch, handler bodies, routing groups) + - resources/web/flutter_web/main.dart.js (which commands the page actually calls) +Writes a grouped markdown table: command -> handler -> params -> response keys. +""" +import re, json, sys, os, collections + +ROOT = sys.argv[1] if len(sys.argv) > 1 else "/home/gordian/proj/OrcaSlicer" +CPP = f"{ROOT}/src/slic3r/GUI/SSWCP.cpp" +DART = f"{ROOT}/resources/web/flutter_web/main.dart.js" +OUT = sys.argv[2] if len(sys.argv) > 2 else os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "04-bridge-wcp", "03-command-parameters.md") + +cpp = open(CPP, encoding='utf-8', errors='replace').read() +dart_cmds = set(re.findall(r'"(sw_[A-Za-z0-9_]+)"', open(DART, encoding='utf-8', errors='replace').read())) + +# --- routing groups: static unordered_set SSWCP::m__cmd_list = {...} +GROUP_LABEL = { + "machine_find": "Discovery", + "machine_connect": "Connection", + "machine_option": "Machine control / files / camera", + "project": "Project & recent files", + "login": "Account, downloads & timelapse", + "machine_manage": "Saved-device management", + "page_state": "Page state", + "mqtt_agent": "Raw MQTT agent", +} +groups, defines = {}, dict(re.findall(r'#define\s+([A-Z_0-9]+)\s+"(sw_[A-Za-z0-9_]+)"', cpp)) +for m in re.finditer(r'SSWCP::m_(\w+?)_cmd_list\s*=\s*\{(.*?)\};', cpp, re.S): + g, body = m.group(1), m.group(2) + for tok in re.findall(r'"(sw_[A-Za-z0-9_]+)"|\b([A-Z_0-9]{4,})\b', body): + name = tok[0] or defines.get(tok[1]) + if name: + groups[name] = g + +# --- dispatch: m_cmd == "sw_X" -> handler() +dispatch = {} +for m in re.finditer(r'm_cmd\s*==\s*"(sw_[A-Za-z0-9_]+)"\s*\)\s*\{\s*(?:this->)?(\w+)\s*\(', cpp): + dispatch[m.group(1)] = m.group(2) + +# --- handler bodies +bodies = {} +for m in re.finditer(r'^[A-Za-z_][\w:<>,\s\*&]*\bSSWCP\w*::(\w+)\s*\([^;{]*\)\s*\{', cpp, re.M): + start, d, k = m.end()-1, 0, m.end()-1 + while k < len(cpp): + if cpp[k] == '{': d += 1 + elif cpp[k] == '}': + d -= 1 + if d == 0: break + k += 1 + bodies.setdefault(m.group(1), '') + bodies[m.group(1)] += cpp[start:k] + +def keys(body, var): + return sorted(set(re.findall(re.escape(var)+r'\["([^"]+)"\]', body)) | + set(re.findall(re.escape(var)+r'\.count\("([^"]+)"\)', body))) + +rows = collections.defaultdict(list) +for cmd in sorted(set(dispatch) | dart_cmds & set(dispatch)): + h = dispatch.get(cmd, '') + b = bodies.get(h, '') + rows[groups.get(cmd, "common")].append({ + "cmd": cmd, "handler": h, + "params": keys(b, "m_param_data"), + "response": keys(b, "m_res_data"), + "called_by_page": cmd in dart_cmds, + }) + +order = ["machine_option","machine_connect","machine_find","machine_manage", + "login","project","page_state","mqtt_agent","common"] +with open(OUT, 'w') as f: + f.write("\n") + f.write("# SSWCP command reference\n\n") + f.write(f"{sum(len(v) for v in rows.values())} commands dispatched by " + "`SSWCP_*::process()`. `page` = called by the Flutter Device page bundle.\n\n") + for g in order: + if g not in rows: continue + f.write(f"## {GROUP_LABEL.get(g, g)}\n\n") + f.write("| Command | page | C++ handler | Request `params` | Response `data` keys |\n") + f.write("|---|:--:|---|---|---|\n") + for r in sorted(rows[g], key=lambda x: x["cmd"]): + p = ", ".join(f"`{x}`" for x in r["params"]) or "-" + q = ", ".join(f"`{x}`" for x in r["response"]) or "-" + f.write(f"| `{r['cmd']}` | {'yes' if r['called_by_page'] else ''} | " + f"`{r['handler']}()` | {p} | {q} |\n") + f.write("\n") +print(f"groups: {len(rows)} commands: {sum(len(v) for v in rows.values())}", file=sys.stderr) diff --git a/docs/u1-webui/tools/gen_error_module.py b/docs/u1-webui/tools/gen_error_module.py new file mode 100644 index 00000000000..13d53e69622 --- /dev/null +++ b/docs/u1-webui/tools/gen_error_module.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Emit the U1 fault catalogue as a JS module the reconstruction can import. + +The 442-code catalogue is extracted from the bundle's i18n table by +extract_error_catalog.py. The Device page needs it at runtime to turn a raw +`action_code` into something a person can read, so it is emitted as data rather +than transcribed by hand. + +Writes: resources/web/shared/js/errors.js +""" +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import DATA, ROOT # noqa: E402 + +OUT = os.path.join(ROOT, "resources", "web", "shared", "js", "errors.js") + + +def main(): + cat = json.load(open(os.path.join(DATA, "error-catalog.json"), encoding="utf-8")) + codes = cat["codes"] + subsystems = cat.get("subsystems", {}) + + # keep it compact: [title, desc] per code + table = {c: [v.get("en_title") or v.get("title") or "", + v.get("en_desc") or v.get("desc") or ""] + for c, v in sorted(codes.items())} + + L = [] + A = L.append + A("/*") + A(" * errors.js - the U1 fault catalogue.") + A(" *") + A(" * GENERATED by docs/u1-webui/tools/gen_error_module.py from") + A(" * docs/u1-webui/data/error-catalog.json. Do not edit by hand.") + A(" *") + A(" * Codes are 16 hex digits: SSSS MMMM UUUU EEEE") + A(" * class/severity, subsystem, unit index (toolhead), specific fault.") + A(" * See docs/u1-webui/06-errors/01-live-error-catalogue.md") + A(" */") + A("'use strict';") + A("") + A("export const SUBSYSTEMS = " + json.dumps(subsystems, indent=1, sort_keys=True) + ";") + A("") + A(f"/** {len(table)} codes, [title, description]. */") + A("export const FAULTS = " + json.dumps(table, indent=0, sort_keys=True) + ";") + A("") + A("""/** Decode a 16-hex-digit code into its four groups. */ +export function decodeErrorCode(code) { + const c = String(code == null ? '' : code).trim(); + if (!/^[0-9A-Fa-f]{16}$/.test(c)) return null; + const subsystem = c.slice(4, 8); + const unit = parseInt(c.slice(8, 12), 16); + return { + code: c.toUpperCase(), + errorClass: c.slice(0, 4), + subsystem, + subsystemName: SUBSYSTEMS[subsystem] || 'unknown', + unitIndex: unit, + specific: c.slice(12, 16), + // toolhead and filament faults number their head from the unit group + toolhead: (subsystem === '0523' || subsystem === '0525') ? unit + 1 : null, + }; +} + +/** + * Look a fault up. Accepts the 16-digit form, and also a bare integer + * `action_code`, which the state model reports as a number. + */ +export function lookupFault(code) { + if (code == null || code === 0 || code === '') return null; + let key = String(code).trim(); + if (/^[0-9]+$/.test(key) && key.length < 16) { + key = Number(key).toString(16).toUpperCase().padStart(16, '0'); + } + key = key.toUpperCase(); + const hit = FAULTS[key] || FAULTS[key.toLowerCase()]; + const parts = decodeErrorCode(key); + if (!hit && !parts) return null; + return { + code: key, + title: (hit && hit[0]) || 'Printer fault', + description: (hit && hit[1]) || 'No description for this code in the shipped catalogue.', + known: !!hit, + ...(parts || {}), + }; +}""") + with open(OUT, "w", encoding="utf-8") as f: + f.write("\n".join(L) + "\n") + print(f"wrote {os.path.relpath(OUT, ROOT)} ({len(table)} codes, " + f"{os.path.getsize(OUT):,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/gen_jsonrpc_reference.py b/docs/u1-webui/tools/gen_jsonrpc_reference.py new file mode 100644 index 00000000000..85935fa6385 --- /dev/null +++ b/docs/u1-webui/tools/gen_jsonrpc_reference.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Render data/jsonrpc-methods.json as the namespace-ordered RPC reference. + +This is the generated *reference* view: every method, grouped by namespace, with +the literal parameter names recovered from the bundle. The hand-written +01-jsonrpc-methods.md groups the same surface by function and adds the async +notification channel; the two are complementary. + +Writes: 05-printer-protocol/02-jsonrpc-reference.md +""" +import collections +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import DATA # noqa: E402 + +DOC = os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "05-printer-protocol", "02-jsonrpc-reference.md")) + +ORIGIN = { + "printer": "Klipper/Moonraker core, plus Snapmaker `printer.control.*`", + "server": "Moonraker server API, plus Snapmaker `server.client_manager.*`", + "machine": "Moonraker machine API", + "camera": "**Snapmaker extension**", + "custom": "**Snapmaker extension**", + "system": "**Snapmaker extension**", +} + + +def main(): + d = json.load(open(os.path.join(DATA, "jsonrpc-methods.json"), encoding="utf-8")) + groups = collections.defaultdict(list) + for k, v in sorted(d.items()): + groups[k.split(".")[0]].append(v) + + L = [] + A = L.append + A("# JSON-RPC reference") + A("") + A("Generated by [`tools/gen_jsonrpc_reference.py`](../tools/gen_jsonrpc_reference.py)") + A("from [`data/jsonrpc-methods.json`](../data/jsonrpc-methods.json). Do not edit by hand.") + A("") + A("Every method the bundle constructs, grouped by namespace. For the same surface") + A("grouped by *function* — plus the printer's async notification channel — see") + A("[JSON-RPC methods](01-jsonrpc-methods.md).") + A("") + A("Extraction works because dart2js lowers a Dart map literal to") + A("`A.x([\"k1\",v1,\"k2\",v2,...])`, so an envelope appears literally as") + A("`[\"jsonrpc\",\"2.0\",\"method\",\"\",\"params\",A.x([...]),\"id\",]`. Parameter names are") + A("recoverable whenever the params map is a literal; where it is built at runtime the") + A("method is still found but its params are marked *dynamic*.") + A("") + A(f"{len(d)} distinct methods across {len(groups)} namespaces.") + A("") + A("| Namespace | Methods | Origin |") + A("|---|---|---|") + for g in sorted(groups): + A(f"| `{g}.*` | {len(groups[g])} | {ORIGIN.get(g, '')} |") + A("") + for g in sorted(groups): + A(f"## `{g}.*`") + A("") + A("| Method | Params | Call sites |") + A("|---|---|---|") + for m in sorted(groups[g], key=lambda x: x["method"]): + style = "/".join(m["param_style"]) + p = ", ".join(f"`{x}`" for x in m["params"]) if m["params"] else f"*{style}*" + A(f"| `{m['method']}` | {p} | {m['call_sites']} |") + A("") + with open(DOC, "w", encoding="utf-8") as f: + f.write("\n".join(L) + "\n") + print(f"wrote {os.path.relpath(DOC)} ({len(d)} methods)") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/gen_legacy_error_doc.py b/docs/u1-webui/tools/gen_legacy_error_doc.py new file mode 100644 index 00000000000..1b08e9af57e --- /dev/null +++ b/docs/u1-webui/tools/gen_legacy_error_doc.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Render data/error-codes.json as the legacy-error-asset reference. + +This documents `assets/files/deviceError.json` - the 72-code, 8-hex-digit table +inherited from the Bambu-derived codebase. It is NOT the U1's live fault table; +that is the 442-code i18n catalogue built by extract_error_catalog.py. + +Writes: 06-errors/02-legacy-error-asset.md +""" +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _common import DATA # noqa: E402 + +DOC = os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "06-errors", "02-legacy-error-asset.md")) + + +def main(): + d = json.load(open(os.path.join(DATA, "error-codes.json"), encoding="utf-8")) + L = [] + A = L.append + A("# Legacy error asset (`deviceError.json`)") + A("") + A("> **This is the secondary table.** The U1's live faults are the 442-code,") + A("> 16-hex-digit catalogue in [`01-live-error-catalogue.md`](01-live-error-catalogue.md),") + A("> carried in the i18n table as `error_<16 hex>_title` / `_desc` pairs. The 72 codes") + A("> below come from `assets/assets/files/deviceError.json`, an 8-hex-digit asset") + A("> inherited from the Bambu-derived codebase — which is why several of its strings") + A("> still say \"Bambu Cloud\" and \"AMS\". It ships, but it is not the live fault table.") + A("") + A("Generated by [`tools/gen_legacy_error_doc.py`](../tools/gen_legacy_error_doc.py) from") + A("[`data/error-codes.json`](../data/error-codes.json). Do not edit by hand.") + A("") + A(f"{d['count']} codes, in `{'` and `'.join(d['locales'])}`.") + A("") + A("## Encoding") + A("") + A("Eight hex digits — note this is a *different* scheme from the live catalogue's") + A("sixteen:") + A("") + A("```") + A(" 0300 4 000") + A(" ^^^^ ^ ^^^") + A(" | | └── fault index within the subsystem") + A(" | └──── severity") + A(" └───────── subsystem") + A("```") + A("") + A("| Prefix | Count | Subsystem |") + A("|---|---|---|") + for p, v in d["by_subsystem"].items(): + A(f"| `{p}` | {v['count']} | {v['label']} |") + A("") + A("The `0700`–`0703` banks carry an identical nine-fault set, once per toolhead —") + A("which is what identifies the third byte as a toolhead index.") + A("") + A("| Severity digit | Count | Meaning |") + A("|---|---|---|") + for dig, v in d["by_severity"].items(): + A(f"| `{dig}` | {v['count']} | {v['label']} |") + A("") + A("## Placeholders") + A("") + A(f"{len(d['placeholder_codes'])} codes ship with an **empty string in every locale** —") + A("`xxxx8007` and `xxxx8010` in each of the four toolhead banks:") + A("") + A("```") + A(" " + " ".join(d["placeholder_codes"])) + A("```") + A("") + A("## Full table") + A("") + A("| Code | Severity | Text (en) |") + A("|---|---|---|") + for c, e in sorted(d["errors"].items()): + t = (e["text"].get("en") or "").replace("|", "\\|") + if not t.strip(): + t = "*(empty — placeholder)*" + A(f"| `{c}` | `{e['severity_digit']}` | {t} |") + A("") + with open(DOC, "w", encoding="utf-8") as f: + f.write("\n".join(L) + "\n") + print(f"wrote {os.path.relpath(DOC)} ({d['count']} codes)") + + +if __name__ == "__main__": + main() diff --git a/docs/u1-webui/tools/harness/README.md b/docs/u1-webui/tools/harness/README.md new file mode 100644 index 00000000000..feb351dd7fe --- /dev/null +++ b/docs/u1-webui/tools/harness/README.md @@ -0,0 +1,131 @@ +# Screenshot harness + +Runs the **real** shipped bundle in a headless browser against a **simulated** +Orca host, so the Device page and the print popup can be captured, inspected and +instrumented without building Orca or owning a U1. + +It is also the fastest way to check a protocol claim: implement the host side in +`inject-state.js`, load the page, and see whether it behaves. That is how the +`code: 200` rule in [the envelope doc](../../04-bridge-wcp/01-envelope.md) was +found — replying `0` produced a login gate, replying `200` did not. + +## What it does + +``` +harness tree (symlinks to the real bundle, plus an edited index.html) + │ + ├─ cloud-stub.js intercepts fetch/XHR to *.snapmaker.com + ├─ wcp-bridge.js implements window.wx, records every request + └─ inject-state.js canned responses, per command + │ +harness_server.py serves it at /web/flutter_web/ (the bundle's ) + │ +cdp.py + shoot.py drive headless Chrome over the DevTools Protocol + │ + └─> screenshots/*.png and a JSON log of every bridge call +``` + +Nothing writes into `resources/` — the harness tree is symlinks plus one edited +`index.html`, built in a scratch directory. + +## Requirements + +- A Chromium build. Playwright's cached one works: + `~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome` +- Its NSS dependencies (`libnss3`, `libnspr4`). If they are missing system-wide, + extract them locally and point `LD_LIBRARY_PATH` at them — no root needed: + ```bash + apt-get download libnss3 libnspr4 + dpkg-deb -x libnss3_*.deb x && dpkg-deb -x libnspr4_*.deb x + find x -name '*.so*' -exec cp -a {} ./libs/ \; + ``` +- Python 3. `cdp.py` implements the WebSocket framing itself, so there are no + Python package requirements at all. + +## Building the tree + +```bash +BUNDLE=resources/web/flutter_web +H=/tmp/harness/web/flutter_web +mkdir -p "$H" +for f in "$BUNDLE"/*; do + [ "$(basename "$f")" = index.html ] || ln -s "$(realpath "$f")" "$H/" +done +cp docs/u1-webui/tools/harness/{wcp-bridge,inject-state,cloud-stub}.js "$H/" +# index.html with the three scripts injected before flutter_bootstrap.js +sed 's||\n\n\n&|' \ + "$BUNDLE/index.html" > "$H/index.html" +``` + +The `` in `index.html` is why the tree must be +served with that exact prefix. + +## Running + +```bash +python3 harness_server.py /tmp/harness 8765 /tmp/dumps & +python3 shoot.py device-control 2 16 1280x900 +python3 shoot.py preuploadandprint 4 14 714x750 click=295,400 post=3 +``` + +`shoot.py NAME PATH [WAIT] [WxH] [click=x,y[;x,y]] [post=SECONDS]` + +- `PATH` is the `?path=` value — see [entry points](../../01-architecture/03-entry-points.md) +- `WAIT` is real seconds to let Flutter boot and settle +- `click=` dispatches real mouse events, e.g. to dismiss the login gate +- writes `shots/NAME.png` and `dumps/NAME.json` + +The dump holds every bridge request the page made, plus the list of commands no +fixture answered — which is the quickest way to find what a screen actually needs. + +## Notes and limits + +- **Use real waits, not `--virtual-time-budget`.** The app schedules periodic + timers and retry loops; under virtual time these run away and the browser hangs + or exits before network callbacks land. +- **Serve with `Cache-Control: no-store`** (the server does). Chrome will + otherwise reuse a stale `wcp-bridge.js` between runs and silently ignore edits. +- `/json/new` is PUT-only in current Chrome; `cdp.py` attaches to the target the + browser already opened and navigates that instead. +- **There is now a second way to run the bundle**, it needs no chromium, and it + reaches a **connected** page with live telemetry: + + ```bash + python3 resources/web/shared/tests/run_webkit.py --original --sn --watch + ``` + + It loads the bundle in WebKitGTK - the engine Orca itself renders with - against a + real printer, answered by [`u1_bridge.py`](../u1_bridge.py) rather than fixtures. + This directory's `cloud-stub.js` is injected for the same reason this harness injects + it. `--sn` matters: Orca's config can hold stale records, and the bundle tries to + connect every device it is handed. + + Four things had to be right, and each was read out of the C++ rather than guessed: + + 1. **Orca posts replies as a JSON STRING.** `send_to_js` builds + `window.postMessage(JSON.stringify({...}), '*')`. Posting the object instead - which + the reconstruction's client happily parses - makes the bundle ignore every reply in + silence. This one masquerades as "the page will not pick a device". + 2. **TLS is decided by the credentials, not the scheme**: `if (ca != "" && cert != "" + && key != "")`. The bundle asks for `mqtt://:8883` - plain scheme, TLS port. + 3. **Orca keeps its own connection to the printer.** `get_connect_host()` is what + answers `sw_GetMachineState`, and it is not the MQTT clients the page creates. The + bundle never calls `sw_mqtt_set_engine` because in Orca a host is already attached, + so the bridge brings up a session of its own. + 4. **`sw_GetConnectedMachine` is the gate.** It returns the first saved device flagged + `connected`; with none, the page sits at `sn=` forever. The bridge probes each + saved device with a TCP connect and reports the ones that answer. + + Also measured: **the bundle's own request timeout is 3 seconds**, against the + reconstruction's 15. And `local_devices_arrived` - which Orca posts on a separate, + non-envelope channel - appears **zero** times in `main.dart.js`; it is for Orca's own + non-Flutter device cards, not for this bundle. + + Still rough: a "Binding rejected" dialog from the cloud stub, and toolheads 2-4 read + `_/_ °C` while toolhead 1 and the bed carry real numbers. + +- A **fully connected** Device page with live telemetry was not reached. The + session additionally depends on the page's own `deviceList` / + `deviceFilamentInfo` cache — written by the page, only relayed by the host — so + the control grid renders disabled. Everything up to `ConnectionStatus.connectedOnline` + does work with `cloud-stub.js` in place. diff --git a/docs/u1-webui/tools/harness/cdp.py b/docs/u1-webui/tools/harness/cdp.py new file mode 100644 index 00000000000..81f4f51d4ed --- /dev/null +++ b/docs/u1-webui/tools/harness/cdp.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""A dependency-free Chrome DevTools Protocol client. + +Only what a screenshot harness needs: connect to a target, call methods, read +results. WebSocket framing is implemented inline because the environment has no +websocket package and pulling one in for ~60 lines of framing is not worth it. +""" +import base64, json, os, socket, struct, subprocess, time, urllib.request + + +class WS: + def __init__(self, url): + # ws://127.0.0.1:PORT/devtools/page/ID + rest = url.split("://", 1)[1] + hostport, path = rest.split("/", 1) + host, port = hostport.split(":") + self.s = socket.create_connection((host, int(port)), timeout=60) + key = base64.b64encode(os.urandom(16)).decode() + req = (f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\nUpgrade: websocket\r\n" + f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n" + f"Sec-WebSocket-Version: 13\r\n\r\n") + self.s.sendall(req.encode()) + buf = b"" + while b"\r\n\r\n" not in buf: + buf += self.s.recv(4096) + if b"101" not in buf.split(b"\r\n")[0]: + raise RuntimeError("websocket handshake failed: " + buf[:200].decode("latin1")) + self.buf = buf.split(b"\r\n\r\n", 1)[1] + + def _read(self, n): + while len(self.buf) < n: + chunk = self.s.recv(65536) + if not chunk: + raise RuntimeError("socket closed") + self.buf += chunk + out, self.buf = self.buf[:n], self.buf[n:] + return out + + def send(self, text): + payload = text.encode() + n = len(payload) + hdr = b"\x81" + if n < 126: + hdr += struct.pack("!B", 0x80 | n) + elif n < (1 << 16): + hdr += struct.pack("!BH", 0x80 | 126, n) + else: + hdr += struct.pack("!BQ", 0x80 | 127, n) + mask = os.urandom(4) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + self.s.sendall(hdr + mask + masked) + + def recv(self): + """Return one complete text message (reassembling continuation frames).""" + out = b"" + while True: + b0, b1 = self._read(2) + fin, opcode = b0 & 0x80, b0 & 0x0F + masked, ln = b1 & 0x80, b1 & 0x7F + if ln == 126: + ln = struct.unpack("!H", self._read(2))[0] + elif ln == 127: + ln = struct.unpack("!Q", self._read(8))[0] + mask = self._read(4) if masked else None + data = self._read(ln) + if mask: + data = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + if opcode == 0x8: + raise RuntimeError("websocket closed by peer") + if opcode == 0x9: # ping -> pong + continue + out += data + if fin: + return out.decode("utf-8", "replace") + + +class Chrome: + def __init__(self, binary, port=9333, extra=(), env=None): + self.port = port + self.proc = subprocess.Popen( + [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", + f"--remote-debugging-port={port}", "--remote-allow-origins=*", + "about:blank", *extra], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env) + for _ in range(120): + try: + urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read() + break + except Exception: + time.sleep(0.25) + else: + raise RuntimeError("chrome did not expose the devtools endpoint") + self._id = 0 + + def open(self, url): + """Attach to the browser's existing page target and navigate it. + + /json/new is a PUT-only endpoint in current Chrome and returns 405 for a + GET/POST, so drive the target that --headless already opened instead. + """ + targets = json.loads( + urllib.request.urlopen(f"http://127.0.0.1:{self.port}/json", timeout=10).read()) + page = next(t for t in targets if t.get("type") == "page") + self.ws = WS(page["webSocketDebuggerUrl"]) + self.call("Page.enable") + self.call("Page.navigate", url=url) + return page + + def call(self, method, timeout=60, **params): + self._id += 1 + mid = self._id + self.ws.send(json.dumps({"id": mid, "method": method, "params": params})) + deadline = time.time() + timeout + while time.time() < deadline: + msg = json.loads(self.ws.recv()) + if msg.get("id") == mid: + if "error" in msg: + raise RuntimeError(f"{method}: {msg['error']}") + return msg.get("result", {}) + raise TimeoutError(method) + + def eval(self, expr, timeout=60): + r = self.call("Runtime.evaluate", timeout=timeout, expression=expr, + returnByValue=True, awaitPromise=True) + return r.get("result", {}).get("value") + + def shot(self, path, full=False): + r = self.call("Page.captureScreenshot", format="png", captureBeyondViewport=full) + with open(path, "wb") as f: + f.write(base64.b64decode(r["data"])) + return os.path.getsize(path) + + def close(self): + try: + self.proc.terminate(); self.proc.wait(timeout=10) + except Exception: + self.proc.kill() + + +import urllib.parse # noqa: E402 (used in open()) diff --git a/docs/u1-webui/tools/harness/cloud-stub.js b/docs/u1-webui/tools/harness/cloud-stub.js new file mode 100644 index 00000000000..07518d30fd3 --- /dev/null +++ b/docs/u1-webui/tools/harness/cloud-stub.js @@ -0,0 +1,53 @@ +// Stub the Snapmaker cloud REST API. +// +// The page treats a failed cloud call as "user is offline" and tears the session +// down, so without this the UI can never reach a connected state offline. Only +// snapmaker hosts are intercepted; everything else passes through untouched. +(function () { + const SN = 'U1SIM0000000001'; + const DEV = { + sn: SN, deviceName: 'Snapmaker U1', model: 'Snapmaker U1', modelName: 'Snapmaker U1', + status: 1, online: true, bind: true, isOnline: true, ownerId: '10001' + }; + function body(url) { + if (url.includes('/user/device/list')) return { code: 200, message: 'ok', data: { list: [DEV], total: 1 } }; + if (url.includes('/user/device/info')) return { code: 200, message: 'ok', data: DEV }; + if (url.includes('getMqttCert')) return { code: 200, message: 'ok', data: { + endpoint: '127.0.0.1', port: 8883, clientId: 'orca-sim', + certificatePem: '', privateKey: '', rootCa: '' } }; + if (url.includes('checkAuth')) return { code: 200, message: 'ok', data: { auth: true, status: 4 } }; + if (url.includes('/oauth2/token')) return { code: 200, message: 'ok', data: { access_token: 'sim-token', token_type: 'Bearer', expires_in: 86400 } }; + return { code: 200, message: 'ok', data: {} }; + } + const isCloud = u => /snapmaker\.(com|cn)|172\.17\.100\.32/.test(String(u)); + + const of = window.fetch; + window.fetch = function (input, init) { + const url = typeof input === 'string' ? input : (input && input.url) || ''; + if (!isCloud(url)) return of.apply(this, arguments); + return Promise.resolve(new Response(JSON.stringify(body(url)), { + status: 200, headers: { 'Content-Type': 'application/json' } })); + }; + + const OX = window.XMLHttpRequest; + window.XMLHttpRequest = function () { + const x = new OX(), open = x.open; + let url = ''; + x.open = function (m, u) { url = u; return open.apply(x, arguments); }; + const send = x.send; + x.send = function () { + if (!isCloud(url)) return send.apply(x, arguments); + const text = JSON.stringify(body(url)); + Object.defineProperty(x, 'readyState', { get: () => 4 }); + Object.defineProperty(x, 'status', { get: () => 200 }); + Object.defineProperty(x, 'responseText', { get: () => text }); + Object.defineProperty(x, 'response', { get: () => text }); + setTimeout(function () { + x.onreadystatechange && x.onreadystatechange(); + x.onload && x.onload(); + }, 1); + }; + return x; + }; + window.XMLHttpRequest.prototype = OX.prototype; +})(); diff --git a/docs/u1-webui/tools/harness/harness_server.py b/docs/u1-webui/tools/harness/harness_server.py new file mode 100644 index 00000000000..24dca0d51a8 --- /dev/null +++ b/docs/u1-webui/tools/harness/harness_server.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Serve the harness tree and accept the page's bridge recording back over POST.""" +import http.server, os, sys + +ROOT = sys.argv[1] +PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8765 +OUTDIR = sys.argv[3] if len(sys.argv) > 3 else ROOT + +class H(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=ROOT, **kw) + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(n) + if self.path.startswith("/wcp-dump"): + name = self.path.split("name=", 1)[1].split("&")[0] if "name=" in self.path else "dump" + with open(os.path.join(OUTDIR, f"{name}.json"), "wb") as f: + f.write(body) + sys.stderr.write(f"[dump] {name}: {len(body)} bytes\n"); sys.stderr.flush() + self.send_response(204); self.end_headers() + def end_headers(self): + # Harness files are edited between runs; never let the browser reuse a copy. + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate") + super().end_headers() + def log_message(self, *a): + pass + +http.server.ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() diff --git a/docs/u1-webui/tools/harness/inject-state.js b/docs/u1-webui/tools/harness/inject-state.js new file mode 100644 index 00000000000..1189c321ae9 --- /dev/null +++ b/docs/u1-webui/tools/harness/inject-state.js @@ -0,0 +1,141 @@ +// Canned host responses, so the page can be driven into states that otherwise +// need a real printer. Shapes come from SSWCP.cpp (the C++ that normally answers) +// and from the subscription field filters in data/state-model.json. +(function () { + const SN = 'U1SIM0000000001'; + + const DEVICE = { + ip: '192.168.1.87', dev_id: SN, dev_name: 'Snapmaker U1', model_name: 'Snapmaker U1', + preset_name: 'Snapmaker U1 0.4 nozzle', connected: true, img: '', + nozzle_sizes: ['0.4', '0.4', '0.4', '0.4'], sn: SN, protocol: 1, api_key: '', + user: '', password: '', ca: '', cert: '', key: '', clientId: 'orca-sim', + port: 8883, link_mode: 'lan', userid: '10001', id: SN + }; + + const USER = { + status: 'online', nickname: 'Simulated User', icon: '', + token: 'sim-token', userid: '10001', account: 'sim@example.com' + }; + + // Klipper/Moonraker object status, filtered exactly as the page subscribes. + function extruder(temp, target, nozzle) { + return { temperature: temp, target: target, power: 0.4, can_extrude: true, + pressure_advance: 0.02, smooth_time: 0.04, state: 'ready', nozzle_diameter: nozzle }; + } + const STATUS = { + extruder: extruder(221.4, 220, 0.4), + extruder1: extruder(28.9, 0, 0.4), + extruder2: extruder(29.4, 0, 0.4), + extruder3: extruder(28.1, 0, 0.4), + heater_bed: { temperature: 59.8, target: 60 }, + print_stats: { + filename: 'benchy_4colour.gcode', state: 'printing', + total_duration: 3182, print_duration: 2955, + filament_used: 4821.5, message: '', info: { current_layer: 84, total_layer: 210 } + }, + virtual_sdcard: { progress: 0.42, file_position: 8123456, is_active: true, + file_size: 19341122, file_path: '/data/gcodes/benchy_4colour.gcode' }, + display_status: { progress: 0.42, message: '' }, + gcode_move: { speed_factor: 1.0, speed: 7200, extrude_factor: 1.0 }, + webhooks: { state: 'ready', state_message: 'Printer is ready' }, + fan: { speed: 0.85 }, + 'fan_generic cavity_fan': { speed: 0.35 }, + 'led cavity_led': { color_data: [[1, 1, 1, 1]] }, + motion_report: { live_position: [110.2, 98.4, 16.8, 0], live_velocity: 62.1 }, + purifier: { power_detected: true, power_det_value: 1, mode: 1, exhaust_fan: 0.5, inner_fan: 0.4 }, + machine_state_manager: { main_state: 'working', action_code: 0 }, + filament_detect: { detected: true }, + 'filament_feed left': { state: 'idle' }, + 'filament_feed right': { state: 'idle' }, + toolhead: { position: [110.2, 98.4, 16.8, 0], homed_axes: 'xyz', + extruder: 'extruder', max_velocity: 500, max_accel: 10000 }, + print_task_config: { + filament_vendor: ['Snapmaker', 'Snapmaker', 'Snapmaker', 'Snapmaker'], + filament_type: ['PLA', 'PLA', 'PETG', 'PLA'], + filament_colour: ['#E4572E', '#17BEBB', '#FFC914', '#2E282A'], + filament_color: ['#E4572E', '#17BEBB', '#FFC914', '#2E282A'], + filament_color_rgba: ['#E4572EFF', '#17BEBBFF', '#FFC914FF', '#2E282AFF'], + extruder_map_table: [0, 1, 2, 3], extruders_used: [0, 1, 2, 3], + auto_bed_leveling: true, flow_calibrate: true, shaper_calibrate: false, + save: true, time_lapse_camera: true, auto_replenish_filament: false, + can_auto_replenish: true, filament_exist: [true, true, true, true] + }, + defect_detection: { enable: true, sensitivity: 1 }, + idle_timeout: { state: 'Printing', printing_time: 2955 } + }; + + window.__wcpFixtures = { + // Logging and telemetry: accept silently. + sw_FileLog: {}, sw_Log: {}, sw_UploadEvent: {}, sw_SetLogLevel: {}, + + sw_GetUserLoginState: USER, + sw_SubscribeUserLoginState: USER, + sw_GetUserUpdatePrivacy: { agree: true }, + sw_SubUserUpdatePrivacy: { agree: true }, + + sw_GetConnectedMachine: DEVICE, + sw_GetLocalDevices: { devices: [DEVICE] }, + sw_SubscribeLocalDevices: { devices: [DEVICE] }, + sw_GetPrinterInfo: { state: 'ready', hostname: 'snapmaker-u1', + software_version: 'v1.4.2', model: 'Snapmaker U1', sn: SN }, + sw_GetMachineSystemInfo: { cpu_info: { model: 'U1' }, distribution: { name: 'Snapmaker OS' } }, + sw_SystemGetDeviceInfo: { sn: SN, model: 'Snapmaker U1', firmware: 'v1.4.2' }, + sw_GetDeviceDataStorageSpace: { free_space: 5.2, total_space: 8.0, units: 'GB' }, + + sw_GetMachineObjects: { objects: Object.keys(STATUS) }, + sw_GetMachineState: { status: STATUS, eventtime: 12345.6 }, + sw_SubscribeMachineState: { status: STATUS, eventtime: 12345.6 }, + sw_SetSubscribeFilter: {}, + + sw_SubscribePageStateChange: { state: 'active' }, + sw_SubscribeRecentFiles: { files: [] }, + sw_GetRecentProjects: { projects: [] }, + sw_SubscribeCacheKey: function (p) { return { key: (p && p.key) || '', value: null }; }, + sw_GetCache: function (p) { return { key: (p && p.key) || '', value: null }; }, + sw_SetCache: {}, + sw_GetSoftwareInfo: { version: '2.3.6', name: 'Snapmaker Orca' }, + sw_GetActiveFile: { filename: 'benchy_4colour.gcode' }, + sw_MachineHeartbeat: { alive: true }, + sw_GetPrintLegal: { legal: true, preset_model: 'Snapmaker U1' }, + + // MQTT agent: the page drives the transport itself, so acknowledge each step. + sw_create_mqtt_client: { client_id: 'orca-sim' }, + sw_mqtt_connect: { connected: true }, + sw_mqtt_disconnect: { connected: false }, + sw_mqtt_subscribe: { subscribed: true }, + sw_mqtt_unsubscribe: { subscribed: false }, + sw_mqtt_publish: { published: true }, + sw_mqtt_set_engine: {}, + sw_Connect: DEVICE, + sw_Test_connect: { ok: true }, + sw_GetPincode: { pincode: '123456' }, + sw_StartMachineFind: { devices: [DEVICE] }, + sw_StopMachineFind: {}, + + // Print-processing surface. + sw_GetFileFilamentMapping: { + filename: 'benchy_4colour.gcode', + filaments: [ + { id: 0, type: 'PLA', colour: '#E4572E', color: '#E4572E', used_g: 12.4, extruder: 0 }, + { id: 1, type: 'PLA', colour: '#17BEBB', color: '#17BEBB', used_g: 9.1, extruder: 1 }, + { id: 2, type: 'PETG', colour: '#FFC914', color: '#FFC914', used_g: 6.7, extruder: 2 }, + { id: 3, type: 'PLA', colour: '#2E282A', color: '#2E282A', used_g: 3.2, extruder: 3 } + ], + prediction_time: 3182, weight: 31.4 + }, + sw_SetFilamentMappingComplete: {}, + sw_FinishFilamentMapping: {}, + sw_FinishPreprint: {}, + sw_GetPrintZip: { name: 'benchy_4colour.zip', content: '' }, + sw_StartLocalPrint: { task_id: 'sim-task-1' }, + sw_StartCloudPrint: { task_id: 'sim-task-1' }, + sw_MachineFilesRoots: { roots: [{ name: 'gcodes', path: '/data/gcodes' }] }, + sw_GetFileListPage: { files: [], total: 0 }, + sw_UpdateMachineFilamentInfo: {} + }; + + // Repeat pushes for the machine-state subscription so the UI animates/settles. + window.__wcpPush = { + sw_SubscribeMachineState: [{ status: STATUS, eventtime: 12346.6 }] + }; +})(); diff --git a/docs/u1-webui/tools/harness/shoot.py b/docs/u1-webui/tools/harness/shoot.py new file mode 100644 index 00000000000..5cd72245ac6 --- /dev/null +++ b/docs/u1-webui/tools/harness/shoot.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Drive the harness: load a route, settle, optionally click, screenshot, pull the record. + +usage: shoot.py NAME PATH [WAIT] [WxH] [click=x,y[;x,y] ...] [post=SECONDS] +""" +import json, os, sys, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import cdp + +SP = os.path.dirname(os.path.abspath(__file__)) +CH = os.path.expanduser("~/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome") +BASE = "http://127.0.0.1:8765/web/flutter_web/index.html" + +args = sys.argv[1:] +name, path = args[0], args[1] +wait = float(args[2]) if len(args) > 2 and not args[2].startswith(("click", "post")) else 12 +size = next((a for a in args if "x" in a and a.replace("x", "").isdigit()), "1280x900") +clicks = next((a.split("=", 1)[1] for a in args if a.startswith("click=")), "") +post = float(next((a.split("=", 1)[1] for a in args if a.startswith("post=")), 2)) +w, h = (int(x) for x in size.split("x")) + +env = dict(os.environ, LD_LIBRARY_PATH=os.path.join(SP, "libs")) +c = cdp.Chrome(CH, extra=[f"--window-size={w},{h}"], env=env) +try: + c.open(f"{BASE}?path={path}&dump={name}") + c.call("Emulation.setDeviceMetricsOverride", width=w, height=h, + deviceScaleFactor=1, mobile=False) + time.sleep(wait) + + for pt in [p for p in clicks.split(";") if p.strip()]: + x, y = (int(v) for v in pt.split(",")) + for typ in ("mousePressed", "mouseReleased"): + c.call("Input.dispatchMouseEvent", type=typ, x=x, y=y, + button="left", clickCount=1) + time.sleep(post) + + os.makedirs(os.path.join(SP, "shots"), exist_ok=True) + p = os.path.join(SP, "shots", f"{name}.png") + size_b = c.shot(p) + rec = c.eval("JSON.stringify({record: window.__wcp ? window.__wcp.record : []," + " replies: window.__wcp ? window.__wcp.replies : 0," + " unhandled: window.__wcp ? Array.from(new Set(window.__wcp.unhandled)) : []})") + d = json.loads(rec) if rec else {"record": [], "replies": 0, "unhandled": []} + os.makedirs(os.path.join(SP, "dumps"), exist_ok=True) + with open(os.path.join(SP, "dumps", f"{name}.json"), "w") as f: + json.dump(d, f, indent=1) + print(f"{name}: {size_b:,} B png | {len(d['record'])} req | {d['replies']} rep | " + f"unhandled: {d['unhandled']}") +finally: + c.close() diff --git a/docs/u1-webui/tools/harness/wcp-bridge.js b/docs/u1-webui/tools/harness/wcp-bridge.js new file mode 100644 index 00000000000..0ce04a6ef3e --- /dev/null +++ b/docs/u1-webui/tools/harness/wcp-bridge.js @@ -0,0 +1,73 @@ +// Recording + replaying stand-in for the Orca C++ host. +// +// The page reaches its host through exactly one channel: +// window.wx.postMessage(JSON.stringify({header, payload:{cmd, params, event_id}})) +// and the host answers on exactly one channel: +// window.postMessage(JSON.stringify({header, payload:{code, message, data}}), '*') +// Both shapes are read straight out of SSWCP.cpp (handle_web_message / send_to_js). +(function () { + const RECORD = []; + window.__wcp = { record: RECORD, replies: 0, unhandled: [] }; + + // Canned data, keyed by command. Populated by inject-state.js when present. + window.__wcpFixtures = window.__wcpFixtures || {}; + + function reply(header, data, code, message) { + const msg = JSON.stringify({ + header: header || {}, + payload: { code: code === undefined ? 200 : code, message: message || 'success', data: data || {} } + }); + // Same delivery the host uses: a window message the page is already listening for. + window.postMessage(msg, '*'); + window.__wcp.replies++; + } + + window.wx = { + postMessage: function (raw) { + let m; + try { m = JSON.parse(raw); } catch (e) { RECORD.push({ parse_error: String(e), raw: String(raw).slice(0, 400) }); return; } + const p = m.payload || {}; + RECORD.push({ t: Date.now(), header: m.header, cmd: p.cmd, params: p.params, event_id: p.event_id || null }); + + const fx = window.__wcpFixtures[p.cmd]; + if (fx === undefined) { window.__wcp.unhandled.push(p.cmd); return; } + + // A fixture may be a value, or a function of (params, header, event_id). + const data = (typeof fx === 'function') ? fx(p.params || {}, m.header || {}, p.event_id) : fx; + if (data === null) return; // explicit "stay silent" + + // Subscriptions echo event_id in the header and may push repeatedly. + const header = Object.assign({}, m.header || {}); + if (p.event_id) header.event_id = p.event_id; + reply(header, data); + + if (p.event_id && window.__wcpPush && window.__wcpPush[p.cmd]) { + const frames = window.__wcpPush[p.cmd]; + frames.forEach(function (f, i) { + setTimeout(function () { reply(header, f); }, 300 * (i + 1)); + }); + } + } + }; + + // index.html's own helper expects this to exist. + window.sendMessage = function (message) { window.wx.postMessage(message); }; +})(); + +// Ship the recording back to the harness server so a headless run can inspect it. +(function () { + const name = new URLSearchParams(location.search).get('dump') || 'dump'; + function ship() { + try { + navigator.sendBeacon('/wcp-dump?name=' + encodeURIComponent(name), + new Blob([JSON.stringify({ + url: location.href, + record: window.__wcp.record, + replies: window.__wcp.replies, + unhandled: Array.from(new Set(window.__wcp.unhandled)) + }, null, 1)], { type: 'application/json' })); + } catch (e) { } + } + setTimeout(ship, 6000); + setTimeout(ship, 14000); +})(); diff --git a/docs/u1-webui/tools/map_sswcp.py b/docs/u1-webui/tools/map_sswcp.py new file mode 100644 index 00000000000..0d81eff434f --- /dev/null +++ b/docs/u1-webui/tools/map_sswcp.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Map sw_* commands -> C++ handler -> param/response keys from SSWCP.cpp. + +Careful with the dispatch chain: SSWCP.hpp defines 15 commands as macros +(`#define GET_DEVICEDATA_STORAGESPACE "sw_GetDeviceDataStorageSpace"`) and the +.cpp compares against the macro, not the literal. Matching only `m_cmd == "sw_*"` +therefore drops those commands from the map and makes them look unimplemented. + +Usage: map_sswcp.py +""" +import re, json, os, sys, collections + +# Both arguments are required. Defaulting the output to sys.argv[-1] means a bare +# `python3 map_sswcp.py` writes the JSON over THIS FILE - which is exactly what +# happened once. Fail loudly instead. +if len(sys.argv) != 3: + sys.exit(f"usage: {sys.argv[0]} \n" + f" (run via tools/run_all.py, which passes both)") + +ROOT = sys.argv[1] +OUT = sys.argv[2] +if os.path.abspath(OUT) == os.path.abspath(__file__): + sys.exit("refusing to write output over the script itself") +SRC = f"{ROOT}/src/slic3r/GUI/SSWCP.cpp" +lines = open(SRC, encoding='utf-8', errors='replace').read().split('\n') +text = '\n'.join(lines) + +# 1) dispatch: `if/else if (m_cmd == "sw_X") { }` +disp = {} +# name -> "sw_*" for every macro the header defines +try: + _hpp = open(SRC[:-4] + ".hpp", encoding="utf-8", errors="replace").read() +except OSError: + _hpp = "" +MACROS = dict(re.findall(r'^#define\s+(\w+)\s+"(sw_[A-Za-z0-9_]+)"', _hpp, re.M)) + +# `m_cmd == "sw_Foo"` or `m_cmd == SOME_MACRO` +cmd_re = re.compile(r'm_cmd\s*==\s*(?:"(sw_[A-Za-z0-9_]+)"|([A-Z][A-Z0-9_]{2,}))') +for i, ln in enumerate(lines): + for m in cmd_re.finditer(ln): + cmd = m.group(1) or MACROS.get(m.group(2)) + if not cmd: + continue + # look ahead up to 6 lines for a call like this->foo( / foo( + body = [] + for j in range(i, min(i+7, len(lines))): + body.append(lines[j]) + if j > i and cmd_re.search(lines[j]): + body.pop(); break + blob = ' '.join(body) + calls = re.findall(r'(?:this->)?([a-zA-Z_][a-zA-Z0-9_]*)\s*\(', blob) + skip = {'if','else','while','for','switch','get','count','json','std','return','sizeof'} + calls = [c for c in calls if c not in skip] + disp.setdefault(cmd, set()).update(calls) + +# 2) handler bodies: find `RetType Class::method(args) {` and capture until matching brace +fn_re = re.compile(r'^[A-Za-z_][\w:<>,\s\*&]*\b(SSWCP\w*)::(\w+)\s*\([^;{]*\)\s*(?:const\s*)?\{', re.M) +bodies = {} +for m in fn_re.finditer(text): + cls, meth = m.group(1), m.group(2) + start = m.end()-1 + depth, k = 0, start + while k < len(text): + if text[k] == '{': depth += 1 + elif text[k] == '}': + depth -= 1 + if depth == 0: break + k += 1 + bodies.setdefault(meth, []).append((cls, text[start:k])) + +def keys_in(body, var): + ks = set() + ks.update(re.findall(re.escape(var) + r'\["([^"]+)"\]', body)) + ks.update(re.findall(re.escape(var) + r'\.count\("([^"]+)"\)', body)) + ks.update(re.findall(re.escape(var) + r'\.contains\("([^"]+)"\)', body)) + return ks + +out = {} +for cmd, calls in sorted(disp.items()): + entry = {"handlers": [], "params": set(), "response": set(), "rpc": set()} + for c in sorted(calls): + if c in bodies: + entry["handlers"].append(c) + for cls, body in bodies[c]: + entry["params"] |= keys_in(body, "m_param_data") + entry["response"] |= keys_in(body, "m_res_data") + entry["rpc"] |= set(re.findall(r'"((?:printer|server|machine|access)\.[a-z_.]+)"', body)) + out[cmd] = {"handlers": entry["handlers"], + "params": sorted(entry["params"]), + "response": sorted(entry["response"]), + "rpc": sorted(entry["rpc"])} + +json.dump(out, open(OUT, 'w'), indent=1) +n_h = sum(1 for v in out.values() if v["handlers"]) +print(f"commands in dispatch: {len(out)}; with resolved handler: {n_h}") +print(f"with rpc method: {sum(1 for v in out.values() if v['rpc'])}") diff --git a/docs/u1-webui/tools/mqtt_min.py b/docs/u1-webui/tools/mqtt_min.py new file mode 100644 index 00000000000..68c6f03d527 --- /dev/null +++ b/docs/u1-webui/tools/mqtt_min.py @@ -0,0 +1,204 @@ +"""A minimal MQTT 3.1.1 client, enough to talk to a U1. + +paho-mqtt is not available here and there is no pip, so this implements the four +packet types the connect path needs: CONNECT, SUBSCRIBE, PUBLISH and PINGREQ. It is +deliberately small and synchronous - a probe, not a library. + +Both transports the U1 uses are supported: plain TCP for the :1884 authorisation leg +and mTLS for the :8883 session. +""" + +import json +import socket +import ssl +import struct +import time + +CONNECT, CONNACK, PUBLISH, PUBACK = 1, 2, 3, 4 +SUBSCRIBE, SUBACK, PINGREQ, PINGRESP, DISCONNECT = 8, 9, 12, 13, 14 + +CONNACK_ERRORS = { + 1: "unacceptable protocol version", + 2: "identifier rejected", + 3: "server unavailable", + 4: "bad username or password", + 5: "not authorised", +} + + +def _rem_len(n): + """MQTT's variable byte integer.""" + out = bytearray() + while True: + b = n % 128 + n //= 128 + if n: + b |= 0x80 + out.append(b) + if not n: + return bytes(out) + + +def _str(s): + b = s.encode("utf-8") + return struct.pack("!H", len(b)) + b + + +class MqttError(Exception): + pass + + +class MqttClient: + """One connection. Not thread-safe; the probe drives it from a single loop.""" + + def __init__(self, host, port, client_id, tls=None, keepalive=30): + self.host, self.port, self.client_id = host, port, client_id + self.tls, self.keepalive = tls, keepalive + self.sock = None + self.buf = b"" + self.pid = 0 + self.last_sent = 0.0 + + # -- wire ------------------------------------------------------------ + + def _next_pid(self): + self.pid = self.pid % 65535 + 1 + return self.pid + + def _send(self, ptype, flags, payload): + pkt = bytes([ptype << 4 | flags]) + _rem_len(len(payload)) + payload + self.sock.sendall(pkt) + self.last_sent = time.time() + + def _recv_packet(self, timeout): + """Return (type, flags, body) or None on timeout.""" + deadline = time.time() + timeout + while True: + # a complete packet may already be buffered + if len(self.buf) >= 2: + n, mult, i = 0, 1, 1 + while True: + if i >= len(self.buf): + i = None + break + b = self.buf[i] + n += (b & 0x7F) * mult + i += 1 + if not b & 0x80: + break + mult *= 128 + if mult > 128 ** 3: + raise MqttError("malformed remaining length") + if i is not None and len(self.buf) >= i + n: + head = self.buf[0] + body = self.buf[i:i + n] + self.buf = self.buf[i + n:] + return head >> 4, head & 0x0F, body + + left = deadline - time.time() + if left <= 0: + return None + self.sock.settimeout(min(left, 1.0)) + try: + chunk = self.sock.recv(65536) + except socket.timeout: + self._maybe_ping() + continue + except ssl.SSLWantReadError: + continue + if not chunk: + raise MqttError("connection closed by peer") + self.buf += chunk + + def _maybe_ping(self): + if self.keepalive and time.time() - self.last_sent > self.keepalive / 2: + self._send(PINGREQ, 0, b"") + + # -- session --------------------------------------------------------- + + def connect(self, clean_session=False, timeout=15): + raw = socket.create_connection((self.host, self.port), timeout=timeout) + self.sock = self.tls.wrap_socket(raw, server_hostname=self.host) if self.tls else raw + + flags = 0x02 if clean_session else 0x00 + payload = (_str("MQTT") + bytes([4, flags]) + struct.pack("!H", self.keepalive) + + _str(self.client_id)) + self._send(CONNECT, 0, payload) + + pkt = self._recv_packet(timeout) + if not pkt: + raise MqttError("no CONNACK") + ptype, _, body = pkt + if ptype != CONNACK or len(body) < 2: + raise MqttError(f"expected CONNACK, got packet type {ptype}") + if body[1]: + raise MqttError(f"CONNACK refused: {CONNACK_ERRORS.get(body[1], body[1])}") + return self + + def subscribe(self, topic, qos=1, timeout=10): + pid = self._next_pid() + self._send(SUBSCRIBE, 0x02, struct.pack("!H", pid) + _str(topic) + bytes([qos])) + deadline = time.time() + timeout + held = [] + while time.time() < deadline: + pkt = self._recv_packet(deadline - time.time()) + if not pkt: + break + if pkt[0] == SUBACK: + for h in held: # keep anything that raced in + self._stash(h) + if pkt[2][2:3] == b"\x80": + raise MqttError(f"subscribe refused: {topic}") + return True + held.append(pkt) + raise MqttError(f"no SUBACK for {topic}") + + _pending = None + + def _stash(self, pkt): + if self._pending is None: + self._pending = [] + self._pending.append(pkt) + + def publish(self, topic, payload, qos=1): + if isinstance(payload, (dict, list)): + payload = json.dumps(payload) + if isinstance(payload, str): + payload = payload.encode("utf-8") + head = _str(topic) + if qos: + head += struct.pack("!H", self._next_pid()) + self._send(PUBLISH, qos << 1, head + payload) + + def messages(self, timeout): + """Yield (topic, payload_bytes) until timeout elapses.""" + deadline = time.time() + timeout + while time.time() < deadline: + pkt = None + if self._pending: + pkt = self._pending.pop(0) + else: + pkt = self._recv_packet(deadline - time.time()) + if not pkt: + return + ptype, flags, body = pkt + if ptype != PUBLISH: + continue + tlen = struct.unpack("!H", body[:2])[0] + topic = body[2:2 + tlen].decode("utf-8", "replace") + rest = body[2 + tlen:] + qos = (flags >> 1) & 0x03 + if qos: + pid = struct.unpack("!H", rest[:2])[0] + rest = rest[2:] + self._send(PUBACK, 0, struct.pack("!H", pid)) + yield topic, rest + + def close(self): + try: + if self.sock: + self._send(DISCONNECT, 0, b"") + self.sock.close() + except Exception: + pass + self.sock = None diff --git a/docs/u1-webui/tools/run_all.py b/docs/u1-webui/tools/run_all.py new file mode 100644 index 00000000000..48508964065 --- /dev/null +++ b/docs/u1-webui/tools/run_all.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Regenerate every artefact under ../data/ and ../reconstructed/. + + python3 docs/u1-webui/tools/run_all.py + +Each step is independent; a failing step reports and the rest continue, so a +bundle update that breaks one extractor still refreshes the others. +""" +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from _common import MAIN_JS, DATA, RECON # noqa: E402 + +# (script, [args]) - scripts with no args write their own output paths. +I10N = os.path.join(os.path.dirname(MAIN_JS), "assets", "assets", "i10n") +REPO = os.path.abspath(os.path.join(os.path.dirname(HERE), "..", "..")) + +STEPS = [ + # --- extractors: bundle/C++ -> data/ --- + ("extract_routes.py", []), + ("extract_enums.py", []), + ("extract_wcp_commands.py", []), + ("extract_errors.py", []), + ("extract_rpc.py", [MAIN_JS, os.path.join(DATA, "jsonrpc-methods.json")]), + ("extract_state_model.py", [MAIN_JS, os.path.join(DATA, "state-model.json")]), + ("extract_dart_classes.py", [MAIN_JS, os.path.join(DATA, "dart-classes.json")]), + ("extract_wire_enums.py", [MAIN_JS, os.path.join(DATA, "wire-enums.json")]), + ("extract_error_catalog.py", [I10N, os.path.join(DATA, "error-catalog.json")]), + ("extract_strings.py", [MAIN_JS, os.path.join(DATA, "bundle-strings.txt")]), + # NB: this one writes to argv[-1] - without an explicit output path it + # would take its own path as the destination and overwrite itself. + ("map_sswcp.py", [REPO, os.path.join(DATA, "sswcp-commands.json")]), + # which printer method each bridge command becomes - what u1_bridge.py runs on + ("extract_bridge_methods.py", []), + # --- generators: data/ -> markdown --- + ("gen_command_catalogue.py", []), + ("gen_command_reference.py", [REPO]), + ("gen_legacy_error_doc.py", []), + ("gen_jsonrpc_reference.py", []), + ("extract_activity.py", []), + ("gen_activity_module.py", []), + ("gen_error_module.py", []), + ("check_coverage.py", ["--quiet"]), +] + + +def main(): + os.makedirs(DATA, exist_ok=True) + os.makedirs(RECON, exist_ok=True) + if not os.path.exists(MAIN_JS): + sys.exit(f"bundle not found: {MAIN_JS}") + + failed = [] + for script, args in STEPS: + print(f"\n=== {script} " + "=" * (58 - len(script))) + r = subprocess.run([sys.executable, os.path.join(HERE, script)] + args) + if r.returncode != 0: + failed.append(script) + + print("\n" + "=" * 66) + if failed: + print("FAILED: " + ", ".join(failed)) + return 1 + print("all extractors succeeded") + for d in (DATA, RECON): + print(f"\n{os.path.relpath(d, os.path.dirname(HERE))}/") + for n in sorted(os.listdir(d)): + size = os.path.getsize(os.path.join(d, n)) + print(f" {size:>9,} {n}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/u1-webui/tools/u1_bridge.py b/docs/u1-webui/tools/u1_bridge.py new file mode 100644 index 00000000000..16b3b3ce267 --- /dev/null +++ b/docs/u1-webui/tools/u1_bridge.py @@ -0,0 +1,701 @@ +#!/usr/bin/env python3 +"""Answer the page's bridge commands by talking to a real U1. No Orca. + +The Device page speaks to Orca over SSWCP - `window.wx.postMessage` out, a `message` +event back - and Orca turns most of those commands into one JSON-RPC call to the +printer. This is that middle layer, in Python: give it a `send` callback and hand it +the packets the page posts, and the page runs against real hardware. + + from u1_bridge import Bridge + b = Bridge(send=lambda msg: post_to_page(msg)) + b.handle(text_from_window_wx) + +Three kinds of command, and the split is not a design choice - it is what Orca does: + + transport sw_create_mqtt_client / connect / subscribe / publish / disconnect / + set_engine. THE PAGE drives the connection itself, including the key + exchange; Orca only owns the socket. So does this. See connection.js. + printer 37 of the commands the page issues are one JSON-RPC method with the + parameters passed nearly straight through. That mapping is read out of + the C++ by extract_bridge_methods.py, not written down here. + local the device book, the login state, discovery. Answered from Orca's own + config file where that is honest, and refused in as many words where + it is not. + +**Orca must not be running.** It authenticates with the same saved `clientId`, and a +broker evicts the older holder of a duplicate id - so the two fight, and neither wins. + +**This does not test Orca's bridge.** It is a second host speaking the same contract. +It proves the page and the printer agree; whether ORCA agrees is a different question +and still needs the app. See STATUS.md, "What to pick up next", item 1. +""" +import glob +import json +import socket +import os +import queue +import ssl +import sys +import tempfile +import threading +import time +from concurrent.futures import Future + +HERE = os.path.dirname(os.path.abspath(__file__)) +DATA = os.path.join(os.path.dirname(HERE), "data") +sys.path.insert(0, HERE) +from mqtt_min import MqttClient, MqttError # noqa: E402 + +OK = 200 # Orca's success code. 200, not 0 - see sswcp.js. + +# Commands Orca answers out of its own state, which this host will not pretend to. +# Each is refused in as many words: to the page, a silent refusal and an unimplemented +# command look identical, and the whole point of running outside Orca is to be able to +# tell which is which. Anything added here has to say why, the same rule check_coverage +# holds the reconstruction to. +REFUSED = { + "sw_AddDevice": "adds a printer to Orca's config; this host only reads it", + "sw_DeleteDevices": "removes a printer from Orca's config; this host only reads it", + "sw_RenameDevice": "renames a printer in Orca's config; this host only reads it", + "sw_ConnectOtherMachine": "switches Orca's connected device; there is no such " + "state here - the page picks its own", + "sw_UpdateMachineFilamentInfo": "writes Orca's per-device filament record", + "sw_DownloadMachineFile": "saves through Orca's download manager. The file is " + "reachable directly at http://:7125/server/files/", + "sw_StartMachineFind": "Orca's own Bonjour sweep (SSWCP.cpp:1789), not a printer " + "command - nothing on MQTT can answer it", + "sw_StopMachineFind": "the other half of Orca's Bonjour sweep", +} +# What Orca waits for a printer reply: add_response_target's default, MoonRaker.hpp:344. +# NOT the client's 15s (sswcp.js) - that is the page giving up on the BRIDGE, and the +# two must not be confused. Guessing 12s here answered before the page's own clock did, +# and turned a 31s toolchange that was working into "the printer refused the command". +RPC_TIMEOUT = 80.0 +POLL = 0.1 # how long a client thread blocks reading, per turn + + +def _log(msg): + # Flushed: the interesting use of this is watching a live session scroll past, and + # a buffered pipe holds the whole conversation back until the process ends. + print(msg, flush=True) + + +# --------------------------------------------------------------------------- config + +def reachable(ip, port, timeout=1.5): + """Can this host open a socket to the machine? Cheap, and it is real evidence.""" + try: + socket.create_connection((ip, int(port or 8883)), timeout=timeout).close() + return True + except OSError: + return False + + +def orca_devices(): + """Every device Orca has saved. ca/cert/key are always blank there by design.""" + for path in sorted(glob.glob(os.path.expanduser("~/.config/Snapmaker_Orca/*.conf"))): + try: + cfg = json.load(open(path, encoding="utf-8")) + except Exception: + continue + devs = [d for d in cfg.get("devices", []) if d.get("ip") and d.get("sn")] + if devs: + return devs + return [] + + +# ------------------------------------------------------------------------ transport + +class Client(threading.Thread): + """One MQTT connection, with every socket operation on this one thread. + + mqtt_min is not thread-safe and does not pretend to be: `subscribe` reads packets + until its SUBACK arrives, so a reader running concurrently would eat it. Rather + than lock around that, all work is queued to the thread that owns the socket. + """ + + def __init__(self, cid, host, port, client_id, tls, on_message, log): + super().__init__(daemon=True, name=f"mqtt-{cid}") + self.cid, self.host, self.port = cid, host, port + self.client_id, self.tls = client_id, tls + self.on_message, self.log = on_message, log + self.cli = None + self.q = queue.Queue() + self.alive = True + + def submit(self, fn): + """Run fn(cli) on this client's thread. Returns a Future.""" + f = Future() + self.q.put((fn, f)) + return f + + def _drain(self): + while True: + try: + fn, f = self.q.get_nowait() + except queue.Empty: + return + try: + f.set_result(fn(self.cli)) + except Exception as e: # noqa: BLE001 + f.set_exception(e) + + def run(self): + while self.alive: + self._drain() + if self.cli is None: + time.sleep(0.02) + continue + try: + for topic, raw in self.cli.messages(POLL): + try: + self.on_message(self.cid, topic, raw) + except Exception as e: # noqa: BLE001 + self.log(f"[bridge] push handler: {e}") + except MqttError as e: + self.log(f"[bridge] {self.cid}: {e}") + self.alive = False + except Exception as e: # noqa: BLE001 + self.log(f"[bridge] {self.cid}: {e}") + self.alive = False + self._shut() + + def _shut(self): + cli, self.cli = self.cli, None + if cli: + try: + cli.close() + except Exception: # noqa: BLE001 + pass + + def open(self): + self.cli = MqttClient(self.host, self.port, self.client_id, tls=self.tls).connect() + return {} + + def stop(self): + """Signal, and let the thread close its own socket. + + Closing it from here yanks the descriptor out from under a `messages()` call + that is mid-read, which surfaces as a spurious "Bad file descriptor" on every + ordinary disconnect. + """ + self.alive = False + if not self.is_alive(): + self._shut() + + +def tls_context(ca, cert, key, tmp): + """An SSLContext from PEM text. load_cert_chain wants files, so files it gets.""" + def write(text, suffix): + fd, path = tempfile.mkstemp(suffix=suffix) + with os.fdopen(fd, "w") as f: + f.write(text) + tmp.append(path) + return path + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE # the printer's cert names an internal host + if ca: + ctx.load_verify_locations(write(ca, ".ca.pem")) + if cert and key: + ctx.load_cert_chain(write(cert, ".crt.pem"), write(key, ".key.pem")) + return ctx + + +# ----------------------------------------------------------------------------- host + +class Bridge: + def __init__(self, send, log=_log, devices=None, trace=120): + # send(msg, as_string=True) - the WCP envelope travels as a JSON string, which + # is what send_to_js posts; the legacy `command` messages travel as an object. + self.send, self.log = send, log + self.trace = trace # how much of each command's params to log + self.devices = devices if devices is not None else orca_devices() + self.clients = {} # id -> Client + self.subs = {} # (client id, topic) -> [event_id] + self.status_events = [] # from sw_SubscribeMachineState + self.cache = {} # the page's own key/value store; Orca only holds it + self.cache_subs = {} # key -> [event_id] + self.pending = {} # rpc id -> (seqid, deadline, cmd) + self.engine = None + self.sn = None + self._n = 0 + self._rpc = int(time.time()) % 100000 + self._tmp = [] + self._lock = threading.Lock() + self.table = json.load(open(os.path.join(DATA, "bridge-methods.json"), + encoding="utf-8")) + self.local = { + "sw_create_mqtt_client": self._create, + "sw_mqtt_connect": self._connect, + "sw_mqtt_disconnect": self._disconnect, + "sw_mqtt_subscribe": self._subscribe, + "sw_mqtt_publish": self._publish, + "sw_mqtt_set_engine": self._set_engine, + "sw_SubscribeMachineState": self._sub_state, + "sw_StopMachineStateSubscription": self._unsub_state, + "sw_UnSubscribeMachineState": self._unsub_state, + "sw_GetLocalDevices": self._get_devices, + "sw_SubscribeLocalDevices": self._get_devices, + "sw_GetConnectedMachine": self._connected_machine, + "sw_GetUserLoginState": self._login_state, + "sw_FileLog": self._file_log, + "sw_SetLogLevel": lambda s, p, e: self._ok(s, {}), + # --- what the SHIPPED bundle asks for and the reconstruction never did. + # All Orca-side facilities rather than printer commands: subscription + # registrations it pushes to later, a key/value store the page owns and + # Orca only holds, and a telemetry sink. + "sw_Connect": self._connect_device, + "sw_Disconnect": self._disconnect_device, + "sw_SubscribePageStateChange": self._ack_sub, + "sw_UnsubscribePageStateChange": self._ack_sub, + "sw_SubscribeUserLoginState": self._sub_login, + "sw_SubscribeRecentFiles": self._sub_recent, + "sw_GetRecentProjects": lambda s, p, e: self._ok(s, []), + "sw_SubUserUpdatePrivacy": self._ack_sub, + "sw_GetUserUpdatePrivacy": lambda s, p, e: self._ok(s, {"status": 1}), + "sw_UploadEvent": lambda s, p, e: self._ok(s, {}), + "sw_SubscribeCacheKey": self._sub_cache, + "sw_UnsubscribeCacheKeys": self._ack_sub, + "sw_GetCache": self._get_cache, + "sw_SetCache": self._set_cache, + "sw_RemoveCache": self._remove_cache, + # Orca answers this about itself, so the honest answer here is us. + "sw_GetSoftwareInfo": lambda s, p, e: self._ok( + s, {"version": "u1_bridge", "build_number": ""}), + } + # Before anything is listening: this opens sockets, and doing it from a + # command handler would block the main loop the replies travel on - which the + # page reads as a timeout, having waited all of 3 seconds. + self.probe_devices() + threading.Thread(target=self._expire, daemon=True).start() + + # ---- to the page ---------------------------------------------------- + + def _ok(self, seqid, data=None): + self.send({"header": {"seqid": str(seqid)}, + "payload": {"code": OK, "message": "", "data": {} if data is None else data}}) + + def _err(self, seqid, code, message): + self.log(f"[bridge] -> {message}") + self.send({"header": {"seqid": str(seqid)}, + "payload": {"code": code, "message": message, "data": None}}) + + def _push(self, event_id, data): + # A push carries event_id and NO seqid; the client keys on exactly that. + self.send({"header": {"event_id": event_id}, + "payload": {"code": OK, "message": "", "data": data}}) + + def _settle(self, future, seqid, shape=lambda r: {}): + def done(f): + try: + self._ok(seqid, shape(f.result())) + except Exception as e: # noqa: BLE001 + self._err(seqid, -1, str(e)) + future.add_done_callback(done) + + # ---- from the page -------------------------------------------------- + + def handle(self, text): + try: + packet = json.loads(text) + seqid = packet["header"]["seqid"] + p = packet["payload"] + cmd = p["cmd"] + except Exception as e: # noqa: BLE001 + self.log(f"[bridge] unparseable packet: {e}") + return + params = p.get("params") or {} + event_id = p.get("event_id") + # sw_FileLog travels over the bridge being watched; logging it is a feedback loop + if cmd != "sw_FileLog": + self.log(f"[bridge] {cmd} {json.dumps(params)[:self.trace]}") + + fn = self.local.get(cmd) + if fn: + try: + fn(seqid, params, event_id) + except Exception as e: # noqa: BLE001 + self._err(seqid, -1, f"{cmd}: {e}") + return + + if cmd in REFUSED: + return self._err(seqid, -1, f"{cmd}: {REFUSED[cmd]}") + + entry = self.table.get(cmd) + if entry and entry.get("method"): + self._call(seqid, cmd, entry, params) + return + why = (entry or {}).get("why", "not dispatched by Orca either") + self._err(seqid, -1, f"{cmd} is answered inside Orca, not by the printer ({why})") + + # ---- transport ------------------------------------------------------ + + def _create(self, seqid, params, event_id): + addr = params.get("server_address") or "" + _, _, rest = addr.partition("://") + host, _, port = rest.partition(":") + ca, cert, key = (params.get(k) or "" for k in ("ca", "cert", "key")) + # TLS is decided by the CREDENTIALS, not the scheme - `if (ca != "" && cert != + # "" && key != "")` in sw_create_mqtt_client. The shipped bundle asks for + # `mqtt://:8883`, plain scheme on the TLS port, and expects a TLS client + # back; reading the scheme instead opened a bare socket against a TLS listener. + secure = bool(ca and cert and key) + tls = tls_context(ca, cert, key, self._tmp) if secure else None + self._n += 1 + cid = f"c{self._n}" + client_id = params.get("clientId") or params.get("clientid") or f"orca-{cid}" + self.clients[cid] = Client(cid, host, int(port or (8883 if secure else 1883)), + client_id, tls, self._on_mqtt, self.log) + self.log(f"[bridge] client {cid} = {host}:{port} " + f"{'TLS' if secure else 'plain'} as {client_id}") + self._ok(seqid, {"type": "mqtt", "id": cid}) + + def _client(self, params): + c = self.clients.get(params.get("id")) + if not c: + raise KeyError(f"unknown client id {params.get('id')!r}") + return c + + def _connect(self, seqid, params, event_id): + c = self._client(params) + if not c.is_alive(): + c.start() + self._settle(c.submit(lambda _: c.open()), seqid) + + def _disconnect(self, seqid, params, event_id): + c = self.clients.pop(params.get("id"), None) + if c: + c.stop() + self._ok(seqid, {}) + + def _subscribe(self, seqid, params, event_id): + c = self._client(params) + topic, qos = params["topic"], int(params.get("qos", 1)) + if event_id: + self.subs.setdefault((c.cid, topic), []).append(event_id) + self._settle(c.submit(lambda cli: cli.subscribe(topic, qos)), + seqid, lambda r: {"topic": topic}) + + def _publish(self, seqid, params, event_id): + c = self._client(params) + topic, payload = params["topic"], params.get("payload", "") + qos = int(params.get("qos", 1)) + self._settle(c.submit(lambda cli: cli.publish(topic, payload, qos)), seqid) + + def _set_engine(self, seqid, params, event_id): + # SSWCP.cpp:6643 hardcodes `bool res = true;` - Orca connects nothing here and + # expects an already-connected engine. Matching that exactly, including the + # part where it cannot fail. + self.engine = self.clients.get(params.get("engine_id")) + self.sn = params.get("sn") + self.log(f"[bridge] engine = {params.get('engine_id')} sn = {self.sn}") + self._ok(seqid, {"result": True}) + + # ---- machine state -------------------------------------------------- + + def _sub_state(self, seqid, params, event_id): + if event_id and event_id not in self.status_events: + self.status_events.append(event_id) + self._ok(seqid, {}) + + def _unsub_state(self, seqid, params, event_id): + self.status_events.clear() + self._ok(seqid, {}) + + # ---- a session of the host's own ------------------------------------- + + def self_connect(self): + """Bring up an mTLS session this host owns, and attach it as the engine. + + Orca keeps its own connection to the printer - `get_connect_host()` - which is + what answers sw_GetMachineState and every other printer command. It is NOT the + MQTT clients the page creates: the shipped bundle makes its own and never calls + sw_mqtt_set_engine, because in Orca there is already a host attached. + + The reconstruction hands one over instead, which is why this was never needed + until the bundle ran. Same connect path either way, just driven from here: + LAN auth on :1884 for keys, then mTLS on the port the printer names. + """ + dev = next((d for d in self.devices if d.get("connected")), None) + if not dev or self.engine: + return + ip, sn = dev.get("ip"), dev.get("sn") + client_id = dev.get("clientId") or f"orca-{sn}" + try: + sys.path.insert(0, HERE) + from u1_probe import Session # noqa: PLC0415 + sess = Session(ip, sn, client_id, verbose=False) + self.log(f"[bridge] host session: asking {ip} for keys") + auth = sess.request_keys() + port = int(auth.get("port") or 8883) + + self._n += 1 + cid = f"h{self._n}" + tls = tls_context(auth.get("ca"), auth.get("cert"), auth.get("key"), + self._tmp) + c = Client(cid, ip, port, client_id, tls, self._on_mqtt, self.log) + self.clients[cid] = c + c.start() + c.submit(lambda _: c.open()).result(20) + for suffix in ("status", "response", "notification"): + c.submit(lambda cli, t=f"{sn}/{suffix}": cli.subscribe(t, 1)).result(10) + self.sn = sn + self.engine = c + self.log(f"[bridge] host session up on {ip}:{port} as engine {cid}") + except Exception as e: # noqa: BLE001 + self.log(f"[bridge] host session failed: {e}") + + # ---- what the shipped bundle needs ---------------------------------- + + def _disconnect_device(self, seqid, params, event_id): + """Orca drops its own host here. The page calls it before making its own.""" + self.log(f"[bridge] host session released ({params.get('dev_id')})") + self._ok(seqid, {}) + + def _connect_device(self, seqid, params, event_id): + threading.Thread(target=self.self_connect, daemon=True).start() + self._ok(seqid, {}) + + + def _ack_sub(self, seqid, params, event_id): + """A subscription Orca registers and pushes to later. Registering is the whole + contract; a page that never gets a push is a page nothing changed for.""" + self._ok(seqid, {}) + + def _sub_login(self, seqid, params, event_id): + self._ok(seqid, {"status": "offline"}) + + def _sub_recent(self, seqid, params, event_id): + self._ok(seqid, []) + + def _sub_cache(self, seqid, params, event_id): + keys = params.get("keys") or params.get("key") or [] + if isinstance(keys, str): + keys = [keys] + for k in keys: + self.cache_subs.setdefault(str(k), []).append(event_id) + self._ok(seqid, {}) + + def _get_cache(self, seqid, params, event_id): + """`{keys: [...]}` in, an ARRAY out, positionally, `{}` for anything missing. + + The store is `SSWCP_Instance::m_wcp_cache`, a static held in memory and shared + by every surface Orca opens - not a file, and not seeded. `deviceList` and + `deviceFilamentInfo` get there because a page PUT them there; the Device tab + only reads them. That is why opening the Device tab on its own leaves it with + no device: in Orca as much as here, nothing has written the list yet. + """ + keys = params.get("keys") + if not isinstance(keys, list) or not keys: + return self._err(seqid, -1, "sw_GetCache wants a non-empty keys array") + self._ok(seqid, [self.cache.get(str(k), {}) for k in keys]) + + def _set_cache(self, seqid, params, event_id): + """`{objects: [{key, value}, ...]}`. + + Orca uses `m_wcp_cache.insert(...)`, which does NOT overwrite an existing key - + so a second write of the same key is dropped while its notification still goes + out with the new value. That is faithfully reproduced: a host standing in for + Orca has to be wrong in the same places, or it is testing something else. + """ + objects = params.get("objects") + if not isinstance(objects, list) or not objects: + return self._err(seqid, -1, "sw_SetCache wants a non-empty objects array") + for o in objects: + key, value = str(o.get("key", "")), o.get("value") + self.cache.setdefault(key, value) # insert(), not assignment + self.log(f"[bridge] cache {key} = {json.dumps(value)[:120]}") + # The notification carries the value that was offered, whether or not the + # store took it, and is shaped { : } - keyed by name. + for ev in self.cache_subs.get(key, []): + self._push(ev, {key: value}) + self._ok(seqid, {}) + + def _remove_cache(self, seqid, params, event_id): + for k in (params.get("keys") or []): + self.cache.pop(str(k), None) + self._ok(seqid, {}) + + # ---- the device book ------------------------------------------------ + + def probe_devices(self): + """Say which saved devices are actually there. + + `connected` is what the shipped bundle gates on: sw_GetConnectedMachine returns + the first device carrying it, and with no device the page sits at `sn=` and + never starts the session it would otherwise drive itself. Orca sets the flag + when Orca has a connection; this host sets it when the machine answers a + socket, which is the same claim made on evidence it can actually check. + """ + for d in self.devices: + up = reachable(d.get("ip"), d.get("port")) + d["connected"] = up + self.log(f"[bridge] {d.get('dev_name')} at {d.get('ip')}: " + f"{'reachable' if up else 'not answering'}") + + def _get_devices(self, seqid, params, event_id): + if event_id: + self.subs.setdefault(("__devices__", ""), []).append(event_id) + # Orca replies with a BARE ARRAY here (m_res_data = devices). + self._ok(seqid, self.devices) + if event_id: + # Orca does BOTH of these every time the device list changes, and they are + # different channels: device_card_notify() pushes on the WCP subscription, + # and a raw window.postMessage carries the legacy `command` shape. The + # reconstruction reads neither - it takes the reply - so the second channel + # went unimplemented until the shipped bundle sat at `sn=` waiting for it. + threading.Timer(0.5, self.announce_devices).start() + + def announce_devices(self): + """Say the device list has arrived, on both channels Orca uses. + + `sequece_id` is spelled that way in SSWCP.cpp. It is not a typo here. + """ + for ev in self.subs.get(("__devices__", ""), []): + self._push(ev, self.devices) + # Orca posts this too, and it is kept for fidelity - but the Flutter bundle + # does not listen for it: `local_devices_arrived` appears zero times in + # main.dart.js. It is for Orca's own non-Flutter device cards. + self.send({"command": "local_devices_arrived", + "sequece_id": "10001", + "data": self.devices}, as_string=False) + + def _connected_machine(self, seqid, params, event_id): + conn = next((d for d in self.devices if d.get("connected")), None) + self._ok(seqid, conn or {}) + + def _login_state(self, seqid, params, event_id): + self._ok(seqid, {"status": "offline"}) + + def _file_log(self, seqid, params, event_id): + line = str(params.get("content", ""))[:400] + self.log(f"[page] {line}") + self._ok(seqid, {}) + + # ---- the printer ---------------------------------------------------- + + def _shape(self, cmd, entry, params): + """The parameters Orca would have sent, not the ones the page happened to send. + + Each async_ builds its params from a named handful; forwarding the page's whole + object instead would be a different request than Orca makes. Dropped keys are + logged rather than swallowed - a silently missing parameter is exactly the kind + of difference that takes an afternoon to find. + """ + if entry.get("forwarded"): + return dict(params) + keep = {k: params[k] for k in entry.get("params", []) if k in params} + dropped = sorted(set(params) - set(keep)) + if dropped: + self.log(f"[bridge] {cmd}: not forwarding {dropped} " + f"(Orca sends only {entry.get('params')})") + return keep + + def _call(self, seqid, cmd, entry, params): + if not self.engine or not self.sn: + return self._err(seqid, -1, f"{cmd}: no engine attached yet " + "(sw_mqtt_set_engine has not run)") + with self._lock: + self._rpc += 1 + rid = self._rpc + body = {"jsonrpc": "2.0", "method": entry["method"], "id": rid, + # Mandatory. Without them the printer neither answers nor errors. + "cli_time": int(time.time()), "dev_time": -1} + shaped = self._shape(cmd, entry, params) + if shaped: + body["params"] = shaped # the C++ omits an empty params too + self.pending[rid] = (seqid, time.time() + RPC_TIMEOUT, cmd) + topic = f"{self.sn}/request" + eng = self.engine + + def sent(f): + if f.exception(): + self.pending.pop(rid, None) + self._err(seqid, -1, f"{cmd}: {f.exception()}") + eng.submit(lambda cli: cli.publish(topic, body, 1)).add_done_callback(sent) + + def _on_mqtt(self, cid, topic, raw): + """Called on a client's own thread for every message that arrives.""" + text = raw.decode("utf-8", "replace") + + # raw subscriptions get the {topic, data} shape Orca pushes, data still a string + for ev in self.subs.get((cid, topic), []): + self._push(ev, {"topic": topic, "data": text}) + + if not self.sn: + return + try: + msg = json.loads(text) + except Exception: # noqa: BLE001 + return + if not isinstance(msg, dict): + return + + if topic == f"{self.sn}/response": + got = self.pending.pop(msg.get("id"), None) + if got: + if self.trace > 1000: + err = msg.get("error") + body = json.dumps(msg.get("result", msg)) + self.log(f"[bridge] <- {got[2]} " + + (f"ERROR {json.dumps(err)}" if err + else f"{len(body)}B {body[:400]}")) + # Orca RESHAPES a printer reply before the page sees it - + # Moonraker_Mqtt::on_response_arrived, the `passthrough == false` arm, + # which every ordinary command takes: + # + # {"data": , "method": } + # + # not the raw envelope. The shipped bundle reads `status` off that one + # level down and calls the envelope an error - which is what left it + # showing a single toolhead while holding all four. + out = {"method": msg.get("method", "")} + if "result" in msg: + out["data"] = msg["result"] + elif "error" in msg: + # An RPC error still arrives as a SUCCESSFUL bridge reply whose + # data carries the error; the bridge decided the command was sent. + out["error"] = msg["error"] + self._ok(got[0], out) + return + + if topic in (f"{self.sn}/status", f"{self.sn}/notification"): + # Moonraker_Mqtt::on_status_arrived, field for field. + if "params" in msg: + data = msg["params"] + elif isinstance(msg.get("result"), dict) and "status" in msg["result"]: + data = msg["result"]["status"] + else: + return + for ev in list(self.status_events): + self._push(ev, {"data": data, "method": msg.get("method", "")}) + + def _expire(self): + while True: + time.sleep(0.5) + now = time.time() + for rid, (seqid, deadline, cmd) in list(self.pending.items()): + if now > deadline: + self.pending.pop(rid, None) + # Orca's exact shape: handle_general_fail(-2, "time out"). The page + # keys on both, so inventing a friendlier message here would make + # this host behave differently from the one it stands in for. The + # detail goes to the terminal instead. + self.log(f"[bridge] {cmd} (rpc {rid}) got no reply in " + f"{RPC_TIMEOUT:.0f}s") + self._err(seqid, -2, "time out") + + # ---- teardown ------------------------------------------------------- + + def stop(self): + for c in list(self.clients.values()): + c.stop() + self.clients.clear() + for p in self._tmp: + try: + os.unlink(p) + except OSError: + pass + self._tmp = [] diff --git a/docs/u1-webui/tools/u1_probe.py b/docs/u1-webui/tools/u1_probe.py new file mode 100755 index 00000000000..e9ed7e16c82 --- /dev/null +++ b/docs/u1-webui/tools/u1_probe.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Talk to a real U1 and record what it actually sends back. + +Three response shapes in the reconstruction are pass-throughs from the printer whose +field names appear nowhere in the Flutter bundle or in Orca's C++ - camera frames, file +thumbnails and discovery results. `pickFrame()` and `pickThumb()` sniff a list of +plausible names because nobody had seen the real thing. This closes that by asking the +printer directly. + +It speaks the connect path documented in 02-device-page/06-connection.md, without Orca: +plain MQTT on :1884 to get keys, then mTLS on :8883 for the session. That makes it usable +as a regression probe as well - run it against a firmware update and diff the shapes. + +Read-only. Every method it sends queries or subscribes; nothing moves the machine, starts +or cancels a print, or writes to the filesystem. Certificates never reach the report. + + python3 u1_probe.py # device read from Orca's config + python3 u1_probe.py --ip 192.168.2.242 --sn --client-id + python3 u1_probe.py --out shapes.json # keep the raw payloads +""" + +import argparse +import glob +import json +import os +import ssl +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from mqtt_min import MqttClient, MqttError # noqa: E402 + +LAN_AUTH_CODE = "12345678" # fixed, not a PIN - see 06-connection.md +PLAIN_PORT = 1884 +TLS_PORT = 8883 + + +# --------------------------------------------------------------------------- config + +def device_from_orca_config(): + """The device record Orca saved. ca/cert/key are always blank there by design.""" + for path in glob.glob(os.path.expanduser("~/.config/Snapmaker_Orca/*.conf")): + try: + cfg = json.load(open(path)) + except Exception: + continue + for dev in cfg.get("devices", []): + if dev.get("ip") and dev.get("sn"): + return dev + return None + + +# ----------------------------------------------------------------------- rpc plumbing + +class Session: + def __init__(self, ip, sn, client_id, verbose=True): + self.ip, self.sn, self.client_id = ip, sn, client_id + self.verbose = verbose + self.cli = None + self.rpc_id = 0 + self.inbox = [] # (topic, obj) seen while waiting for something else + self._tmp = [] + + def log(self, msg): + if self.verbose: + print(msg, flush=True) + + def envelope(self, method, params=None, rpc_id=None): + """cli_time and dev_time are mandatory: without them the printer stays silent.""" + self.rpc_id += 1 + return { + "jsonrpc": "2.0", + "method": method, + "params": params or {}, + "id": rpc_id if rpc_id is not None else self.rpc_id, + "cli_time": int(time.time()), + "dev_time": -1, + } + + # -- phase 1 --------------------------------------------------------- + + def request_keys(self, timeout=20): + """Plain MQTT on :1884, server.client_manager.request_lan_auth.""" + throwaway = f"orca-try-{int(time.time() * 1000) % 1000000}" + self.log(f" phase 1 mqtt://{self.ip}:{PLAIN_PORT} as {throwaway}") + c = MqttClient(self.ip, PLAIN_PORT, throwaway).connect() + try: + c.subscribe(f"{LAN_AUTH_CODE}/config/response") + req = self.envelope( + "server.client_manager.request_lan_auth", + {"clientid": self.client_id, "app_id": f"orca-{int(time.time() * 1e6)}"}, + ) + c.publish(f"{LAN_AUTH_CODE}/config/request", req) + self.log(" phase 1 sent request_lan_auth, waiting for keys") + + for topic, raw in c.messages(timeout): + try: + msg = json.loads(raw) + except Exception: + continue + res = msg.get("result", msg) + if isinstance(res, dict) and res.get("ca") and res.get("cert"): + self.log(f" phase 1 keys received (state={res.get('state')})") + return res + if msg.get("error"): + raise MqttError(f"auth refused: {msg['error']}") + raise MqttError("no keys within timeout (cli_time/dev_time missing?)") + finally: + c.close() + + # -- phase 2 --------------------------------------------------------- + + def _tls_context(self, auth): + """The printer's cert is not issued for its IP, so verify the chain, not the name.""" + def tmp(text, suffix): + fd, path = tempfile.mkstemp(suffix=suffix) + os.write(fd, text.encode()) + os.close(fd) + self._tmp.append(path) + return path + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.load_verify_locations(cafile=tmp(auth["ca"], ".ca.pem")) + ctx.load_cert_chain(tmp(auth["cert"], ".crt.pem"), tmp(auth["key"], ".key.pem")) + return ctx + + def open(self, auth): + ctx = self._tls_context(auth) + port = int(auth.get("port") or TLS_PORT) + self.log(f" phase 2 mqtts://{self.ip}:{port} as {self.client_id}") + self.cli = MqttClient(self.ip, port, self.client_id, tls=ctx).connect() + for suffix in ("status", "response", "notification"): + self.cli.subscribe(f"{self.sn}/{suffix}") + self.log(f" phase 2 subscribed {self.sn}/{{status,response,notification}}") + return self + + def call(self, method, params=None, timeout=12, collect_pushes=False): + """Send one JSON-RPC request; return (result_envelope, pushes_seen).""" + req = self.envelope(method, params) + self.cli.publish(f"{self.sn}/request", req) + pushes, answer = [], None + deadline = time.time() + timeout + while time.time() < deadline: + got = False + for topic, raw in self.cli.messages(max(0.1, deadline - time.time())): + got = True + try: + msg = json.loads(raw) + except Exception: + msg = {"_raw_non_json": raw[:200].decode("utf-8", "replace"), + "_bytes": len(raw)} + if isinstance(msg, dict) and msg.get("id") == req["id"]: + answer = msg + if not collect_pushes: + return answer, pushes + deadline = min(deadline, time.time() + 3) + else: + pushes.append((topic, msg)) + if not got: + break + return answer, pushes + + def drain(self, seconds): + out = [] + for topic, raw in self.cli.messages(seconds): + try: + out.append((topic, json.loads(raw))) + except Exception: + out.append((topic, {"_raw_non_json": raw[:200].decode("utf-8", "replace"), + "_bytes": len(raw)})) + return out + + def close(self): + if self.cli: + self.cli.close() + for p in self._tmp: + try: + os.unlink(p) + except OSError: + pass + self._tmp = [] + + +# --------------------------------------------------------------------------- shaping + +def describe(obj, depth=0, max_depth=4): + """A field-name skeleton. Long strings become their length, so certificates and + base64 frames are summarised rather than dumped.""" + pad = " " * depth + if isinstance(obj, dict): + if depth >= max_depth: + return pad + f"{{{len(obj)} keys: {', '.join(list(obj)[:8])}}}" + return "\n".join(f"{pad}{k}: {describe(v, depth + 1, max_depth).lstrip()}" + for k, v in obj.items()) + if isinstance(obj, list): + if not obj: + return "[] (empty)" + return f"[{len(obj)}] of\n" + describe(obj[0], depth + 1, max_depth) + if isinstance(obj, str): + return f"" if len(obj) > 80 else repr(obj) + return repr(obj) + + +def find_long_strings(obj, path="", out=None, minlen=256): + """Where the payload's bulk actually lives - the candidate frame/thumbnail field.""" + if out is None: + out = [] + if isinstance(obj, dict): + for k, v in obj.items(): + find_long_strings(v, f"{path}.{k}" if path else k, out, minlen) + elif isinstance(obj, list): + for i, v in enumerate(obj[:4]): + find_long_strings(v, f"{path}[{i}]", out, minlen) + elif isinstance(obj, str) and len(obj) >= minlen: + out.append((path, len(obj), obj[:48])) + return out + + +# ----------------------------------------------------------------------------- probes + +def probe_files(sess, report): + """A directory listing first, because the thumbnail calls need a real filename.""" + print("\n[files] server.files.get_directory") + ans, _ = sess.call("server.files.get_directory", {"path": "gcodes", "extended": True}) + report["server.files.get_directory"] = ans + if not ans or "result" not in ans: + print(" no result:", json.dumps(ans)[:300] if ans else "timeout") + return None + print(describe(ans["result"], max_depth=3)[:1200]) + + files = ans["result"].get("files") or [] + names = [f.get("filename") or f.get("path") for f in files if isinstance(f, dict)] + names = [n for n in names if n] + print(f" {len(names)} file(s): {names[:5]}") + return names[0] if names else None + + +def probe_thumbnails(sess, report, filename): + """The shape pickThumb() has been guessing at.""" + if not filename: + print("\n[thumbnails] skipped - no file on the printer to ask about") + report["_skipped_thumbnails"] = "no gcode file present" + return + for method, params in (("server.files.thumbnails", {"filename": filename}), + ("server.files.thumbnails_base64", {"path": filename})): + print(f"\n[thumbnails] {method} ({filename})") + ans, _ = sess.call(method, params, timeout=20) + report[method] = ans + if not ans: + print(" timeout") + continue + if "error" in ans: + print(" error:", json.dumps(ans["error"])[:200]) + continue + print(describe(ans.get("result"), max_depth=4)[:1500]) + for path, length, head in find_long_strings(ans.get("result"), minlen=64): + print(f" >> payload at result.{path} len={length} starts {head!r}") + + +def probe_camera(sess, report, watch=12): + """start_monitor is a subscription: the answer is small, the frames arrive after.""" + print("\n[camera] camera.start_monitor") + ans, pushes = sess.call("camera.start_monitor", + {"domain": "", "interval": 2, "expect_pw": False}, + timeout=15, collect_pushes=True) + report["camera.start_monitor"] = ans + print(" ack:", json.dumps(ans)[:400] if ans else "timeout") + + print(f" watching {watch}s for frames...") + frames = pushes + sess.drain(watch) + report["camera.frames"] = [] + seen = 0 + for topic, msg in frames: + longs = find_long_strings(msg, minlen=256) + if not longs: + continue + seen += 1 + if seen <= 2: + print(f"\n frame on {topic}:") + print(describe(msg, max_depth=4)[:900]) + for path, length, head in longs: + print(f" >> frame data at {path} len={length} starts {head!r}") + report["camera.frames"].append( + {"topic": topic, "skeleton": describe(msg, max_depth=4), + "long_fields": longs}) + if not seen: + print(" no frame-shaped push arrived " + "(camera off, or frames travel outside MQTT)") + report["camera.frames_none"] = [ + {"topic": t, "keys": list(m) if isinstance(m, dict) else str(type(m))} + for t, m in frames[:12]] + else: + print(f"\n {seen} frame push(es) seen") + + sess.call("camera.stop_monitor", {"domain": ""}, timeout=6) + print(" monitor stopped") + + +def probe_state(sess, report): + """Proves the session is real before anything is concluded from a silent probe.""" + print("\n[sanity] printer.objects.list") + ans, _ = sess.call("printer.objects.list", {}) + report["printer.objects.list"] = ans + if ans and "result" in ans: + objs = ans["result"].get("objects", []) + print(f" {len(objs)} objects: {objs[:8]}") + return True + print(" no result:", json.dumps(ans)[:300] if ans else "timeout") + return False + + +# ------------------------------------------------------------------------------- main + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--ip") + ap.add_argument("--sn") + ap.add_argument("--client-id") + ap.add_argument("--out", help="write the raw payloads here as JSON") + ap.add_argument("--camera-watch", type=int, default=12, + help="seconds to watch for camera frames (default 12)") + ap.add_argument("--skip-camera", action="store_true") + args = ap.parse_args() + + ip, sn, client_id = args.ip, args.sn, args.client_id + if not (ip and sn and client_id): + dev = device_from_orca_config() + if not dev: + print("No device in Orca's config; pass --ip/--sn/--client-id.", + file=sys.stderr) + return 2 + ip = ip or dev["ip"] + sn = sn or dev["sn"] + client_id = client_id or dev.get("clientId") + print(f"device from Orca config: {dev.get('dev_name')} ({sn}) at {ip}") + + sess = Session(ip, sn, client_id) + report = {"_device": {"ip": ip, "sn": sn}} + try: + auth = sess.request_keys() + report["_auth_keys"] = {k: (f"<{len(v)} chars>" if isinstance(v, str) and len(v) > 80 + else v) for k, v in auth.items()} + sess.open(auth) + except (MqttError, OSError, ssl.SSLError) as e: + print(f"\nconnect failed: {type(e).__name__}: {e}", file=sys.stderr) + sess.close() + return 1 + + try: + if not probe_state(sess, report): + print("\nSession answered nothing - not probing shapes against a dead session.", + file=sys.stderr) + return 1 + first = probe_files(sess, report) + probe_thumbnails(sess, report, first) + if not args.skip_camera: + probe_camera(sess, report, args.camera_watch) + finally: + sess.close() + if args.out: + with open(args.out, "w") as fh: + json.dump(report, fh, indent=2, default=str) + print(f"\nraw payloads written to {args.out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/u1-webui/tools/u1_topics.py b/docs/u1-webui/tools/u1_topics.py new file mode 100755 index 00000000000..068a6d20c6b --- /dev/null +++ b/docs/u1-webui/tools/u1_topics.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Survey every MQTT topic a U1 publishes on, and what travels over each. + +The connect path documented in 06-connection.md names four topics because those are the +four Orca subscribes to. That is what Orca uses, not what the printer offers. This +subscribes to the `#` wildcard instead and lets the printer enumerate itself. + +Traffic is provoked in labelled phases - idle, a state subscription, a file listing, a +camera monitor - so each topic can be attributed to what caused it rather than guessed +at. For every topic it records the JSON-RPC methods seen, a field skeleton per method, +and one truncated sample. + +Read-only: every method it sends queries, lists or subscribes. Nothing moves the machine +or touches a print job. + + python3 u1_topics.py # ~60s survey, prints a report + python3 u1_topics.py --idle 30 # listen longer between phases + python3 u1_topics.py --md ../topics.md # write the markdown overview +""" + +import argparse +import json +import os +import ssl +import sys +import time +from collections import OrderedDict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from mqtt_min import MqttError # noqa: E402 +from u1_probe import (Session, device_from_orca_config, describe, # noqa: E402 + find_long_strings, LAN_AUTH_CODE) + + +class TopicSurvey: + """Everything seen, bucketed by topic and then by JSON-RPC method.""" + + def __init__(self): + self.topics = OrderedDict() + + def record(self, topic, raw, phase): + t = self.topics.setdefault(topic, { + "count": 0, "bytes": 0, "phases": set(), "methods": OrderedDict(), + "non_json": 0, + }) + t["count"] += 1 + t["bytes"] += len(raw) + t["phases"].add(phase) + try: + msg = json.loads(raw) + except Exception: + t["non_json"] += 1 + return None + + # notifications carry `method`; replies carry `id` and result/error + if isinstance(msg, dict) and "method" in msg: + key = msg["method"] + elif isinstance(msg, dict) and "error" in msg: + key = "" + elif isinstance(msg, dict) and ("result" in msg or "id" in msg): + key = "" + else: + key = "" + + m = t["methods"].setdefault(key, {"count": 0, "sample": None, + "skeleton": None, "long": []}) + m["count"] += 1 + if m["sample"] is None: + m["sample"] = json.dumps(msg)[:600] + m["skeleton"] = describe(msg, max_depth=4)[:2000] + m["long"] = find_long_strings(msg, minlen=256)[:4] + return msg + + def drain(self, sess, seconds, phase): + for topic, raw in sess.cli.messages(seconds): + self.record(topic, raw, phase) + + # -- reporting ------------------------------------------------------- + + def report(self): + out = [] + for topic, t in sorted(self.topics.items()): + out.append(f"\n{'=' * 78}\n{topic}") + out.append(f" {t['count']} message(s), {t['bytes']:,} bytes, " + f"seen during: {', '.join(sorted(t['phases']))}") + if t["non_json"]: + out.append(f" {t['non_json']} non-JSON payload(s)") + for name, m in sorted(t["methods"].items(), + key=lambda kv: -kv[1]["count"]): + out.append(f"\n -- {name} x{m['count']}") + for line in (m["skeleton"] or "").split("\n")[:26]: + out.append(f" {line}") + for path, length, head in m["long"]: + out.append(f" >> bulk at {path} len={length} {head!r}") + return "\n".join(out) + + def markdown(self, dev, phases): + L = ["# MQTT topics on a Snapmaker U1", "", + "What the printer actually publishes, enumerated by subscribing to the `#`", + "wildcard rather than to the four topics Orca happens to use. Generated by", + "[`tools/u1_topics.py`](../tools/u1_topics.py) against real hardware.", "", + f"Printer `{dev['sn']}` at `{dev['ip']}`. " + f"Survey phases: {', '.join(phases)}.", "", + "## Topics at a glance", "", + "| Topic | Messages | Bytes | Carries |", "|---|---:|---:|---|"] + for topic, t in sorted(self.topics.items()): + methods = ", ".join(f"`{m}`" for m in list(t["methods"])[:4]) + if len(t["methods"]) > 4: + methods += f", +{len(t['methods']) - 4} more" + L.append(f"| `{topic}` | {t['count']} | {t['bytes']:,} | {methods or '-'} |") + + L += ["", "## Each topic in detail", ""] + for topic, t in sorted(self.topics.items()): + L += [f"### `{topic}`", "", + f"{t['count']} message(s), {t['bytes']:,} bytes. " + f"Observed during: {', '.join(sorted(t['phases']))}.", ""] + for name, m in sorted(t["methods"].items(), key=lambda kv: -kv[1]["count"]): + L += [f"#### `{name}` — {m['count']} message(s)", "", + "Shape:", "", "```", (m["skeleton"] or "").strip(), "```", ""] + if m["long"]: + L.append("Bulk payload fields:") + L.append("") + for path, length, head in m["long"]: + L.append(f"- `{path}` — {length} chars, starts `{head}`") + L.append("") + L += ["Sample:", "", "```json", m["sample"] or "", "```", ""] + return "\n".join(L) + + +# ---------------------------------------------------------------------------- phases + +def survey(sess, sv, idle): + """Provoke traffic in labelled phases so topics can be attributed, not guessed.""" + phases = [] + + def phase(name, seconds, before=None): + phases.append(name) + print(f"\n[{name}] ...", flush=True) + if before: + try: + before() + except Exception as e: + print(f" provoke failed: {type(e).__name__}: {e}") + sv.drain(sess, seconds, name) + print(f" topics so far: {len(sv.topics)}") + + phase("idle", idle) + + phase("objects.list", 8, + lambda: sess.cli.publish(f"{sess.sn}/request", + sess.envelope("printer.objects.list", {}))) + + # a broad state subscription is what makes /status talk + watched = ["print_stats", "virtual_sdcard", "display_status", "heater_bed", + "extruder", "toolhead", "gcode_move", "idle_timeout", "fan"] + phase("objects.subscribe", max(idle, 15), + lambda: sess.cli.publish( + f"{sess.sn}/request", + sess.envelope("printer.objects.subscribe", + {"objects": {o: None for o in watched}}))) + + phase("files.get_directory", 8, + lambda: sess.cli.publish(f"{sess.sn}/request", + sess.envelope("server.files.get_directory", + {"path": "gcodes", "extended": True}))) + + phase("timelapse.list", 8, + lambda: sess.cli.publish(f"{sess.sn}/request", + sess.envelope("camera.get_timelapse_instance", + {"page_index": 0, "page_rows": 4, + "thumbnail_direct": True}))) + + # domain 'lan' is the value the printer accepts; '' is rejected -32000 + phase("camera.monitor", max(idle, 15), + lambda: sess.cli.publish(f"{sess.sn}/request", + sess.envelope("camera.start_monitor", + {"domain": "lan", "interval": 2, + "expect_pw": False}))) + sess.cli.publish(f"{sess.sn}/request", + sess.envelope("camera.stop_monitor", {"domain": "lan"})) + sv.drain(sess, 4, "camera.monitor") + + phase("server.info", 8, + lambda: sess.cli.publish(f"{sess.sn}/request", + sess.envelope("server.info", {}))) + return phases + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--ip") + ap.add_argument("--sn") + ap.add_argument("--client-id") + ap.add_argument("--idle", type=int, default=12, help="seconds per listening phase") + ap.add_argument("--md", help="write the markdown overview here") + ap.add_argument("--json", help="write the raw survey here") + ap.add_argument("--filter", default="#", help="topic filter (default: everything)") + args = ap.parse_args() + + dev = {} + if not (args.ip and args.sn and args.client_id): + dev = device_from_orca_config() or {} + if not dev: + print("No device in Orca's config; pass --ip/--sn/--client-id.", + file=sys.stderr) + return 2 + ip = args.ip or dev["ip"] + sn = args.sn or dev["sn"] + client_id = args.client_id or dev.get("clientId") + print(f"surveying {sn} at {ip}") + + sess = Session(ip, sn, client_id) + sv = TopicSurvey() + try: + sess.open(sess.request_keys()) + except (MqttError, OSError, ssl.SSLError) as e: + print(f"connect failed: {type(e).__name__}: {e}", file=sys.stderr) + sess.close() + return 1 + + try: + # the whole point: let the printer enumerate its own topics + try: + sess.cli.subscribe(args.filter) + print(f" subscribed wildcard {args.filter!r}") + except MqttError as e: + print(f" wildcard refused ({e}); falling back to the known topics") + for t in (f"{sn}/status", f"{sn}/response", f"{sn}/notification", + f"{LAN_AUTH_CODE}/config/response", + f"{LAN_AUTH_CODE}/config/notification"): + try: + sess.cli.subscribe(t) + except MqttError: + pass + phases = survey(sess, sv, args.idle) + finally: + sess.close() + + print(sv.report()) + print(f"\n{len(sv.topics)} topic(s) seen.") + if args.md: + with open(args.md, "w") as fh: + fh.write(sv.markdown({"sn": sn, "ip": ip}, phases)) + print(f"markdown written to {args.md}") + if args.json: + with open(args.json, "w") as fh: + json.dump({t: {**v, "phases": sorted(v["phases"])} + for t, v in sv.topics.items()}, fh, indent=2, default=str) + print(f"raw survey written to {args.json}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/web/device_page/README.md b/resources/web/device_page/README.md new file mode 100644 index 00000000000..060552e022c --- /dev/null +++ b/resources/web/device_page/README.md @@ -0,0 +1,168 @@ +# Device page — clean-room reimplementation + +A dependency-free HTML/CSS/JS Device page for the Snapmaker U1, built from the +reverse engineering in [`docs/u1-webui/`](../../../docs/u1-webui/). + +It exists to prove the protocol documentation is correct and complete: it speaks the +same WCP bridge, subscribes to the same 24-object state model, and issues the same +control commands as the shipped 5.2 MB Flutter bundle — with no build step. + +This is **one of two** reconstructed surfaces. The bridge client, the state model, the +protocol constants and the simulated host live in [`../shared/`](../shared/) and are +shared with [`../print_processing/`](../print_processing/); only this surface's own +`app.js`, `ui.js` and `device.css` are here. See +[what the two surfaces share](../../../docs/u1-webui/00-shared/01-shared-models.md). + +A build badge in the bottom-right corner names the surface and the build it reports — +the marker that tells this reconstruction apart from the shipped Flutter page. + +## Running it + +**Standalone** (simulated printer, no Orca, no hardware) — ES modules need a real +origin, so serve rather than opening the file directly: + +```bash +python3 -m http.server 8099 --directory resources/web +``` + +Then open `http://127.0.0.1:8099/device_page/index.html?mock=1`. + +**Inside Orca** — the app's own HTTP server already serves `resources/web`, so the page +is reachable at `/web/device_page/index.html`. It auto-detects the host: if +`window.wx` is present it drives the real printer, otherwise it falls back to the +simulator. To load it in place of the Flutter page, point `PrinterWebView` at it: + +```cpp +// src/slic3r/GUI/PrinterWebView.cpp +wxString url = wxString::FromUTF8(LOCALHOST_URL + + std::to_string(wxGetApp().get_page_http_port()) + + "/web/device_page/index.html"); +``` + +## Files + +**Start at [`js/registry.js`](js/registry.js)** — it lists the page's two destinations +and which panels each one has. Everything inside `.content` is built from it. + +Then **one panel is one directory**, and everything about it is in there: + +``` +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 +``` + +| | | +|---|---| +| `index.html` | The shell, and only the shell: the rail and an empty `.content` | +| `js/registry.js` | **What this page is made of.** Destinations, panels, order | +| `js/shell.js` | Builds the page from the registry; scopes each panel's commands | +| `js/app.js` | Startup, the rail's device menu, the render loop | +| `js/page-commands.js` | The few commands that belong to no single panel | +| `js/views///` | One directory per panel — see above | +| `js/core/store.js` | **What the page remembers** — view, tab, fetched lists, chosen tool | +| `js/core/pending.js` | What was asked for and not yet confirmed. Neither state nor store | +| `js/core/render.js` | The one update discipline: rebuild on a signature, reconcile by key | +| `js/core/dom.js` | `$`, `el`, `icon` — the three primitives every view builds with | +| `js/core/session.js` | Having a printer on the other end: connect, staleness, retry, the stream | +| `js/core/connection.js` | The connect path itself: pairing, mTLS, the MQTT engine | +| `js/core/overlay.js` | Menus, dialogs and popovers | +| `js/core/diag.js` | The `?diag=1` beacon | +| `js/core/mock.js` | Installs the shared simulator, so the page runs with no hardware | +| `js/core/thumbs.js` | The thumbnail sniffer, shared by the job card and Storage | +| `js/widgets/` | The rail, the trace pane, and art/formatting used by more than one view | +| `css/device.css` | Styling | + +Every filename carries its component, so nothing in the tree is called `panel.js`. + +The bridge client, protocol constants, state store, activity table, fault catalogue and +simulated host are shared with the print-processing popup and live in +[`../shared/js/`](../shared/js/). + +### The three stores, and why there are three + +A panel is handed one `ctx` with three of them, 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 is the one that has to exist separately. Keeping a request in the thing that +mirrors the machine is a bug this page has had in three unrelated controls, because the +next state push arrives before the printer has acted and writes the pre-click value back +over what the user just asked for. `pending.js` documents all three. + +### Adding a panel + +Make `js/views///` with its three files, and add the panel to `PANELS` +in the registry. That is the whole change — the +`
`, the 40px header, the title, the header buttons and the paint loop all come +from the declaration. It used to mean editing five files with nothing to check you had +done all five. + +A panel is handed **its own command module and nothing else**, so reaching for another +panel's command is a `TypeError` rather than a quiet dependency. That is also what makes +the tooling honest: `check_coverage.py` reads the `CMD.` references out of +`commands/.js` to answer *which panel can issue this*, and a conformance check fails +on any handler nothing calls. Both are facts about the code rather than declarations +that can drift — an earlier version had each panel declare a `sends` list, and one of +them claimed a command no code issued. + +## Tests + +```bash +# 1. constants match the RE evidence, and the page's decisions match the write-up +python3 resources/web/shared/tests/conformance_test.py + +# 2. the page itself, in WebKitGTK - the engine Orca renders with +python3 resources/web/shared/tests/run_webkit.py + +# 3. the pure logic, in JavaScriptCore, against captured hardware payloads +python3 resources/web/shared/tests/unit_jsc.py + +# 4. every command implemented, and reachable from a control +python3 docs/u1-webui/tools/check_coverage.py +``` + +`run_webkit.py --shots DIR` writes real PNGs: an unattended run renders into a +`Gtk.OffscreenWindow` with the accelerated compositor off, which needs no EGL driver. +(A `--watch` run keeps its real window, and on a machine with no working EGL that +window still reads back blank.) The images are worth having and are still the weaker +half — the DOM assertions are what catch a seam three pixels out of true. + +`conformance_test.py` re-derives the constant tables from `docs/u1-webui/data/` +and fails if `protocol.js` has drifted — it is the regression guard tying this code to +the reverse-engineering evidence. `selftest.html` exercises the real modules against +the simulator: envelope shape, subscription ack-then-push, partial-state merging, +control round-trips, error decoding, and failure handling. + +### multiACE + +The Filament panel integrates with **multiACE** +([decay71/multiACE](https://github.com/decay71/multiACE)), a third-party Klipper plugin +deployed onto a U1 — not with Snapmaker's own firmware. A printer without it reports no +`ace` object and gets the four filament slots this page always drew. + +Everything about it is in [`../shared/js/multiACE.js`](../shared/js/multiACE.js): the +macro names and the line builder, the unit letters, the dryer presets, the override-store +URL, the state model and the bay merge. `MachineState.ace()` calls into it. Two sources +feed that model and they do not carry the same thing — the `ace` Klipper object has the +topology and what is *loaded*, and multiACE's override store has what is in each *bay* — +so the precedence (**rfid → override → derived**) lives there too, as pure logic +`unit_jsc.py` can test. + +## What it does not do + +The shipped page is much larger in scope. Deliberately out of scope here: device +discovery and pairing, cloud login and binding, file browser and upload, camera and +timelapse, firmware update, filament mapping dialogs, and the model library. The +protocol for all of those is documented in `docs/u1-webui/`; only the live +machine-control surface is implemented. + +Also note the page assumes a printer is already connected — it calls +`sw_GetConnectedMachine` and proceeds. Connection state handling is the shipped page's +job (see the seven `device*.webp` states in the routes doc). diff --git a/resources/web/device_page/css/device.css b/resources/web/device_page/css/device.css new file mode 100644 index 00000000000..18c198485cd --- /dev/null +++ b/resources/web/device_page/css/device.css @@ -0,0 +1,2016 @@ +/* + * device.css - the U1 Device tab. + * + * Rebuilt to match the shipped Flutter page rather than to a taste of its own. + * Every colour and dimension below was measured off the real page: computed + * styles pulled out of its DOM over CDP, plus pixel samples for the raster + * layers. See docs/u1-webui/02-device-page/05-visual-reference.md + * + * Layered on ../shared/css/base.css, whose tokens it overrides wholesale - this + * surface is a fixed light design, not a theme-following one. It must therefore + * define every token base.css styles against (the build badge included). + */ +:root { + /* measured from the shipped page */ + --page: #F5F6FA; /* app ground, right of the rail */ + --rail: #FFFFFF; /* left rail */ + --rail-line: #D9D9D9; /* 1px divider between rail and content */ + --panel: #FFFFFF; /* panel body */ + --panel-head: #E8E8E8; /* panel header bar */ + --tab-active: #FFFFFF; /* selected tab pill inside a header */ + --pill: #F5F6FA; /* "Print Preferences" pill */ + --accent: #0C63E2; /* selection tint, used at 10% on the rail */ + --accent-soft: rgba(12, 99, 226, .10); + --ink: #333333; /* panel + header text */ + --ink-nav: #242424; /* rail nav text and icon strokes */ + --ink-dim: #666666; /* the "|" separator */ + --line: #E6E6E6; /* card and control outlines */ + --line-face: #EDEFF2; /* faint outline used by the jog controls */ + --line-soft: #F0F0F0; + + --rail-w: 262px; + --col-w: 830px; /* panel column width */ + --head-h: 40px; + --radius: 8px; + + /* aliases so ../shared/css/base.css components inherit this palette */ + --bg: var(--page); + --panel-2: var(--pill); + --ink-2: var(--ink); + --muted: var(--ink-dim); + --accent-in: #FFFFFF; + --ok: #1F7A4D; + --warn: #9A5B12; + --err: #E03131; + /* heater states. Not measured off the shipped page - it shows none - so these are + chosen to read as temperature rather than as status: warm going up, cool coming + down, and the panel's own ink once the machine is holding. */ + --heat: #E8590C; + --cool: #1971C2; + --mono: "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace; + /* NOT the bundle's HarmonyOS Sans SC: tried, measured, and every title came out + narrower than the shipped page's - Camera by 11px - so whatever Flutter renders + those titles with, it is not that face at this size. The system stack lands + "Camera" on the measured 57px exactly. */ + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--page); + color: var(--ink); + font: 14px/1.45 var(--sans); + overflow: auto; +} + +/* ---- frame ---------------------------------------------------------- */ +.app { display: flex; min-height: 100%; align-items: stretch; } + +.rail { + width: var(--rail-w); + flex: 0 0 var(--rail-w); + background: var(--rail); + border-right: 1px solid var(--rail-line); +} + +/* + * Two columns, and they are not the same width. + * + * The shipped page put four panels in a 2x2 grid and locked every cell to + * `aspect-ratio: 830/548`, so a wider window bought four wider squares. The camera is + * the only one of the four whose content actually scales, and it gained nothing the + * others did not; the Control panel, whose content is a fixed 758px cluster, gained a + * band of empty white. + * + * So: a main column that takes what is left, and a side column pinned at --col-w, the + * width the Control cluster already is. Control keeps its layout untouched and gives up + * only HEIGHT - it sizes to its cluster instead of to a ratio - and Filament, directly + * beneath it, grows into what it releases. The camera fills its own column in both + * directions and its frame letterboxes, which is what a video viewport does anyway. + * + * Measured, at a 980px window: Control 340 and Filament 572, against 588 and 324 before. + * + * The narrow layout below the breakpoint is unchanged in shape - one centred column at + * `0.6 * available + 219`, which fits the shipped page's own measurements at 1280, 1330 + * and 1400. + * + * THE BREAKPOINT IS A TRADE-OFF, and it is this one number. The main column is + * `window - 304 - 20 - 830`; the old equal grid gave each panel `(window - 324) / 2`. + * Those cross at exactly 1984, so below 1984 this layout's camera is NARROWER than the + * grid's was, and the single centred column is wider than either: + * + * window main (here) old grid one column + * 1600 446 638 1021 + * 1920 766 798 1213 + * 1984 830 830 1252 + * 2560 1406 1118 1597 + * + * 1600 is chosen because the 1920 case - the common maximised width - costs the camera + * 32px and buys a Filament panel with real height and a destination that fits the + * window without scrolling. The 1600 case costs it 192px, which is the weakest point of + * this design. Raise this number to 1984 to make the two-column layout cost the camera + * nothing anywhere; lower it to 1420 to keep the old switching point. + */ +.content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 20px 0 40px; +} + +/* The destination itself is a column: the two panel columns in a row, and the + development aids beneath them rather than beside them. */ +#view-control { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} +.view-cols { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} +.view-col { + width: calc(60% + 219px); + min-width: 0; + display: flex; + flex-direction: column; + gap: 20px; +} + +@media (min-width: 1600px) { + .content { align-items: stretch; padding: 20px 21px 40px; } + /* A definite height is what lets `flex: 1` mean anything: without it the camera and + the Filament panel have no leftover to grow into and both collapse to content. + `min-height` rather than `height`, so a window too short for the cluster scrolls + instead of clipping it. */ + #view-control { align-items: stretch; min-height: calc(100vh - 60px); } + .view-cols { + flex: 1; min-height: 0; + flex-direction: row; align-items: stretch; + } + .view-col { width: auto; } + .col-main { flex: 1 1 0; } + .col-side { flex: 0 0 var(--col-w); } + /* one per column takes the leftover: the camera on the left, Filament on the right */ + .panel.grows { flex: 1; min-height: 0; } + .panel.grows > .panel-body { flex: 1; min-height: 0; } +} + +/* ---- rail ----------------------------------------------------------- */ +.device-select { + width: 100%; + height: 114px; /* nav starts at y=114 on the shipped page */ + display: flex; + align-items: center; + gap: 8px; + padding: 0 20px 0 35px; + background: none; + border: 0; + border-bottom: 1px solid var(--line-soft); + font: inherit; + color: var(--ink); + cursor: pointer; + text-align: left; +} +.device-select .dev-icon { + width: 26px; height: 26px; flex: 0 0 26px; + border-radius: 6px; + background: #101418; + display: grid; place-items: center; + color: #fff; font-weight: 700; font-size: 13px; font-style: italic; +} +.device-select .dev-name { + font-size: 16px; color: var(--ink); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; min-width: 0; +} +.device-select .chev { width: 20px; height: 20px; opacity: .54; flex: 0 0 20px; } + +.nav-item { + display: flex; align-items: center; gap: 19px; + height: 48px; + padding: 0 47px; + color: var(--ink-nav); + font-size: 14px; + cursor: pointer; + user-select: none; +} +.nav-item.is-active { background: var(--accent-soft); } +.nav-item img { width: 22px; height: 22px; } + +/* ---- panels --------------------------------------------------------- */ +/* A panel fills its column. `.grows` is the one in each column that takes the leftover + height - the camera on the left, Filament on the right - and it is declared in the + panel's own module, not inferred from source order here. + It only applies where a column HAS leftover height, which is the two-column layout; + see the media query below. Applied unconditionally it sets `min-height: 0` on bodies + in the single column, which collapsed the Filament panel to 180px with a 260px + min-height written directly above it. */ +.panel { min-width: 0; display: flex; flex-direction: column; } + +.panel-head { + height: var(--head-h); + background: var(--panel-head); + border-radius: var(--radius) var(--radius) 0 0; + display: flex; + align-items: center; + gap: 0; + padding: 0 15px 0 28px; +} +.panel-title { font-size: 14px; color: var(--ink); } +/* 14px before the separator and 13px after, not 20 and 20. Measured on the shipped + page: title at 28, separator at 99, selected pill at 117 - so a 20px margin pushed + the Camera tabs 13px right of where they belong. The Control and Filament headers + have no separator (their refresh pill starts 36px after the title) and were already + landing on 117. */ +.panel-head .sep { color: var(--ink-dim); font-size: 14px; margin: 0 13px 0 14px; } +.panel-head .spacer { flex: 1; } +/* Control's header has no separator: the title, a 36px gap, then the pill */ +.panel-head .gap-36 { width: 36px; flex: 0 0 36px; } + +/* a header tab pill: white when selected, transparent otherwise */ +/* A tab meets the card, it does not hover above it. + Measured on the shipped page: the header spans y 20..60 and the selected pill + 24..60 - inset at the TOP only, so its white runs into the white body underneath. + Centring 36px inside a 40px header leaves 2px of grey below it, and that gap is + the whole difference between reading as a tab and reading as a loose button. */ +.tab { + align-self: flex-end; + height: 36px; min-width: 52px; + display: grid; place-items: center; + border: 0; border-radius: 6px 6px 0 0; + background: none; + cursor: pointer; + padding: 0 14px; +} +.tab.is-active { background: var(--tab-active); } +.tab img { width: 24px; height: 24px; } +.tab.wide { min-width: 56px; height: 40px; } + +.pref-pill { + height: 24px; + display: inline-flex; align-items: center; gap: 3px; + padding: 0 7px; /* measured 120x24 on the shipped page */ + background: var(--pill); + border: 0; border-radius: 4px; + color: var(--ink); + font: 12px/1 var(--sans); + cursor: pointer; +} +.pref-pill .chev { width: 9px; height: 13px; } + +/* No blanket ratio any more. 830/548 came off the shipped page and was right when every + panel was one cell of an equal grid; with unequal columns it only forced three panels + to be the wrong height so the fourth could be the right one. Each body now says what + it needs: the camera and Filament take their column's leftover, Printing Task is a + card with a natural height, Control sizes to its cluster. */ +.panel-body { + background: var(--panel); + border-radius: 0 0 var(--radius) var(--radius); + overflow: hidden; + flex: 0 0 auto; +} + +/* ---- camera --------------------------------------------------------- */ +/* 16:9 while the column has no height to give it - which is the narrow layout - and + nothing at all once it does, because there the column decides and the frame + letterboxes inside it. */ +.camera-body { display: grid; place-items: center; aspect-ratio: 16 / 9; min-height: 240px; } +@media (min-width: 1600px) { + .camera-body { aspect-ratio: auto; } +} +/* The bundle's illustration is 803x509 and the shipped page draws it at panel + width, with the printer artwork occupying the middle third. Anything smaller + makes it read as a stray thumbnail. */ +.camera-body img.placeholder { width: 803px; max-width: 100%; height: auto; display: block; } +.camera-body .cam-msg { + margin-top: 6px; color: #B9BFC8; font-size: 13px; text-align: center; +} + +/* ---- control -------------------------------------------------------- */ +/* content spans x=390..1148 inside an 830px panel: 34 left, 38 right */ +/* The Control panel sizes to its content instead of inheriting .panel-body's + aspect-ratio. That ratio comes from the measured original and is right for the + camera, whose illustration scales with the panel - but the control content is a + fixed-size cluster, so on a panel wider than 830px the ratio only bought a taller + panel and a growing band of empty space beneath the wheel. Releasing it hands that + height back to the panels below. */ +.control-body { + padding: 20px 38px 24px 34px; + aspect-ratio: auto; + min-height: 0; + overflow: auto; +} + +/* the whole control area dims until the machine is reachable, exactly as the + shipped page does - it renders the full layout and fades it, rather than + hiding it */ +.control-grid { display: flex; gap: 25px; align-items: flex-start; min-width: 0; } +.control-grid[data-enabled="0"] { opacity: .18; pointer-events: none; } + +/* left status card */ +.status-card { + width: 160px; flex: 0 0 160px; + border: 1px solid var(--line); border-radius: 10px; + padding: 6px 0; +} +.status-row { + height: 56px; + display: flex; align-items: center; gap: 10px; + padding: 0 14px; + font-size: 13px; color: var(--ink); +} +.status-row img { width: 18px; height: 18px; flex: 0 0 18px; opacity: .85; } +.status-row .val { font-variant-numeric: tabular-nums; white-space: nowrap; } +.status-row .go { margin-left: auto; color: #B9BFC8; } + +.switch { + margin-left: auto; + width: 34px; height: 18px; border-radius: 999px; + background: #D5D9E0; position: relative; cursor: pointer; border: 0; +} +.switch::after { + content: ""; position: absolute; top: 2px; left: 2px; + width: 14px; height: 14px; border-radius: 50%; background: #fff; + transition: left .12s ease; +} +.switch[aria-checked="true"] { background: var(--accent); } +.switch[aria-checked="true"]::after { left: 18px; } +@media (prefers-reduced-motion: reduce) { .switch::after { transition: none; } } + +/* right side */ +.control-main { flex: 1; min-width: 0; } +.control-top { display: flex; align-items: center; gap: 32px; flex-wrap: nowrap; } +.control-top .seg.tools { width: 233px; } +.control-top .seg.steps { width: 205px; margin-left: 0; } +.control-top .home-btn { margin-left: auto; } /* right-aligned, as on the original */ + +.seg { + display: inline-flex; align-items: center; + height: 50px; + border: 1px solid var(--line); border-radius: 10px; + background: #fff; + padding: 5px; + gap: 2px; +} +.seg button { + flex: 1 1 0; min-width: 0; + height: 40px; padding: 0 6px; + border: 0; border-radius: 7px; background: none; + font: 14px/1 var(--sans); color: #9AA3AF; cursor: pointer; + white-space: nowrap; +} +.seg button.is-active { background: #fff; color: var(--ink); box-shadow: 0 0 0 1px var(--line); } + +.home-btn { + width: 83px; flex: 0 0 83px; height: 50px; + border: 1px solid var(--line); border-radius: 10px; + background: #fff; cursor: pointer; + display: grid; place-items: center; +} +.home-btn img { width: 26px; height: 26px; } + +/* jog cluster */ +.jog { + margin-top: 34px; + display: flex; align-items: center; justify-content: center; + gap: clamp(16px, 6%, 74px); flex-wrap: wrap; +} +.axis-col { display: flex; flex-direction: column; align-items: center; gap: 12px; } +.axis-col .axis-label { color: #C6CBD3; font-size: 13px; letter-spacing: .08em; } +.round-btn { + width: 80px; height: 80px; border-radius: 50%; + border: 1px solid var(--line-soft); background: #fff; + display: grid; place-items: center; cursor: pointer; +} +.round-btn .tri { color: #D3D8DF; font-size: 22px; line-height: 1; } + +.rosette { + width: 202px; height: 202px; border-radius: 50%; + border: 1px solid var(--line-soft); background: #fff; + position: relative; +} +.rosette button { + position: absolute; border: 0; background: none; cursor: pointer; + color: #D3D8DF; font-size: 22px; line-height: 1; padding: 6px; +} +.rosette .up { top: 22px; left: 50%; transform: translateX(-50%); } +.rosette .down { bottom: 22px; left: 50%; transform: translateX(-50%); } +.rosette .left { left: 22px; top: 50%; transform: translateY(-50%); } +.rosette .right { right: 22px; top: 50%; transform: translateY(-50%); } +.rosette .hub { + position: absolute; inset: 50% auto auto 50%; transform: translate(-50%, -50%); + width: 58px; height: 58px; border-radius: 50%; + border: 1px solid var(--line-soft); background: #fff; + display: grid; place-items: center; + color: #C6CBD3; font-size: 13px; +} + +.extrude-bar { + margin: 30px 0 0; + width: 235px; height: 50px; + border: 1px solid var(--line); border-radius: 10px; + background: #fff; + display: grid; place-items: center; + color: #C6CBD3; letter-spacing: .1em; +} + +/* ---- printing task -------------------------------------------------- */ +/* The job card fills the panel from the top; only the empty states are centred. + Centring the card left a band of white above the badge that the original has not. */ +/* Exactly as tall as its card, and no taller. + This was pinned at 150px on the reasoning that a job card "has a natural height and no + reason to grow" - the second half was true and the first was never measured. The card + was 304, `.panel-body` hides its overflow, and the progress bar and both job buttons + were cut off with nothing to say they had been. + Then it was given `min-height: 150` as a floor for an idle machine - and the card + became a band, so the floor stopped being a floor and started being 22px of nothing. + There is no floor now: the card always draws a 96px thumbnail, so it cannot collapse, + and whatever it comes to is what the panel is. The camera above takes the rest. */ +.task-body { display: grid; place-items: center; } +.task-body:has(> .job) { place-items: start stretch; } +.task-body img.placeholder { width: 803px; max-width: 100%; height: auto; display: block; } +.task-body .empty { color: #9AA3AF; font-size: 13px; } + +/* ---- trace (development aid, not part of the shipped page) ---------- */ +.trace-panel { width: 100%; } +.trace-panel summary { + cursor: pointer; color: #9AA3AF; font-size: 12px; + font-family: var(--mono); padding: 6px 2px; +} +#trace { + max-height: 150px; overflow: auto; + background: #fff; border: 1px solid var(--line); border-radius: 6px; + padding: 6px 10px; font: 11px/1.6 var(--mono); color: #6B7280; +} +#trace .t { white-space: pre; } +#trace .t-tx, #trace .t-mock-rx { color: var(--accent); } +#trace .t-mock-push { color: #B9BFC8; } + +.status-line { + width: 100%; + font: 11px/1 var(--mono); color: #9AA3AF; + display: flex; justify-content: space-between; align-items: center; +} +.status-line .mode { color: var(--accent); } + +/* shell.js's `status` header kind, at its default class. It had no rule until the + Filament panel used the default - the job card passes `job-badge` - so this is the + plain live label the vocabulary always promised: the panel's own dim ink, beside its + title, saying what the machine is. */ +.head-status { font-size: 13px; color: var(--ink-dim); } + +/* ---- filament panel -------------------------------------------------- */ +.icon-only { + border: 0; background: none; cursor: pointer; + width: 20px; height: 20px; padding: 0; + display: grid; place-items: center; +} +.icon-only img { width: 20px; height: 20px; } + +.filament-body { + /* The panel has two shapes and the MACHINE picks: `.slot-row` when the printer + reports no `ace` object, `.ace-grid` when it does. The body itself is only the + padding they share. */ + padding: 8px 20px 9px; + min-height: 260px; +} + +.slot-row { + display: flex; align-items: center; justify-content: center; + height: 100%; min-height: 243px; + /* cards are 64 wide and the measured pitch is 174; let the gap shrink + rather than overflow once a grid track gets narrow */ + gap: clamp(24px, 8%, 110px); +} + +/* ---- the multiACE card ---------------------------------------------- + Settled across five studies; the reasoning is in + docs/u1-webui/02-device-page/10-multiace-filament.md and the drawing it is settled + as is 02-device-page/multiace-cabinet.html. + + Every number here was measured in WebKitGTK at 1920x1080, where this panel's body is + 456px - and `.panel-body` is `overflow: hidden`, so a body past it is clipped in + silence. Two rows of card at 215 plus an 8px gap plus 17 of padding is 455. + + Four toolheads, two by two: a subpanel per head, its header choosing what feeds it. + Head-major pays for SHARING a unit (one cabinet drawn once per head it feeds) and + unit-major pays for COUNTING them (four open unit boxes plus a bus is 815 against + 456) - and four units on a four-head machine is the common shape, so head-major pays + in the cheaper currency. It also never folds: four units is sixteen bays drawn in the + same height as none, because a card shows only its OWN source. */ +.ace-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + +.ace-card { + border: 1px solid var(--line); border-radius: var(--radius); + background: var(--panel); overflow: hidden; min-width: 0; +} +.ace-card.is-ace { border-color: rgba(12, 99, 226, .35); } +/* One unit may feed several heads - ACE_SET_HEAD_ACE says nothing about the reverse - + so the same cabinet is drawn on more than one card. Four copies bound to one state + object cannot disagree; what survives is the READING risk, and this is where it is + answered: a dashed edge, a mark in the unit's own pill, and the sentence in its + title. */ +.ace-card.is-shared { border-style: dashed; } + +/* The card's own header is the panel header's language one step down: the same rule + under it, the same ink, a smaller height because it is subordinate. Two lines, + because the first has no room for the three things that are not optional + information - which ACE it is, how wet it is, and whether it is drying. */ +.ace-head { + display: flex; flex-direction: column; align-items: stretch; gap: 3px; + padding: 2px 10px 3px; + background: var(--pill); border-bottom: 1px solid var(--line); +} +.ace-hrow { display: flex; align-items: center; gap: 7px; width: 100%; min-width: 0; } +.ace-hrow .spacer { flex: 1; } +.ace-name { font-size: 12.5px; color: var(--ink); white-space: nowrap; } + +/* A list, not an icon switch. The icon triple was 102px wide and could not name WHICH + ACE; with four units the question has six answers. */ +.ace-src { + height: 22px; max-width: 140px; min-width: 0; + border: 0; border-radius: 4px; padding: 0 22px 0 7px; + background-color: var(--pill); color: var(--ink); + font: 12px/1 var(--sans); cursor: pointer; + appearance: none; -webkit-appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; background-position: right 6px center; +} +.ace-src:hover:not(:disabled) { color: var(--accent); } +.ace-src:disabled { opacity: .42; cursor: not-allowed; } +.ace-src:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +/* Held on screen until the machine agrees it happened - see core/pending.js. */ +.ace-src[data-pend="sent"] { box-shadow: inset 0 0 0 1px var(--accent); } +.ace-src[data-pend="lost"] { box-shadow: inset 0 0 0 1px var(--warn); } + +.ace-more { height: 22px; font-size: 13px; color: var(--ink-dim); line-height: 1; } +.ace-more:hover { color: var(--accent); } + +/* The unit's own line. A card with no ACE names its module here rather than holding an + empty spacer, which alignment required anyway. */ +.ace-strip { + display: flex; align-items: center; gap: 7px; + height: 17px; padding: 0 4px; margin-bottom: 2px; min-width: 0; +} +.ace-strip .spacer { flex: 1; } +/* The ACE unit, in the three forms docs/ace-mmu/16-ace-visuals.md settles - the same + standard the C++ Prepare page draws from, lifted from Orca's own AMSItem.hpp geometry. + An ACE had been drawn four different ways across the app and this panel had made it + five; the geometry is in shared/js/multiACE.js so there is one drawing of it. + + The badge is 44x26 NOMINAL and takes the unit row's height here: the row is 17 because + the panel's body is 456, which is measured and immovable, and zooming the nominal + drawing is the standard's own mechanism rather than a second set of proportions. */ +.ace-badge { flex: 0 0 auto; display: block; } +/* The two line forms at their nominal sizes, in the slots the standard names for them: + the square in a menu row, the wide one in a label. */ +.menu-item .ace-glyph-sq { width: 24px; height: 24px; flex: 0 0 24px; } +.ace-glyph-wide { display: block; width: 44px; height: 26px; color: var(--ink-dim); } +.ms-note .ace-glyph-wide { float: left; margin: 0 10px 4px 0; } +/* The feeder module is not a cabinet, and the ACE's wide badge for it only ever read as a + squashed ACE. */ +.ace-modbadge { width: 17px; height: 17px; } +.ace-unit { + font: 11px/1.3 var(--mono); color: var(--ink-dim); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; +} +.ace-unit b { color: var(--ink); font-weight: 600; } + +/* AMSHumidity's own shape: two readings and one divider, on the block grey. Untinted - + the tinted variant said what the number already said and made a reading that is + usually fine look like a warning. */ +.ace-hum { + height: 17px; display: inline-flex; align-items: center; gap: 4px; padding: 0 7px; + border-radius: 10px; font: 11px/1 var(--sans); white-space: nowrap; + background: #EEEEEE; color: var(--ink); box-shadow: inset 0 0 0 1px #DEDEDE; +} +.ace-sep { width: 1px; height: 11px; background: #C2C2C2; } +.ace-share { opacity: .75; } + +/* The chip offers `Dry` while it is idle and reports elapsed OF TOTAL while it runs - + where you are and how long it was set for, in 8px more than "2 h 41 m left". Clicking + it is the only thing that opens the dryer dialog. */ +.ace-dry { + height: 17px; flex: 0 0 auto; + border: 1px solid var(--line); border-radius: 9px; + background: var(--panel); color: var(--ink-dim); font: 11px/1 var(--sans); + padding: 0 8px; cursor: pointer; + display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; +} +.ace-dry:hover { border-color: var(--accent); color: var(--accent); } +.ace-dry.is-running { background: #FDF1E3; border-color: #E3B57A; color: #8A5A12; } + +.ace-body { + position: relative; min-width: 0; + padding: 4px 9px 5px; + display: flex; flex-direction: column; gap: 2px; align-items: stretch; +} +/* The manifold sits below the cabinet, so the drop from a bay has to cross the box to + reach it - and filament inside a machine is not drawn over the machine. The lane is + the room the bar needs. */ +.ace-lane { position: relative; height: 9px; } +.ace-wire { + position: absolute; inset: 0; width: 100%; height: 100%; + overflow: visible; pointer-events: none; z-index: 0; +} + +/* `.slot`'s own geometry, at `.slot`'s own size: a 36px disc over a 58x19 name pill, + 6px apart. Splitting the bay across the cabinet's two halves is what made that + affordable - with both in the upper half the box was 71px and a bay had to shrink to + 26/52, which would have been a second language for the same substance. */ +.ace-box { --disc: 36px; --pillw: 58px; --pillh: 19px; --bay-gap: 6px; } +/* The seam runs through the MIDDLE of the spool, as a hard colour stop, so half of + every roll is in each half. Disc-relative, which is why widening the gap under the + roll moves the chip without moving the seam. */ +.ace-cab, .ace-feed { position: relative; --seam: calc(5px + var(--disc) / 2); } +.ace-cab-top, .ace-feed-top { + position: relative; z-index: 2; + width: fit-content; margin: 0 auto; + padding: 5px 16px; border-radius: 9px; +} +/* Orca's own AMS neutrals: AMS_CONTROL_DEF_BLOCK_BK_COLOUR over + AMS_CONTROL_DISABLE_COLOUR. The box hugs its four spools rather than filling the + card - at four bays, full width was mostly empty grey. */ +.ace-cab-top { + background: linear-gradient(180deg, #EEEEEE 0 var(--seam), #CECECE var(--seam) 100%); +} +/* The stock feeder is the same drawing in the module's own black and white: Snapmaker's + Automatic Filament Feeder Module is a different device and one glance should say so. + A frame may change HORIZONTAL padding and nothing else, or a feeder's spool stops + sitting at an ACE bay's exact height. */ +.ace-feed-top { + background: linear-gradient(180deg, #FFFFFF 0 var(--seam), #1F1F1F var(--seam) 100%); + box-shadow: inset 0 0 0 1px #D9DDE3; +} +.ace-bays, .ace-feed-row { + display: flex; gap: 10px; justify-content: center; align-items: flex-start; + position: relative; +} + +/* ---- multi mode: the lane, and the unit row that left the card ------- + In multi, bay i is plumbed to head i, so a card holds slot i of every cabinet rather + than one cabinet's four - the same drawing, one bay wide, once per unit. The unit's own + row then belongs to the PANEL rather than to any card, which is what the band is; in + normal mode the same band sits below the grid instead, because an idle cabinet still + reports humidity and can still dry. + + Measured in WebKitGTK at 1920x1080 against the same 456 budget: multi 437, normal 443. + Both fit only because the card's second header line goes with the unit row - four + copies of `Feeder · channel 1` put normal at 497. */ +.ace-lanes { + display: flex; gap: 12px; justify-content: center; align-items: flex-start; + position: relative; +} +.ace-band { display: flex; gap: 10px; min-width: 0; } +.ace-band .ace-strip { flex: 1; min-width: 0; height: 22px; margin: 0; padding: 0 6px; } +.ace-grid + .ace-band { margin-top: 6px; } +.ace-band + .ace-grid { margin-top: 2px; } + +/* A bay of this head's lane that another head is recorded as being fed from. Only + reachable on a machine whose tubes do not match its mode - which is the machine this was + measured on - so it is drawn rather than assumed away. The note is absolute: it must not + move the spool off the line its three neighbours sit on. */ +.ace-bay.is-lent .ace-chip { background: #B7BDC6; } +.ace-lent { + position: absolute; left: 50%; transform: translateX(-50%); + top: calc(var(--disc) + var(--bay-gap) + var(--pillh) + 2px); + font: 10px/13px var(--sans); color: var(--ink-dim); white-space: nowrap; + pointer-events: none; +} + +/* Where the filament came from, when that is not this head's own lane. `head_source` + survives a mode switch, so a machine plumbed for head mode and put into multi reports + exactly this - and the panel draws the machine's record rather than the mode's claim. */ +.ace-from { + align-self: center; margin-top: 2px; height: 16px; + display: inline-flex; align-items: center; gap: 5px; padding: 0 7px; + border-radius: 8px; background: var(--pill); + font: 10.5px/1 var(--sans); color: var(--ink-dim); white-space: nowrap; +} +.ace-from-dot { + width: 8px; height: 8px; border-radius: 50%; display: inline-block; + background: #B7BDC6; +} + +/* The head picker in the mode dialog: which toolhead is on the cabinet. */ +.hrow { + width: 100%; display: flex; align-items: center; gap: 10px; + padding: 8px 10px; margin-bottom: 6px; + border: 1px solid var(--line); border-radius: 8px; background: var(--panel); + font: 12.5px/1 var(--sans); color: var(--ink); cursor: pointer; text-align: left; +} +.hrow:hover { border-color: var(--accent); } +.hrow[aria-pressed="true"] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); } +.hrow .hsub { margin-left: auto; font: 10.5px/1 var(--mono); color: var(--ink-dim); } + +.ace-bay { + position: relative; display: block; cursor: pointer; + border: 0; background: none; padding: 0; + width: calc(var(--pillw) + 4px); flex: 0 0 calc(var(--pillw) + 4px); +} +/* Nothing is drawn round a bay at rest: anything permanent there either hides the seam + that runs through the roll or fights it. The shape appears only under the pointer, as + a 1.5px border with no fill - the blue the rail already uses for the selected + destination - drawn as a backing BEHIND the bay and inset negatively, so it costs no + layout and the bays keep their exact positions whether it is there or not. */ +.ace-bay::before { + content: ""; position: absolute; inset: -4px; border-radius: 11px; + z-index: 0; pointer-events: none; transition: box-shadow .1s; +} +.ace-bay:hover::before, .ace-bay.is-hover::before { + box-shadow: inset 0 0 0 1.5px var(--accent); +} +.ace-disc { + position: relative; z-index: 1; margin: 0 auto; + width: var(--disc); height: var(--disc); border-radius: 50%; + display: grid; place-items: center; + font: 500 calc(var(--disc) * .33) / 1 var(--mono); color: #333; + box-shadow: 0 0 0 3px #FFFFFF; + /* the checkerboard is this page's word for EMPTY, exactly as the shipped page draws + it - which is why an occupied-but-unnamed bay must never wear it */ + background-image: + linear-gradient(45deg, #C4C4C4 25%, transparent 25%, transparent 75%, #C4C4C4 75%), + linear-gradient(45deg, #C4C4C4 25%, transparent 25%, transparent 75%, #C4C4C4 75%); + background-size: 8px 8px; background-position: 0 0, 4px 4px; background-color: #E4E4E4; +} +.ace-disc[data-loaded="1"] { background-image: none; } +.ace-bay.is-fed .ace-disc { box-shadow: 0 0 0 2px var(--ok), 0 0 0 5px #FFFFFF; } +.ace-chip { + position: relative; z-index: 1; display: block; + margin: var(--bay-gap) auto 0; + width: var(--pillw); height: var(--pillh); line-height: var(--pillh); + border-radius: calc(var(--pillh) / 2); + background: #6E6E6E; color: #fff; + font-size: calc(var(--pillh) * .66); text-align: center; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.ace-bay.is-unknown .ace-chip { background: #B7BDC6; } + +/* An RFID tag carries vendor, type, colour and temperatures and none of it is ours to + overwrite, so it gets an eye where a typed value gets a pencil. On the roll, + bottom-right: `.slot`'s own placement under the chip is the only one of five that + costs height, and it costs 33px more than the panel has. */ +.ace-prov { + position: absolute; z-index: 3; pointer-events: none; + left: calc(50% + var(--disc) / 2 - 13px); top: calc(var(--disc) - 12px); + width: 17px; height: 17px; border-radius: 50%; + display: grid; place-items: center; + color: #4A5563; background: #fff; box-shadow: 0 1px 2px rgba(16, 24, 40, .22); +} +.ace-bay:hover .ace-prov { color: var(--accent); } + +/* The tag mark, mirrored to the other side of the same roll. Two marks, two questions: + the right one is whether the record may be edited, the left one is whether the spool + identified itself over the air. Absolute like its twin, so a spool that has one and a + spool that has not occupy exactly the same box - a bay is 62px and nothing here may + move when a tag appears. Green, because that is the colour `.slot .slot-tag` already + uses for the same fact in the four-slot form. */ +.ace-tag { + position: absolute; z-index: 3; pointer-events: none; + left: calc(50% - var(--disc) / 2 - 4px); top: calc(var(--disc) - 12px); + width: 17px; height: 17px; border-radius: 50%; + display: grid; place-items: center; + color: #16A34A; background: #fff; box-shadow: 0 1px 2px rgba(16, 24, 40, .22); +} +.ace-glyph { display: block; } + +/* The toolhead is a control: every one of these macros addresses `HEAD=n`, and it is the + one target that does not shrink with the panel. It wears the bay's own edge under the + pointer - a 1.5px traced accent, no fill, drawn as a backing BEHIND the artwork and + inset negatively so nothing moves - because two things you can click on one card should + not answer differently. Tight to the drawing: the element's top ~9px is the two inlet + hairlines, so the box is trimmed there. */ +.ace-tool.is-target { cursor: pointer; } +.ace-tool.is-target::before { + content: ""; position: absolute; inset: 5px -5px 0; border-radius: 8px; + z-index: 0; pointer-events: none; +} +.ace-tool.is-target:hover::before, +.ace-tool.is-target.is-hover::before, +.ace-tool.is-target:focus-visible::before { box-shadow: inset 0 0 0 1.5px var(--accent); } +/* `.is-hover` beside `:hover` for the same reason `.ace-bay` has one: a pointer cannot be + held across a re-render, and a screenshot under WSL proves nothing - so the state is + reachable by class and the edge is measured rather than looked at. */ +.ace-tool.is-target:focus-visible { outline: none; } + +/* The bays, brought to a sheet. Two of the five verbs take a SLOT= and a toolhead is not + one, so where the verb is chosen away from the cabinet the bays have to come to it - + each button saying what clicking it would do in the state the head is in. */ +.pickrow { display: flex; gap: 8px; margin: 8px 0 2px; flex-wrap: wrap; } +.pickbay { + flex: 1 1 0; min-width: 62px; cursor: pointer; + display: flex; flex-direction: column; align-items: center; gap: 6px; + padding: 8px 6px; border: 1px solid var(--line); border-radius: 8px; + background: var(--panel); color: var(--ink); font: 11.5px/1.2 var(--sans); +} +.pickbay:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.pickbay:disabled { cursor: not-allowed; opacity: .5; } +.pickdisc { + width: 30px; height: 30px; border-radius: 50%; + display: grid; place-items: center; + font: 500 10.5px/1 var(--mono); color: #333; + background-image: + linear-gradient(45deg, #C4C4C4 25%, transparent 25%, transparent 75%, #C4C4C4 75%), + linear-gradient(45deg, #C4C4C4 25%, transparent 25%, transparent 75%, #C4C4C4 75%); + background-size: 8px 8px; background-position: 0 0, 4px 4px; background-color: #E4E4E4; +} +.pickbay .picklab { white-space: nowrap; } + +/* ---- what the machine is doing to this head ------------------------------------- + In the GUTTER beside the artwork, which is the only space this card has spare: the + toolhead is 32px wide in a 371px cell, and a row under the box measures 479 against a + 456 budget - `.panel-body` is `overflow: hidden`, so that row would have been clipped + rather than seen. + + One tick per step of `channel_state`'s own list. Determinate, because the machine + names WHICH of a known list it is in - not a percentage derived from a phase word. */ +.ace-flight { + position: absolute; left: 2px; top: 50%; transform: translateY(-50%); + display: flex; flex-direction: column; align-items: flex-start; gap: 3px; + width: calc(50% - 22px); + font: 11px/1.25 var(--sans); color: #8A5A12; + pointer-events: none; +} +.ace-flight.is-fail { color: #A4331F; } +.ace-flight-at { + max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font: 500 10px/1 var(--mono); letter-spacing: .02em; + padding: 2px 6px; border-radius: 8px; background: #FDF1E3; +} +.ace-flight.is-fail .ace-flight-at { background: #FBE9E6; } +.ace-steps { display: flex; gap: 3px; align-items: center; } +.ace-steps i { width: 11px; height: 3px; border-radius: 2px; background: #E4E4E4; } +.ace-steps i.is-done { background: #C9862A; } +.ace-steps i.is-now { background: #E3B57A; box-shadow: inset 0 0 0 1px #C9862A; } + +/* ---- a bay's own sheet ------------------------------------------------------------ + The verbs that take a `SLOT=`, where the slot is the thing you clicked. The macro + under each is not an explanation - it is the line that will be sent, which is the one + piece of prose this panel's copy rule allows in a dialog. */ +.verb-id { display: flex; align-items: center; gap: 10px; margin: 2px 0 0; } +.verb-disc { + width: 36px; height: 36px; border-radius: 50%; flex: 0 0 36px; + display: grid; place-items: center; background: #E4E4E4; + font: 500 12px/1 var(--mono); color: #333; +} +.verb-idtext { font: 12.5px/1.4 var(--sans); color: var(--ink); min-width: 0; } +.verb-idtext span { display: block; color: var(--ink-dim); font-size: 11.5px; } +.verbs { display: flex; flex-direction: column; gap: 6px; margin: 12px 0 2px; } +.verb { + display: flex; align-items: flex-start; gap: 10px; width: 100%; text-align: left; + padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; + background: var(--panel); color: var(--ink); font: 12.5px/1.3 var(--sans); + cursor: pointer; +} +.verb:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.verb:disabled { cursor: not-allowed; opacity: .55; } +.verb-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } +.verb-name { font-weight: 600; } +.verb-cmd { font: 10.5px/1.35 var(--mono); color: var(--ink-dim); overflow-wrap: anywhere; } +.verb:disabled .verb-cmd { color: #A4331F; } +/* ACE_BG_SET_HEAD's help says what declaring a head means physically - the dock below is + open, the cold-pull purges ~60 mm through it - so it is asked for separately rather + than folded into the verb that needs it. */ +.verb-gate { + margin-top: 4px; align-self: flex-start; cursor: pointer; + border: 1px solid var(--accent); border-radius: 6px; background: var(--panel); + color: var(--accent); font: 500 11px/1 var(--sans); padding: 5px 8px; +} +.verb-gate:hover { background: var(--accent-soft, rgba(12, 99, 226, .08)); } + +/* The head carries no filament identity any more - no colour, no material, no pencil. + What it has of its own is a SENSOR, and the printer reports that properly. */ +.ace-toolwrap { + position: relative; /* the in-flight line sits in its gutter */ + display: flex; justify-content: center; align-items: flex-end; +} +.ace-tool { + position: relative; --s: .5; + width: calc(64px * var(--s)); height: calc(140px * var(--s)); + background: url("../icons/extruderBackground.svg") no-repeat center / 100% 100%; +} +/* Centred on the artwork's BODY: extruderBackground.svg draws that body y=17.4..127.6 + of its 64x140, so the middle is (32, 72.5) at whatever scale. */ +.ace-sensor { + position: absolute; box-sizing: content-box; + left: calc(32px * var(--s) - 7.5px); top: calc(72.5px * var(--s) - 7.5px); + width: 11px; height: 11px; border-radius: 50%; border: 2px solid var(--panel); +} +.ace-sensor.is-at { background: var(--ok); } +.ace-sensor.is-tube { background: #E8A33D; } +.ace-sensor.is-none { + background: var(--panel); border-color: #B7BDC6; box-shadow: inset 0 0 0 1px #B7BDC6; +} +.ace-sensor.is-err { background: var(--err); } + +/* Two `...` a few pixels apart mean two different scopes - the panel's is the printer, + a card's is one toolhead - so each menu names its own on the way open, and each item + names the macro behind it. */ +.menu-head { + font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; + color: #9AA3AF; padding: 5px 16px 6px; font-weight: 600; +} +/* A label wraps to three lines rather than widening the menu once a glyph and a macro + name share its row. The menu has no fixed width - only a minimum - so letting the + label refuse to break is what makes it grow instead. */ +.menu-item > span:not(.mcmd) { white-space: nowrap; } +.menu-item .mcmd { + margin-left: auto; font: 10px/1 var(--mono); color: #B9BFC8; padding-left: 10px; +} + +/* ---- the dryer dialog ----------------------------------------------- + Bambu's AMS Dryness Control, in this page's own dialog chrome. It answers four + questions - how wet is it, is it drying, how hard, for how long - and it answers the + first AS A QUANTITY, which is most of why it works. The running dialog is the SAME + dialog: an amber droplet was tried and taken out, because it made the reading you + came for change meaning at the moment you came for it. */ +.dry-sub { margin: 0 0 10px; font: 11px/1.4 var(--mono); color: var(--ink-dim); } +.dry-cols { display: flex; gap: 18px; } +.dry-c1 { width: 158px; flex: 0 0 158px; } +.dry-c2 { flex: 1; min-width: 0; } +.dry-drop { width: 88px; height: 104px; position: relative; margin: 2px auto 0; } +.dry-drop svg { display: block; } +/* One word, whatever the state: a two-line state wraps in this column and grows the + block under the droplet, which reads as the droplet having moved. */ +.dry-state { + text-align: center; font-size: 13px; font-weight: 600; color: var(--ink); margin-top: 6px; +} +.dry-reads { display: flex; justify-content: center; margin-top: 10px; } +.dry-read { flex: 1; text-align: center; padding: 0 8px; } +.dry-read + .dry-read { border-left: 1px solid var(--line); } +.dry-k { display: block; font-size: 11px; color: var(--ink-dim); } +.dry-v { display: block; font-size: 16px; color: var(--ink); margin-top: 1px; } +.dry-field { margin-bottom: 10px; } +.dry-field .dry-k { margin-bottom: 4px; } +/* A preset OR a typed value, never both, and in ONE row because it is one choice: the + macro takes a number and not a menu, and a spool whose tag says 65 °C for 8 h should + not have to be rounded to the nearest chip. */ +.dry-seg { + display: inline-flex; align-items: center; flex-wrap: wrap; gap: 2px; + background: var(--pill); border-radius: 6px; padding: 3px; +} +.dry-opt { + padding: 4px 9px; border-radius: 4px; font-size: 12px; color: var(--ink-dim); + cursor: pointer; line-height: 1.3; +} +.dry-opt.is-on { + background: #fff; color: var(--ink); box-shadow: 0 1px 2px rgba(16, 24, 40, .10); +} +.dry-custom { + width: 62px; height: 24px; margin-left: 3px; + border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; + font: 12px/1 var(--sans); color: var(--ink); background: #fff; + -moz-appearance: textfield; +} +.dry-custom::-webkit-inner-spin-button, +.dry-custom::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } +.dry-custom::placeholder { color: #9AA3AF; } +.dry-custom:focus { outline: 2px solid var(--accent); outline-offset: -1px; } +.dry-custom.is-on { border-color: var(--accent); font-weight: 600; } +.dry-note { margin: 6px 0 0; font-size: 11.5px; color: var(--ink-dim); line-height: 1.5; } +/* What would be sent, updating as you type. An ACE macro is slow and physical; being + able to read it before pressing the button is worth a line. */ +.dry-cmd { + margin-top: 8px; padding-top: 7px; border-top: 1px dashed var(--line); + font: 10.5px/1.6 var(--mono); color: #9AA3AF; word-break: break-word; +} + +/* Each slot is the bundle's own 64x140 extruder artwork with the live values + laid over it, at the offsets measured off the shipped page. */ +.slot { + position: relative; + width: 64px; height: 140px; + background: url("../icons/extruderBackground.svg") no-repeat center / 64px 140px; + border: 0; padding: 0; cursor: pointer; +} +.slot .dot { + position: absolute; left: 15px; top: 29px; + width: 36px; height: 36px; border-radius: 50%; + display: grid; place-items: center; + font-size: 14px; color: #333; + /* checkerboard reads as "no filament", exactly as the shipped page draws it */ + background-image: + linear-gradient(45deg, #C4C4C4 25%, transparent 25%, transparent 75%, #C4C4C4 75%), + linear-gradient(45deg, #C4C4C4 25%, transparent 25%, transparent 75%, #C4C4C4 75%); + background-size: 8px 8px; + background-position: 0 0, 4px 4px; + background-color: #E4E4E4; +} +.slot .dot[data-loaded="1"] { background-image: none; } +.slot .bar { + position: absolute; left: 3px; top: 71px; + width: 58px; height: 19px; border-radius: 10px; + background: #6E6E6E; color: #fff; + font-size: 11px; line-height: 19px; text-align: center; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.slot .pencil { + position: absolute; left: 50%; top: 100px; transform: translateX(-50%); + width: 20px; height: 20px; opacity: .8; + border-bottom: 1px solid #9B9B9B; padding-bottom: 2px; +} +.slot:hover .pencil { opacity: 1; } +.slot:disabled { cursor: default; } + +/* ---- anchored menu --------------------------------------------------- */ +.menu { + position: fixed; z-index: 200; min-width: 214px; + background: #fff; border-radius: 10px; + box-shadow: 0 6px 24px rgba(16, 24, 40, .14), 0 1px 3px rgba(16, 24, 40, .08); + padding: 6px 0; +} +.menu-item { + width: 100%; display: flex; align-items: center; gap: 10px; + padding: 11px 16px; border: 0; background: none; + font: 14px/1 var(--sans); color: var(--ink-nav); cursor: pointer; text-align: left; +} +.menu-item:hover { background: #F2F5FA; } +.menu-item img { width: 18px; height: 18px; } +.menu-item.is-muted { color: #9AA3AF; } +.menu-sep { height: 1px; background: var(--line-soft); margin: 5px 12px; } + +/* ---- dialog ---------------------------------------------------------- */ +.scrim { + position: fixed; inset: 0; z-index: 300; + background: rgba(16, 24, 40, .32); + display: grid; place-items: center; +} +.dialog { + width: 360px; max-width: calc(100vw - 40px); + background: #fff; border-radius: 12px; + box-shadow: 0 12px 40px rgba(16, 24, 40, .22); + overflow: hidden; +} +.dialog.wide { width: 520px; } +.dialog-head { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 18px 8px; +} +.dialog-head h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--ink-nav); } +.dialog-x { + border: 0; background: none; cursor: pointer; + font-size: 20px; line-height: 1; color: #9AA3AF; padding: 0 2px; +} +.dialog-body { padding: 6px 18px 4px; } +.dialog-foot { + display: flex; justify-content: flex-end; gap: 10px; + padding: 14px 18px 18px; +} +.btn { + font: 14px/1 var(--sans); padding: 9px 18px; + border: 1px solid var(--line); border-radius: 8px; + background: #fff; color: var(--ink); cursor: pointer; +} +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn:hover { border-color: #C7CDD6; } +.btn.primary:hover { background: #0B58C9; } + +.field { display: block; margin: 12px 0 16px; } +.field-label { display: block; font-size: 13px; color: var(--ink-nav); margin-bottom: 6px; } +.field-row { display: flex; align-items: center; gap: 8px; } +.field-row input { + flex: 1; font: 14px/1 var(--sans); padding: 9px 10px; + border: 1px solid var(--line); border-radius: 8px; color: var(--ink); +} +.field-row input:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); } +.field-unit { color: #9AA3AF; font-size: 13px; } +.field-hint { display: block; margin-top: 6px; font-size: 12px; color: #9AA3AF; } +.field-toggle { + display: flex; align-items: center; justify-content: space-between; + padding: 11px 0; font-size: 13px; color: var(--ink-nav); + border-top: 1px solid var(--line-soft); +} + +/* connection dot beside the device name in the rail */ +.device-select .dev-state { + width: 7px; height: 7px; border-radius: 50%; + flex: 0 0 7px; background: #C9CED6; +} +.device-select .dev-state[data-state="on"] { background: #1F9254; } +.device-select .dev-state[data-state="off"] { background: #D9A23B; } +.device-select .dev-state[data-state="none"] { background: #C9CED6; } + +/* ---- print job ------------------------------------------------------- */ +/* ---- the job card ---------------------------------------------------- * + * One card at every state, zeroed rather than replaced. Laid out off the shipped + * page: badge and machine name on top, a square thumbnail beside the numbers, the + * bar across the full width, and one round control under it. */ +/* ---- the printing task card ------------------------------------------ * + * A band: a 96px thumbnail anchoring three rows. See task-view.js for what each move + * was worth - the buttons riding the bar's line is most of it. */ +.job { + width: 100%; padding: 16px 20px; + display: flex; gap: 18px; align-items: flex-start; +} +.job-thumb { + flex: 0 0 96px; width: 96px; height: 96px; + display: grid; place-items: center; overflow: hidden; + border: 1px solid var(--line); border-radius: 8px; background: var(--panel); +} +.job-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; } +.job-thumb .job-thumb-ph { width: 72px; } + +.job-col { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px; } +/* Every row is the same shape: something that shrinks on the left, something that does + not on the right. That is what keeps three unrelated rows reading as one block. */ +.job-row { display: flex; align-items: center; gap: 12px; min-width: 0; } + +.job-name { + flex: 1 1 auto; min-width: 0; + font-size: 13px; color: var(--ink-2); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.job-meta { + flex: 0 0 auto; display: flex; gap: 14px; + font: 11px/1 var(--mono); color: var(--ink-dim); + font-variant-numeric: tabular-nums; +} +/* Each of the four figures is one token and must never be broken across lines: they sit + in rows that shrink, and a wrapped "0h 22m" makes the row taller than the band. */ +.job-elapsed, .job-used, .job-layer, .job-time { white-space: nowrap; } +.job-time { text-align: right; } +/* The percentage is still the thing you see first - it just stopped being 40px of grey + above a bar saying the same thing. Green, because it is the one number on this card + that is about progress rather than about the file. */ +.job-pct { + flex: 1 1 auto; + font-size: 26px; line-height: 1; font-weight: 600; + color: var(--ok); font-variant-numeric: tabular-nums; +} +.job-nums { + flex: 0 0 auto; display: flex; gap: 14px; + font-size: 12px; color: var(--ink-dim); font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.job-bar { + flex: 1 1 auto; min-width: 0; + height: 6px; border-radius: 999px; background: #EEF1F5; overflow: hidden; +} +.job-bar > div { height: 100%; background: var(--accent); } +.job-actions { flex: 0 0 auto; display: flex; gap: 10px; } +.job-btn { + width: 30px; height: 30px; padding: 0; + display: grid; place-items: center; + border: 0; border-radius: 50%; background: var(--pill); cursor: pointer; +} +.job-btn svg { width: 18px; height: 18px; fill: #9AA3AF; } +.job-btn:not(:disabled):hover { background: var(--line); } +.job-btn:not(:disabled) svg { fill: var(--ink-dim); } +.job-btn:disabled { cursor: default; } +.job-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.job-msg { font-size: 12px; color: var(--ink-dim); } + +/* The status badge, now a header item. The colours stay here rather than moving with it, + because they are the card's language and the card is what they describe. */ +.job-badge { + padding: 2px 7px; border-radius: 4px; + background: #16A34A; color: #FFFFFF; + font-size: 11px; font-weight: 600; text-transform: lowercase; +} +.job-badge[data-state="printing"] { background: var(--accent); } +.job-badge[data-state="paused"] { background: var(--warn); } +.job-badge[data-state="error"], +.job-badge[data-state="failed"] { background: var(--err); } + +.job-meta-row { + display: flex; gap: 18px; align-items: center; + font-size: 12px; color: var(--ink-dim); font-variant-numeric: tabular-nums; +} + +/* ---- machine files --------------------------------------------------- */ +.files { width: 100%; height: 100%; padding: 14px 18px; display: flex; + flex-direction: column; gap: 12px; min-height: 0; } +.files-bar { display: flex; gap: 8px; flex-wrap: wrap; } +.chip { + font: 12px/1 var(--sans); padding: 7px 12px; + border: 1px solid var(--line); border-radius: 999px; + background: #fff; color: var(--ink-dim); cursor: pointer; +} +.chip.is-active { border-color: var(--accent); color: var(--accent); } +.file-list { overflow: auto; display: flex; flex-direction: column; min-height: 0; } +/* An action, not a filter: it sits apart from the root chips and carries a glyph. */ +.icon-btn { + margin-left: auto; + width: 28px; height: 28px; padding: 0; + display: grid; place-items: center; + border: 1px solid var(--line); border-radius: 6px; + background: var(--panel); cursor: pointer; +} +.icon-btn img { width: 16px; height: 16px; opacity: .7; } +.icon-btn:hover { background: var(--pill); } +.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.file-row { + display: flex; align-items: center; gap: 12px; + padding: 10px 4px; border-bottom: 1px solid var(--line-soft); +} +.file-row img { width: 18px; height: 18px; opacity: .7; } +.file-meta { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.file-name { font-size: 13px; color: var(--ink); overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.file-sub { font-size: 11px; color: var(--ink-dim); } +.file-row .btn { padding: 6px 12px; font-size: 12px; } + +/* ---- camera ---------------------------------------------------------- */ +.cam-frame { max-width: 100%; max-height: 100%; object-fit: contain; display: block; } + +/* ---- the camera viewport -------------------------------------------- * + * Black, and the control lives inside it. The shipped page shows a dark viewport + * whether or not a frame is playing, with one round button in the middle of it - + * not an illustration with a text button underneath, which is what the rebuild had + * and which says "there is no printer" rather than "the camera is off". */ +.cam-view { + position: relative; + width: 100%; height: 100%; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + background: #000; overflow: hidden; +} + +/* ---- the tile grid --------------------------------------------------- * + * One to four pictures in one viewport. The printer offers three live views - the case + * camera, the USB camera and its own touchscreen - and watching two at once is the + * reason to have a second camera at all. + * + * A tile is an a poller re-points; nothing here decides what it shows. The tiles + * are absolutely positioned into a grid rather than laid out by content, so a frame + * arriving at a different resolution cannot move the tile it is in. */ +.cam-tiles { + position: absolute; inset: 0; + display: grid; gap: 2px; background: #05080B; +} +.cam-view[data-view="single"] .cam-tiles { grid-template-columns: 1fr; } +.cam-view[data-view="split"] .cam-tiles { grid-template-columns: 1fr 1fr; } +.cam-view[data-view="grid"] .cam-tiles { grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; } +/* Picture-in-picture: the first tile is the picture and the second floats over it. The + inset is by definition the unfocused one, which is what makes PiP the cheapest way to + watch two things - it polls at 1 fps. */ +.cam-view[data-view="pip"] .cam-tiles { grid-template-columns: 1fr; } +.cam-view[data-view="pip"] .cam-tile:nth-child(2) { + position: absolute; right: 12px; top: 12px; + width: 31%; aspect-ratio: 16 / 9; + border-radius: 7px; border: 2px solid rgba(255, 255, 255, .5); + box-shadow: 0 6px 18px rgba(0, 0, 0, .5); + overflow: hidden; z-index: 3; +} +.cam-view[data-view="pip"] .cam-tile:nth-child(n+3) { display: none; } + +.cam-tile { + position: relative; overflow: hidden; min-width: 0; min-height: 0; + background: #0B0F14; +} +.cam-tile.is-empty { display: grid; place-items: center; } +.cam-tile-msg { color: #5D6774; font-size: 11px; font-family: var(--mono); } +/* Which tile is being watched, and therefore which one polls fast. A ring rather than a + tint, because anything drawn over the picture is drawn over the thing being watched. */ +.cam-tile.is-focus { box-shadow: inset 0 0 0 2px var(--accent); } +.cam-tile[tabindex]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.cam-tile-name { + position: absolute; left: 7px; bottom: 6px; z-index: 2; + font: 10.5px/1 var(--mono); color: #fff; + background: rgba(8, 11, 16, .68); padding: 4px 7px; border-radius: 4px; + pointer-events: none; +} +/* A tile whose camera stopped answering keeps its last frame, dimmed, rather than going + black - the usual cause is the printer rebooting, and a stale frame says more than + nothing does while the poller keeps retrying. */ +.cam-tile .cam-frame[data-failed="1"] { opacity: .25; } +.cam-tile .cam-frame { + position: absolute; inset: 0; margin: auto; + max-width: 100%; max-height: 100%; object-fit: contain; +} + +.cam-view .cam-frame { position: absolute; inset: 0; margin: auto; } +.cam-play { + position: relative; z-index: 4; + width: 56px; height: 56px; flex: 0 0 56px; + display: grid; place-items: center; + border: 0; border-radius: 50%; padding: 0; + background: rgba(255, 255, 255, .28); cursor: pointer; + transition: background .15s, opacity .15s; +} +.cam-play svg { width: 30px; height: 30px; fill: #FFFFFF; } +.cam-play:hover { background: rgba(255, 255, 255, .42); } +.cam-play:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* While it is playing the picture is the point, so the control keeps out of the way + until the pointer asks for it. */ +.cam-view:not(:hover) .cam-play[data-on="1"] { opacity: 0; } +/* The caption is hung BELOW the centred button rather than sharing a column with it. + In the flow it was a second flex child, so the pair got centred together and the play + button sat 19px above where the stop button lands - measured 397 against 416 in a + 1920x1080 window. The two states now put the control in the same place, which is what + makes the toggle read as one control changing rather than two controls swapping. + The 42px is the button's own half-height plus the 14px that used to be the gap. */ +.cam-view .cam-msg { + position: absolute; left: 0; right: 0; top: calc(50% + 42px); + text-align: center; color: #FFFFFF; font-size: 13px; +} +.tl-none { color: #6B7280; font-size: 11px; } +.tl-dur { + position: absolute; right: 6px; bottom: 6px; + padding: 1px 5px; border-radius: 3px; + background: rgba(0, 0, 0, .62); color: #fff; + font-size: 11px; font-variant-numeric: tabular-nums; +} + +/* ---- fault banner ---------------------------------------------------- */ +.fault { + width: 100%; + display: flex; align-items: flex-start; gap: 12px; + padding: 13px 16px; + border: 1px solid #F0CFC6; border-left: 3px solid #C0432B; + border-radius: var(--radius); + background: #FDF3F1; +} +.fault[data-severity="warn"] { border-color: #F0DFC0; border-left-color: #B8801F; + background: #FDF8EE; } +.fault img { width: 20px; height: 20px; margin-top: 1px; flex: 0 0 20px; } +.fault-body { flex: 1; min-width: 0; } +.fault-title { font-size: 13px; font-weight: 600; color: #7A2A18; } +.fault[data-severity="warn"] .fault-title { color: #7A5410; } +.fault-desc { font-size: 12px; color: var(--ink-2); margin-top: 3px; line-height: 1.5; } +.fault-code { + font-family: var(--mono); font-size: 11px; color: var(--ink-dim); margin-top: 6px; +} +.fault .btn { padding: 6px 12px; font-size: 12px; } + +/* ---- info sheet ------------------------------------------------------- */ +.info-grid { display: grid; grid-template-columns: auto 1fr; gap: 7px 16px; } +.info-grid dt { color: var(--ink-dim); font-size: 12px; } +.info-grid dd { margin: 0; font-size: 12px; color: var(--ink); + font-variant-numeric: tabular-nums; word-break: break-all; } +.bar-mini { height: 6px; border-radius: 999px; background: #EEF1F5; overflow: hidden; + margin-top: 6px; } +.bar-mini > div { height: 100%; background: var(--accent); } + +/* ---- discovery list --------------------------------------------------- */ +.found-list { display: flex; flex-direction: column; max-height: 260px; overflow: auto; } +.found-row { + display: flex; align-items: center; gap: 10px; + padding: 9px 2px; border-bottom: 1px solid var(--line-soft); +} +.found-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.found-name { font-size: 13px; color: var(--ink); } +.found-sub { font-size: 11px; color: var(--ink-dim); } + +/* ---- file thumbnail --------------------------------------------------- */ +.file-thumb { + width: 40px; height: 40px; flex: 0 0 40px; border-radius: 4px; + border: 1px solid var(--line); object-fit: cover; background: var(--panel-2); +} + +/* ---- print speed ------------------------------------------------------ */ +.speed-row { + display: flex; align-items: center; gap: 12px; + margin-top: 18px; font-size: 12px; color: var(--ink-dim); +} +.speed-row input[type="range"] { flex: 1; max-width: 260px; accent-color: var(--accent); } +.speed-row .val { font-variant-numeric: tabular-nums; color: var(--ink); min-width: 44px; } + +/* ---- live camera controls ------------------------------------------- + The live-view branch used to render only the frame, which left a running + camera with no way to stop it. The bar sits under the image rather than over + it so it never covers what the user turned the camera on to see. */ +.camera-body .cam-controls { + display: flex; + gap: 10px; + justify-content: center; + margin-top: 10px; +} +.cam-frame[data-failed="1"] { opacity: .25; } + +/* Last-file line under the idle Printing Task placeholder. */ +.task-body .job-last { + margin-top: 4px; + font-size: 12px; + color: #8B8B8B; +} + +/* An axis that is not homed: Klipper will refuse the move, so say so quietly + rather than letting the button look broken. */ +.jog .hub[data-warn="1"] { color: #C9531F; } + +/* ---- tool selection: requested vs live ------------------------------ + A tool change is mechanical and takes seconds, so the button has to say + which of the two states it is in - otherwise a click that is working looks + like a click that did nothing. */ +.seg.tools button[data-live="1"]::after { + content: ""; + width: 5px; height: 5px; + margin-left: 5px; + border-radius: 50%; + background: #16A34A; + display: inline-block; + vertical-align: middle; +} + + + +/* An RFID-identified spool, distinguished from a hand-typed one. */ +.slot .slot-tag { + display: block; + font-size: 9px; + letter-spacing: .04em; + color: #16A34A; + margin-top: 2px; +} + +/* ---- Materials Setting dialog --------------------------------------- */ +.materials .ms-block { + border: 1px solid #E6E6E6; + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 12px; +} +.materials .ms-head { + margin: 4px 0 8px; + font-size: 13px; + font-weight: 600; + color: #262E30; +} +.materials .ms-row { + display: flex; + align-items: baseline; + gap: 12px; + padding: 3px 0; + font-size: 12px; +} +.materials .ms-key { color: #6B7280; flex: 0 0 auto; min-width: 130px; } +.materials .ms-val { color: #262E30; margin-left: auto; text-align: right; } +.materials .ms-row[data-severity="error"] .ms-val { color: #C9531F; } +.materials .ms-note { + font-size: 12px; + line-height: 1.5; + color: #6B7280; + margin: -4px 0 12px; +} +.materials .ms-color { display: flex; align-items: center; gap: 8px; } +.materials .ms-swatch { + width: 18px; height: 18px; + border-radius: 50%; + border: 1px solid #D4D4D4; + flex: 0 0 auto; +} +.materials .ms-color input[type="color"] { + width: 44px; height: 26px; + padding: 0; + border: 1px solid #D4D4D4; + border-radius: 4px; + background: none; +} + +/* ---- print history --------------------------------------------------- */ + +/* The banner is display:flex, which beats the UA stylesheet's [hidden]{display:none} - + so hiding it in JS did nothing and an empty pink bar sat above the panels forever. */ +.fault[hidden] { display: none; } + +/* Speed choices inside the sheet. */ +.seg.speeds.wide { display: flex; margin-top: 4px; } +.seg.speeds.wide button { flex: 1; padding: 7px 0; font-size: 12px; } + +/* ---- pick extruder --------------------------------------------------- */ +.pick-list { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; } +.pick-row { + display: block; + width: 100%; + text-align: left; + padding: 9px 12px; + border: 1px solid #E6E6E6; + border-radius: 8px; + background: #fff; + cursor: pointer; +} +.pick-row[data-chosen="1"] { border-color: var(--accent); background: #F5F9FF; } +.pick-row[data-live="1"] .pick-name::after { + content: ""; + display: inline-block; + width: 5px; height: 5px; + margin-left: 6px; + border-radius: 50%; + background: #16A34A; + vertical-align: middle; +} +.pick-name { font-size: 13px; color: #262E30; } +.pick-meta { font-size: 11px; color: #8B8B8B; margin-top: 2px; } + +/* ---- blocking modal -------------------------------------------------- */ +.dialog.blocking .dialog-body { text-align: center; padding-top: 18px; } +.blocking-msg { font-size: 13px; color: #39434F; margin: 14px 0 4px; line-height: 1.5; } +.spinner { + width: 26px; height: 26px; + margin: 0 auto; + border: 3px solid #E6E6E6; + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 900ms linear infinite; +} +/* The footer's, next to a line of 12px text rather than alone in a dialog. */ +.spinner.sm { width: 13px; height: 13px; border-width: 2px; margin: 0; } +.spinner[hidden] { display: none; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 3s; } } + +/* The pick button sits beside Home and reads as an action, not a mode. */ +.pick-btn { margin-right: 6px; } + + +/* ===================================================================== + Control panel, rebuilt: readings | toolhead picker | motion. + Follows docs/u1-webui/02-device-page/panel-popover-mockup.html + ===================================================================== */ + +.control-grid { + display: flex; + gap: 40px; + align-items: flex-start; + justify-content: space-between; + /* The panel is calc(60% + 219px), so it grows past the 830px this layout was drawn + against. Without a cap, space-between hands every extra pixel to the two gaps and + the three groups drift apart - 162px each at a 1010px panel, against the ~96px the + design uses. Capping the row keeps the geometry the mockup was measured at, and + matches the shipped page, whose control content spans 830 regardless of panel. */ + max-width: 758px; +} +/* The motion column sizes to its content. As flex:1 it swallowed the surplus and + centred the wheel inside itself, which made an equal `gap` render unequally. */ +.control-main { flex: 0 0 auto; display: flex; gap: 40px; align-items: flex-start; } +.status-card { width: 126px; flex: 0 0 126px; border: 0; padding: 0; } +.status-card .status-row + .status-row { margin-top: 2px; } + +/* ---- readings: icon and value, no label ---------------------------- */ +.status-row { + position: relative; + /* 4px, not the 5 the rest of the panel uses: the target field has to hold three + digits (a 350 nozzle, a 100 bed) and 9px per tabular digit plus its own padding + does not fit in a 126px column at 5. Measured in the page, not estimated. + + Centred, because the row's contents are now all fixed width - icon, three digits, + separator, field, unit - so there is nothing left to make centring move. Left + aligned they sat 8px in from one edge and 2px from the other, which read as a + readings column shifted off the quick settings squarely below it. */ + display: flex; align-items: center; justify-content: center; gap: 4px; + height: 38px; padding: 0 6px; + border: 2px solid transparent; border-radius: 8px; + font-size: 13px; color: var(--ink); +} +/* 20px, the size the picker boxes use - a 17px glyph read as a smaller class of + thing than the toolhead it names. */ +.status-row img { width: 20px; height: 20px; flex: 0 0 20px; opacity: .85; } +/* Always three digits of room, right-aligned against the separator. A reading + climbing from 99 to 100 used to push the slash, the target and the unit along with + it, so a row could not be read at a glance while it was moving - and five rows at + different temperatures never lined up with each other. 9.00px per tabular digit, + measured in the page rather than guessed. min-width, not width, so a reading that + somehow needs more room takes it instead of being clipped. */ +.status-row .cur { + min-width: 27px; text-align: right; + font-variant-numeric: tabular-nums; font-weight: 600; +} +.status-row .sl { color: var(--ink-dim); } +.status-row .unit { color: var(--ink-dim); font-size: 12px; } + +/* The target looks like text until the row is hovered. Keyed to the ROW, not the + input: a 30px number is not a target you find by accident. */ +.status-row .tgt { + /* 27px of digits + 6 padding + 2 border. At 30 a target of 220 lost its last digit, + which is worse than an unstyled field: it read as 22. */ + width: 35px; padding: 2px 3px; + font: inherit; font-size: 13px; font-variant-numeric: tabular-nums; + color: var(--ink); background: transparent; + border: 1px solid transparent; border-radius: 5px; + cursor: text; -moz-appearance: textfield; +} +.status-row .tgt::-webkit-outer-spin-button, +.status-row .tgt::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +/* :not() guards, not source order - `.status-row:hover .tgt` outranks `.tgt:focus` + and would mask both the editing outline and the out-of-range warning. */ +.status-row:hover { border-color: var(--line); } +.status-row:hover .tgt:not(:focus):not(:invalid) { + border-color: var(--line); background: var(--pill); +} +.status-row .tgt:focus { + outline: none; border-color: var(--accent); background: var(--panel); +} +.status-row:focus-within { border-color: var(--accent); background: var(--accent-soft); } +.status-row .tgt:invalid { border-color: var(--warn); color: var(--warn); } + +/* ---- what the heater is doing --------------------------------------- * + * The readings column is 126px and every pixel of it is spoken for, so the feedback + * is carried by colour and by a bar under the numbers rather than by another glyph. + * The bar runs from the temperature the ramp started at to the target, so it moves + * for as long as the machine does - which is the point: a set temperature used to + * land in silence, and a nozzle climbing 1 degree a second reads as nothing at all. */ +.status-row .heat { + position: absolute; left: 6px; bottom: 1px; + width: 0; max-width: calc(100% - 12px); height: 2px; + border-radius: 1px; background: var(--heat); + transition: width .6s linear; +} +.status-row[data-heat="cooling"] .heat { background: var(--cool); } +.status-row[data-heat="heating"] .cur { color: var(--heat); } +.status-row[data-heat="cooling"] .cur { color: var(--cool); } +.status-row[data-heat="ready"] .cur { color: var(--ok); } + +/* A target that has been sent and not yet echoed back by the machine. Same :not() + guards as the hover rule and after it in the file, so neither masks the other. */ +.status-row[data-pend="1"] .tgt:not(:focus):not(:invalid) { + border-style: dashed; border-color: var(--heat); color: var(--heat); + background: transparent; animation: pend 1.2s ease-in-out infinite; +} +.status-row[data-pend="lost"] .tgt:not(:focus):not(:invalid) { + border-color: var(--err); color: var(--err); background: transparent; +} +@keyframes pend { 50% { opacity: .4; } } + +/* ---- quick settings ------------------------------------------------- */ +.qtiles { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } +.qtile { + flex: 1 1 calc(50% - 3px); min-width: 0; + display: flex; flex-direction: column; align-items: center; gap: 3px; + padding: 8px 4px; + border: 2px solid transparent; border-radius: 8px; + background: var(--pill); cursor: pointer; font: inherit; +} +.qtile img { width: 20px; height: 20px; } +.qtile b { font-size: 12px; font-variant-numeric: tabular-nums; color: var(--ink); } +.qtile:hover { background: var(--line); } +.qtile[aria-expanded="true"] { border-color: var(--accent); background: var(--accent-soft); } +.qtile[aria-expanded="true"] b { color: var(--accent); } +.qtile[data-absent="1"] { opacity: .55; } +.qtile[data-absent="1"] b { color: var(--ink-dim); } +.qtile .switch { margin: 1px 0 0; } + +/* ---- toolhead picker ------------------------------------------------ */ +.pick-col { width: 196px; flex: 0 0 196px; display: flex; flex-direction: column; gap: 7px; } +.pick-row { display: flex; gap: 6px; } +.pick-box { + flex: 1; height: 44px; + display: grid; place-items: center; + border: 2px solid var(--line); border-radius: 8px; + background: var(--panel); cursor: pointer; position: relative; +} +.pick-box img { width: 20px; height: 20px; opacity: .8; } +.pick-box:hover { background: var(--pill); } +.pick-box.is-active { border-color: var(--accent); background: var(--accent-soft); } +.pick-box.is-active img { opacity: 1; } +/* the machine's own state, distinct from what the panel is pointed at */ +.pick-box[data-live="1"]::after { + content: ""; position: absolute; top: 4px; right: 4px; + width: 6px; height: 6px; border-radius: 50%; background: var(--ok); +} +/* one button now, so it takes the row */ +.pick-acts { display: flex; } +.pick-acts .btn { flex: 1; padding: 8px 4px; font-size: 12px; line-height: 1.25; } +.ejog { + margin-top: 6px; display: flex; flex-direction: column; + align-items: center; gap: 6px; +} +.ejog .ebtn { + width: 74px; height: 32px; + display: grid; place-items: center; + border: 1px solid var(--line); border-radius: 8px; + background: var(--pill); cursor: pointer; font-size: 12px; color: var(--ink); +} +.ejog .ebtn:hover { background: var(--line); } +.ejog .ecap { font-size: 12px; color: var(--ink); font-weight: 600; } + +/* ---- the jog wheel --------------------------------------------------- */ +.motion-col { display: flex; flex-direction: column; align-items: center; gap: 12px; } +.wheel { width: 272px; height: 272px; display: block; } +.sect { + fill: var(--line-face); stroke: var(--panel); stroke-width: 2.5; + cursor: pointer; transition: fill .12s ease; +} +.sect.ring1 { fill: #E9EDF2; } +.sect.ring2 { fill: #E1E7ED; } +.sect:hover { fill: #DCE8FA; } +.sect[data-blocked="1"] { cursor: not-allowed; opacity: .5; } +.sect[data-blocked="1"]:hover { fill: var(--line-face); } +.home { fill: var(--pill); stroke: var(--line); stroke-width: 1.5; cursor: pointer; } +.home:hover { fill: var(--accent-soft); } +.home[data-invite="1"] { stroke: var(--accent); stroke-width: 2; } +.homeicon { fill: var(--accent); pointer-events: none; } +text.axis { + fill: var(--ink); font: 600 12px var(--sans); + text-anchor: middle; dominant-baseline: middle; pointer-events: none; +} +text.step { + fill: var(--ink-dim); font: 11px var(--mono); + text-anchor: middle; dominant-baseline: middle; pointer-events: none; +} + +/* Z moves the bed, so the row says so rather than joining the wheel. */ +.bed-row { display: flex; align-items: center; gap: 4px; font-size: 11px; } +.bed-btn { + display: flex; align-items: center; gap: 3px; padding: 5px 6px; + background: var(--pill); border: 1px solid var(--line); border-radius: 7px; + cursor: pointer; font-variant-numeric: tabular-nums; color: var(--ink); +} +.bed-btn:hover:not(:disabled) { background: var(--line); } +/* Disabled means the printer would refuse the move, not that the page is broken. */ +.bed-btn:disabled { opacity: .45; cursor: not-allowed; } +.ejog .ebtn:disabled { opacity: .45; cursor: not-allowed; } +.bed-lab { font-size: 12px; color: var(--ink-dim); padding: 0 4px; } + +/* ---- anchored popover ------------------------------------------------ */ +.popover { + position: fixed; z-index: 60; + background: var(--panel); border: 1px solid var(--line); + border-radius: 10px; box-shadow: 0 2px 4px rgba(16,24,32,.07), 0 14px 30px -12px rgba(16,24,32,.4); + padding: 12px 15px 15px; +} +.popover::before { + content: ""; position: absolute; top: -6px; left: var(--caret, 22px); + width: 10px; height: 10px; background: var(--panel); + border-left: 1px solid var(--line); border-top: 1px solid var(--line); + transform: rotate(45deg); +} +.popover-head { display: flex; align-items: baseline; margin-bottom: 10px; } +.popover-title { font-size: 13px; font-weight: 600; color: var(--ink); } +.popover-x { + margin-left: auto; border: 0; background: none; cursor: pointer; + color: var(--ink-dim); font-size: 16px; line-height: 1; padding: 0 3px; +} +.popover-x:hover { color: var(--ink); } +.pop-empty { + display: flex; align-items: center; gap: 9px; padding: 2px 0 12px; + font-size: 12.5px; color: var(--ink); +} +.pop-empty img { width: 22px; height: 22px; opacity: .65; } + +/* ---- slider, after the shipped fan control --------------------------- */ +.sctl + .sctl { margin-top: 18px; } +.sctl-head { display: flex; align-items: center; gap: 9px; margin-bottom: 12px; } +.sctl-head img { width: 20px; height: 20px; flex: none; } +.sctl-title { font-size: 13px; color: var(--ink); } +.sctl-num { margin-left: auto; font-size: 13px; font-variant-numeric: tabular-nums; } +.sl-wrap { position: relative; height: 22px; } +.sl-ticks { + position: absolute; left: 9px; right: 9px; top: 9px; height: 4px; + display: flex; justify-content: space-between; pointer-events: none; +} +.sl-ticks i { width: 1px; height: 4px; background: var(--line); display: block; } +.sl-ticks i:first-child, .sl-ticks i:last-child { background: transparent; } +.sl-wrap input[type="range"] { + -webkit-appearance: none; appearance: none; + width: 100%; height: 22px; margin: 0; background: none; cursor: pointer; +} +.sl-wrap input[type="range"]::-webkit-slider-runnable-track { + height: 4px; border-radius: 999px; background: var(--pill); border: 1px solid var(--line); +} +.sl-wrap input[type="range"]::-moz-range-track { + height: 4px; border-radius: 999px; background: var(--pill); border: 1px solid var(--line); +} +.sl-wrap input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 18px; height: 18px; margin-top: -8px; border-radius: 50%; + background: #fff; border: 1px solid var(--line); + box-shadow: 0 1px 3px rgba(16,24,32,.22); +} +.sl-wrap input[type="range"]::-moz-range-thumb { + width: 18px; height: 18px; border-radius: 50%; + background: #fff; border: 1px solid var(--line); + box-shadow: 0 1px 3px rgba(16,24,32,.22); +} +.sl-wrap input[type="range"]:focus { outline: none; } +.sl-wrap input[type="range"]:focus::-webkit-slider-thumb { border-color: var(--accent); } +.sl-wrap input[type="range"]:focus::-moz-range-thumb { border-color: var(--accent); } +.sl-labels { display: flex; justify-content: space-between; margin-top: 5px; } +.sl-labels span { font-size: 11px; color: var(--ink-dim); flex: 1; text-align: center; } +.sl-labels span:first-child { text-align: left; } +.sl-labels span:last-child { text-align: right; } + +/* ---- purifier modes -------------------------------------------------- */ +.mode-seg { display: flex; gap: 6px; } +.mode-seg .mode { + flex: 1; padding: 7px 0; font: inherit; font-size: 12px; cursor: pointer; + border: 1px solid var(--line); border-radius: 7px; + background: var(--panel); color: var(--ink); +} +.mode-seg .mode.is-active { + border-color: var(--accent); color: var(--accent); + background: var(--accent-soft); font-weight: 600; +} +.mode-seg[aria-disabled="true"] { opacity: .45; } +.mode-seg[aria-disabled="true"] .mode { cursor: not-allowed; } + +/* ---- pick-extruder dialog -------------------------------------------- */ +.dlg-note { margin: 2px 0 12px; font-size: 12.5px; line-height: 1.5; color: var(--ink); } +.pick-list { display: flex; flex-direction: column; gap: 6px; } +.pick-opt { + display: flex; align-items: center; gap: 10px; + width: 100%; padding: 9px 12px; text-align: left; + border: 1px solid var(--line); border-radius: 8px; + background: var(--panel); cursor: pointer; +} +.pick-opt img { width: 20px; height: 20px; opacity: .8; flex: none; } +.pick-opt[data-chosen="1"] { border-color: var(--accent); background: var(--accent-soft); } +.pick-opt-txt { display: flex; flex-direction: column; } +.pick-opt-name { font-size: 13px; color: var(--ink); } +.pick-opt-meta { font-size: 11px; color: var(--ink-dim); margin-top: 1px; } +.pick-opt[data-live="1"] .pick-opt-name::after { + content: ""; display: inline-block; width: 6px; height: 6px; + margin-left: 6px; border-radius: 50%; background: var(--ok); vertical-align: middle; +} + +/* ---- storage --------------------------------------------------------- * + * One destination for everything the printer is holding. The panel fills the content + * area rather than sitting in the 2x2 grid, because the point of the view is a long + * list you scroll - it is the only thing on screen. */ +/* Storage is the whole screen while it is open, so the content column stops being a + grid of stacked panels and becomes one panel filling the viewport. Without a definite + height the panel just grows and the list never scrolls - it runs off the bottom. */ +/* Two destinations, side by side in the markup and never nested: the control panels + are one view and Storage is another. Storage fills the viewport, because the point + of it is a long list you scroll. */ +.content:has(> #view-storage:not([hidden])) { + display: flex; flex-direction: column; height: 100vh; padding: 20px 21px 20px; +} +#view-storage { flex: 1; min-height: 0; display: flex; flex-direction: column; } +/* Third time: an id rule with `display` outranks the UA stylesheet's + [hidden]{display:none}, so the Storage panel went on showing its header bar on the + Device control page. #view-control opted back in and this did not. The runtime check + in run_webkit.py now asserts it of every hidden element rather than of a list. */ +#view-storage[hidden] { display: none; } +/* This was `display: contents` for as long as the four panels had to be direct children + of a 2x2 grid. They are in two column wrappers now, so it is a real box - which is + what lets the two columns distribute height independently of each other. + It still has to opt back into `hidden` explicitly: the UA's `[hidden]{display:none}` + loses to an id selector, and without the second rule the control panels went on + rendering underneath Storage. */ +#view-control[hidden] { display: none; } +.panel.storage { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.storage-body { + flex: 1; min-height: 0; + aspect-ratio: auto; /* the panels' 830:548 does not apply here */ + display: flex; flex-direction: column; + padding: 16px; +} +.stor-grid { + flex: 1; min-height: 0; overflow: auto; + display: grid; grid-template-columns: repeat(auto-fill, minmax(188px, 1fr)); + gap: 14px; align-content: start; grid-auto-rows: max-content; + padding: 2px; +} +.stor-card { + display: flex; flex-direction: column; + border: 1px solid var(--line); border-radius: 8px; + background: var(--panel); overflow: hidden; +} +.stor-card > * { flex: 0 0 auto; } +.stor-shot { + position: relative; width: 100%; aspect-ratio: 4 / 3; + display: grid; place-items: center; overflow: hidden; + background: var(--pill); border-bottom: 1px solid var(--line-soft); +} +.stor-shot img { width: 100%; height: 100%; object-fit: contain; display: block; } +.stor-shot .stor-ph { width: 58%; height: auto; opacity: .8; } +.stor-shot .stor-glyph { width: 34px; height: 34px; opacity: .45; } +.stor-badge { + position: absolute; left: 6px; top: 6px; + padding: 1px 6px; border-radius: 3px; + font-size: 10px; font-weight: 600; color: #FFFFFF; background: #6B7280; +} +.stor-badge[data-status="ok"] { background: #16A34A; } +.stor-badge[data-status="bad"] { background: #C9531F; } +.stor-badge[data-status="busy"] { background: var(--accent); } +.stor-info { padding: 9px 11px 0; min-width: 0; } +.stor-name { + display: block; font-size: 12px; color: var(--ink); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.stor-sub { + display: block; margin-top: 2px; font-size: 11px; color: var(--ink-dim); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.stor-actions { display: flex; gap: 6px; padding: 10px 11px 11px; margin-top: auto; } +.stor-actions .btn { flex: 1; } +.stor-actions .btn:disabled { opacity: .45; cursor: default; } +.stor-empty { margin: auto; text-align: center; } +.stor-foot { + display: flex; align-items: center; gap: 12px; + padding-top: 12px; font-size: 12px; color: var(--ink-dim); +} +/* A class rule with `display` beats the UA stylesheet's [hidden]{display:none}, so an + element styled this way must opt back in or setting .hidden in JS does nothing. Same + trap as .fault, which is the second time this has cost an hour. */ +.stor-foot[hidden] { display: none; } +/* The footer itself is no longer hidden - the count in it is wanted whenever there is + anything to count - so it is the button that comes and goes. `.btn` sets no `display` + today, which is the only reason [hidden] reaches it; this is here so that stays true + if it ever gains one. */ +.stor-more[hidden] { display: none; } + +/* A log has no picture, so it does not get a picture's worth of card. Same grid, same + card, laid out sideways: glyph, name and size, one action. */ +.stor-grid[data-kind="logs"] { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); +} +.stor-grid[data-kind="logs"] .stor-card { + display: grid; grid-template-columns: 44px 1fr auto; align-items: center; + gap: 10px; padding: 8px 10px; +} +.stor-grid[data-kind="logs"] .stor-shot { + aspect-ratio: 1 / 1; border: 0; border-radius: 6px; background: var(--pill); +} +.stor-grid[data-kind="logs"] .stor-info { padding: 0; } +.stor-grid[data-kind="logs"] .stor-actions { padding: 0; margin: 0; } +.stor-grid[data-kind="logs"] .stor-actions .btn { flex: 0 0 auto; } + +/* The chamber light, while the printer has not yet echoed it. Same pulse as a sent + temperature, for the same reason: the control is showing a request, not a reading. */ +.qtile[data-pend="1"] .switch { animation: pend 1.2s ease-in-out infinite; } +.qtile[data-pend="lost"] .switch { box-shadow: inset 0 0 0 1px #B8801F; } + +/* A panel whose update() threw. It keeps whatever it last painted; this says why the + rest of it is not there, rather than leaving a half-built panel looking finished. */ +[data-paint-error]::after { + content: "this panel failed to paint: " attr(data-paint-error); + display: block; padding: 10px 14px; + font: 12px/1.5 var(--mono); color: #7A2A18; background: #FDF3F0; + border-top: 1px solid #F0C9C0; +} + +/* ---- camera settings, in the popover the gear opens ------------------ * + * Anchored under the gear rather than replacing the picture, because every setting here + * is about what the picture looks like and a sheet would hide it at exactly the moment + * it is being adjusted. `core/overlay.js` does the anchoring; this is only the contents. */ +.cam-set { display: flex; flex-direction: column; gap: 16px; } +.cam-set-group { display: flex; flex-direction: column; gap: 7px; } +.cam-set-label { + font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; + color: var(--ink-dim); font-weight: 600; +} +.cam-seg { + display: inline-flex; gap: 2px; padding: 3px; + border: 1px solid var(--line); border-radius: 9px; background: #fff; +} +.cam-seg-btn { + flex: 1 1 0; min-width: 0; + height: 28px; padding: 0 9px; + border: 0; border-radius: 6px; background: none; + font: 12.5px/1 var(--sans); color: var(--ink-dim); cursor: pointer; + font-variant-numeric: tabular-nums; +} +.cam-seg-btn[data-on="1"] { background: var(--accent); color: #fff; } +.cam-seg-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +.cam-pick-row { display: flex; gap: 7px; flex-wrap: wrap; } +.cam-chip { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px 6px 6px; + border: 1px solid var(--line); border-radius: 9px; background: #fff; + cursor: pointer; font: inherit; text-align: left; +} +.cam-chip[data-on="1"] { border-color: var(--accent); } +.cam-chip[data-focus="1"] { box-shadow: inset 0 0 0 1px var(--accent); } +.cam-chip-th { width: 40px; height: 24px; flex: 0 0 40px; border-radius: 4px; + background: #0B0F14; } +.cam-chip-meta { display: flex; flex-direction: column; min-width: 0; } +.cam-chip-name { font-size: 12.5px; color: var(--ink); } +.cam-chip-sub { font: 10.5px/1.3 var(--mono); color: var(--ink-dim); } + +.cam-modes { display: flex; flex-direction: column; gap: 5px; } +.cam-mode { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; + border: 1px solid var(--line); border-radius: 9px; background: #fff; + cursor: pointer; font: inherit; text-align: left; width: 100%; +} +.cam-mode[data-on="1"] { border-color: var(--accent); background: var(--accent-soft); } +/* Greyed, and the reason next to it - which is the whole argument for naming these + after transports rather than after picture quality. "This engine has no H.264 + decoder" is something a person can act on; "not on Linux" is not. */ +.cam-mode[data-off="1"] { opacity: .55; cursor: not-allowed; background: var(--pill); } +.cam-mode-dot { + width: 14px; height: 14px; flex: 0 0 14px; border-radius: 50%; + border: 1.5px solid var(--ink-dim); position: relative; +} +.cam-mode[data-on="1"] .cam-mode-dot { border-color: var(--accent); } +.cam-mode[data-on="1"] .cam-mode-dot::after { + content: ""; position: absolute; inset: 2.5px; border-radius: 50%; + background: var(--accent); +} +.cam-mode-body { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.cam-mode-name { font-size: 12.5px; color: var(--ink); font-weight: 600; } +.cam-mode-hint { font: 10.5px/1.35 var(--mono); color: var(--ink-dim); } +.cam-mode-tag { + flex: 0 0 auto; font: 10px/1 var(--mono); padding: 3px 6px; border-radius: 4px; + background: rgba(31, 122, 77, .12); color: var(--ok); white-space: nowrap; +} +.cam-mode-tag.is-no { background: rgba(224, 49, 49, .12); color: var(--err); } + +.cam-note { font: 11px/1.5 var(--sans); color: var(--ink-dim); } +.cam-set-foot { display: flex; } +.cam-set-foot .btn { flex: 1; } diff --git a/resources/web/device_page/icons/chevronRight.svg b/resources/web/device_page/icons/chevronRight.svg new file mode 100644 index 00000000000..fe4affe308d --- /dev/null +++ b/resources/web/device_page/icons/chevronRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/web/device_page/icons/delete.svg b/resources/web/device_page/icons/delete.svg new file mode 100644 index 00000000000..6aff8074297 --- /dev/null +++ b/resources/web/device_page/icons/delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/web/device_page/icons/deviceActionHome.svg b/resources/web/device_page/icons/deviceActionHome.svg new file mode 100644 index 00000000000..f0508e1ba29 --- /dev/null +++ b/resources/web/device_page/icons/deviceActionHome.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/deviceControl.svg b/resources/web/device_page/icons/deviceControl.svg new file mode 100644 index 00000000000..e7b65dfbe23 --- /dev/null +++ b/resources/web/device_page/icons/deviceControl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/web/device_page/icons/deviceNotConnected.webp b/resources/web/device_page/icons/deviceNotConnected.webp new file mode 100644 index 0000000000000000000000000000000000000000..e2344eb10034d2533aa27519d96773f0ee50faf1 GIT binary patch literal 2746 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

?r$Ze zh7(6+5pbvYi($q?9)>GUO+!@D-RLB`ZaUv8pP ziI&qzmO`po9>xmR8;SiP7y+f#x*Q^IB=1bP!u&Wr^jFrk7TIe@D>dSiArr<*kON^U zR>)5U_tmp~Uyq)7rT`e1{`bofnWA!XLf00JFF!yCyR5Ls=I6ogbIqhWk`SYA5JCPT zJ~qprj`%gsMc*aC0=Al(4z`-A0|?+QT5v@lcxU53A7NcP+n0Xa@Ch))uKd*SA2tZ( zf6al>PcbjgpR|^*{V}c}Drx#`Z!9K5k@S7-Yc-s|&EVqc*`?rUMQW)*^TCbeZik4y zneym8d!f#C6=`m}Y~Nd#CoUlvz#v-?IcAY>tWHuWqR?1#pOB`cc)IJ&`h&n)WMO>z zvrUby>q{1{4^9A@g8#K+go`vgl(bDN>`R^brD}exXI*bJC-RLruhxoFY2LbZWAx$| z+AHZE_eEIbpgK7kP&7V5t#hkoM{0}{e!&{|01mH!`-ZIen5xpBHygX?1_yPy@3@GQ zYy+Fl9cf4asK?Vmc*6y5^GX}Aq{qQ$0Koc44ZC&eZ1Kd-sU?^MR4>*qEqBepe&Y7{ zkL+RHnY(qj6yl+^&;%+v7f4kS4e@R0{>iAskqR~t`#2|eMuFM{QFhL2; z%jVx_+F0rmO_?a)$?=vGCYhy5ZoL)Hk#}h+X9d|2hN`NfvX?l5K%NGy53{!c?e#L5 z5s#$foKf2+>@MF#w6>m!c$&QwRCJF1^pRD8LL=<{G1P7C0;V5KSV(#-QdLc-BoMmQ zp6IOmdda7-IzDdl?z#e)!5#fEIlY$-BSr1E8|`T#o5x@5Tm9A680HEEwy3xr|A~f$ zATtl#WpI_i9zPbzXw6URfoo{or%D7H>=(VXMNJi@Ihn#j@n!@2cqar-e`;jZb*;R6 z-`zATJLUjyl&iAax4Z#U4cXgKJql=g^v?h_>bw=Yz9oTFe_ynv@)g#r!Q+Qz5&7Rc zSp|tWERip)5!NVynTcgSa{f6X=dM~Wc<0Fos$RQlLdoqMHu+*Q5eTbWkM0f1AG#j) z^xg#H$s_-I%6$jpt%-Y5G`<_tZSNq(tvhLIKG#C?--RY5@)UqDn_JW|Q&R%UWeO8Y z+%uk~<=x%^X(20c^_JHXlLJB6E3St8XSwa_`ojaWCqv}HZ3lr$bajzSbXZXrRS57a zGwk*a_9!}cLz$-MMvm~c-YBn87Rv#E>SS(#QVQx*k;!GgAOdN%)zJo5!4Ge zvzj~y6&2O*T=BEqC-yv>vhjESjt`2Y{*IKe-3%2;1#uq>Ygt{Wg(5KoM15k?+iE{B zwBpVRl6%jjMfeim>4B&JX~De=(1n7$x;VUGr0!hcN>qeix`b*&5N+i*0)(TFNO3y^ z=MUig@yol3MI`lifq^^3?L8QEZS5f7`2RIw6-e%_794<4lUs%rCYbE;9>1-k&6hn+ z^`?7qvFmd+KH-%V;t%157bK<8gQrqs)TQxJ5YkQ6q{VI08kyjdNqMV&8s65IF6l$q zHGEzAQ1g<5xGUxrr>I>@ZcAFizKD*F&Y|Jtp5Q6`2&?gMz_gjU{nvD_W8tB=b?^|WvaVX&&q$Cs`fm6 zOai0zoIm&gv^)+kqNr!KkJ}kyLKUMMTAl#j$FnB|fWZ6z1>2rmJoCeR-!JrFuA_X@ zRK35v@dB^Hbk$qkx!3F`K1TPi{&U$uOZU|qczU)Tl}`;LP^HEGUcpJzdLE0M=)jFR z&O1W%fZ>fpHE{dE8Mb6CdkqNycz+4e>V`i)Ru35;?-n1x*V#w}{)*g+I@NUab~P;n zZ7bG1yjFeL3LOZ zGjd#I$NcgW?BBPSgKO|kSpF?YZ@0G|_q8)aw`{FC#4V`2I8*mR3YV6I(E&5h>w7m4G zpQmDQu%^+jPU?&KmDE08_C|og#`>kRRn}K%(T5VNg1#3nHG1z`F~%Pi@9s*T{#D9w z@@O%;`uC25oBClRjGT}jhzj)NiMr-8G z0)M)iogDfL>2H!g#Aw1<6gk251 zL!GxJ$B5ZI>qj7{I-Quv(~p8oQlFfCcDf3v@@#(~x(jr%=4tay>m~BDhzHj&=6VlC z@=M8~y6W5qxE|4yLMQ(_@~;N`Tu@NJNcqJ?Y%kEop(>#h8}dTY=5gp(O{%omjW<=5 z4AUf_*Ik!ISsL9IVi%dVfSbV^%rb^NO7O%CL0^>(EpKIX;Ww8ZJU7=3~ z5jykh_fA(8i#T)ycv0^>X>3w>6f4JV2DIMmS8sirAzT|kcG)bq<*QTmGx8zu$iiLY zcdEJyTe6sYxKWvOMOdh{=N!kG4S&^MA5?k9CCQY)YG!|gSZf_gluAZ42hD0a4ar`1 z7&i2oJ8vx(z|ndUYCHhAsz`^oP%Y_(3@zS*$ny1Xn| zL@f;ci9|B^jsTR{!B~OgwNO#BO~pm>ter;6O;EeCnU&~k{x<)YkIjE{T?zQ17JlGu@B`Thgt7CK-YPkRVPHTt2tHy%10r7P>` ziOgP{)%@^N_)hjGCSykf=}b0ptmHA=49mF+?z|=6nD}?G8&yOq*>Js|!%`pFI24Mm zK1>G$YO%AtnHzHhm-_622(FDhuiS&*1?J}}cd`&IYe8Ks?Fa1>HcfU|nxf)jw2U);M1)QYKkOIJ|}Vdihh*AK~S&X7)_osHs{+ zO!WZ2J;9tl_1^qnCuvX$d&}&i!>xASSxN0nPJ0R-$JndwCqvD!SM51DFQ5d~B<@Hd zAzRv{BlzQqFqLKOv8>G^%R!Ae_hYVLmCM3jXxJ;IV{=Ryvor~>X`M`Xz z4v*bE5eM(=_FeM{yx(XX$;S`>{djbb#nav=FTdi(&Wc}OEKVeoRjHY5Std6lixeD> zQylpz49s{G>;oCuevDE?YhZU8!1So)K;snh4A?fTz8q4 z<*z%}Ts_DC3zWUPtK<2!^UW74^;DLy>VYnEmgsdEz;ls(auPSi+LP^>{!9%gA$o|K znBAu^40PbH)P&*Da!h6S$8!SrswnQQ7h&WhTZ{P>!oS>@YIlCQu}~|#vZA*GEfH#p zn9;%O*$>RWVqRbJGQVugFwJyTk^kIn@JQ;E{HTP~owRb~>(5x*sEf(BzCQ&wzrI(g zB02f(yf~q|9}}a&Z}7=sI?M>7t_sL(`KS(NIG2qqh z8&wVP#4oN#{Kcaca?C=w-d+nD{L-BlFT0;{%RXNze}9sUbxx?VLV6O69A_RzS7K65 zDO?nCAo(u5(zfC~uW48rQ1z{Pv>?KCX37meyZ7+_(R7t@O}^h76$z1+?nb&LMMg`B zO0&@=-8oRY8)=Y`kQkjKM7n#x=!P*mB>j)?@AH4LS9`N(&$-Wi&UKx0U0|~|@-MC~ zxY=UWobfvj&U7s9lCLKeHbXgEZf6Uu2BQakb9jI}gAcni*@F)iZgg2PCyiKNUGY!_ zA_Of~Q8nP3t+PCbs$3Mm3h(6iRN?z9{(&l8ecyvRF%?4BjNa9vTW8vZIdk94SN*O@ zPIC=lxdWyHVtS!D#mqJv${}QUa^Zx)duNy6q-0p>-h~)biBd%Mx01&G*A90+EvdKYonlWP zhue~af}UN5gNxk(#1-U%wABxyxX-h!&?Cal2xn0izjZ46)um27QM(snBbORyp!ZLc z=fCqCTD;7V#Ed=(j-kE^{+8ui^)LL=0jbVYP7ORlN%Xp&A5X}zbvkSjbSEBv$>NhU z7`5%oX}nmtgr0)3K^^&4@SOHh+OwOrlvMDE2a4Ryp30-k^j5p41p2%sQ^Tq7@4%j2 z9|Y^7g9JEdF>-2Ht++lfb1*W^MUz!H{=rh_j#O}kQai(Z^WFZb65&ODJXApyLajMx zGe(fUxrX0I*Qhp+*G^*Z#B&NiIsl0hIwdguA1A}9t{gsbiqmuwtR(_EUx{yzZz&?P zrhJ%8cJDJsR(mMSO|@XSbG!N{I9dgpwY_BAI1}2hl+tm1sIz_nB7>A;RcV=Mknn!b z!rh8FmZT`SMd?d&M2Z4gDNZr>-0!1zJiM_P{=Uo65Pm@{6sVkNWxdA7UiLs6+l9mQ zZT2Pc7Xk_yQ`u`u!Ccj`>zaHO^vp8?QV)ZON|6{~nZME$pzOA<^10>F%nK>b7X2-f z!p>HO*mo)y0le9_zI(#=zUZB#kFDb_DlXfh?Xqlo#1tCun)81-`2`F6xO?=Zwq4Hs z3S70o;fi|=c{QaT?2xh_TiLjyLlQ6&?NqN86Pc%Fh%TNGcxNMN$8*Y@MKO zs}LzeR3H1?xAi04&H2go1Wp);@cL;)X$J93^i&i6Wiix9fJ_C4%!=O}mA%L~bkWkM zU6X~K>01t{xwAu;r{A!f!5i;q=JDm-lvf4_!$27FORU*myP}`Aj5KoLN5vkrL}&(& z-Kp`{9YR~xUp}oyDk7H1`e%-m0oW>x_>VswV3Kmma>CabLuulqjj>k*j{x+F4EzEN zz36tAbk02eIznhHY}Sm8Kc^R&m--r-XtLtnI)|{RJ=qIMs9oKc()Wh>Gy*1O(&FX% zl7xwjqrWiO^^NXK0u&YoY+dF<5>)b4ax^fQd86&7_TbDkAPSe@@8`g7txA~LNnU_ z(+i#Qldf`?$_6|8d#g3AGD4=|MgMX$Ar0)h{y$o9m^)~t^eUFSH4<$KY@Fxjdqiqa z^kAn%6njP)=#`Nl-3H9+;q!*JGy~-dE`BYjHrmVnWhcOl8Bk>hF(Qx|rynfbixEI; zneYD|Z1B@kZ6;K39eY%Dh9#4bI26wEY@p)bS)55M)oDE+y0eg(*+#|iTI51>^sihn z0KeH`4Whyd8N;87^ZBQ9>hF&#&<0R<3|gdDUc`u~nQ6pq^4aBWO9u8)1{VKi?1}4U z1qa2P@Nz6JdsPu)*Ta1Aawm>9Qek`5ivX=SZKTxRt;y`gS1EDP zgG8s{pKiH+cbw|70Wr1ovp7{#ucY8;zO9wO)=L{AP@EkAmkH~pzA?OCg=QtiCQ$Cx ztEfu(2P$o;;-MGZ;CAP(%kaByGEl#Xe;cNIMb`NCN+>YcC zD@IQWe(rIV9inH8U{JK4e;dyfY4^)h@vCwsxZ}NRG4;s?>rH$|?E>*^G%D+CaDoCCKKEEM}www)YwN{C35O`3V% zi#Hn45uFJ5c^AnOT{8c;5n%ZI=`J63)zVV?+oE2B*IW5xq+#@+HWwB{OgnYoiHrJV zxq~cQmA-=$)Gk|aZbWFFjd+lWV8S-ge(32y8%)kOUh;@k6H2PH;Z=9w@u@#_H#};f zJ&6|pMV#~VPK1(j7WkZlQv`_L?|VtaaW>9GDZ6xQ6S&Mt)ezWC*+QP96h8h+rjCqV zdR=AhKOE23S_K4ndS3ORcT%7TaWq|QHoY*v>KG`9R^Qs4ESFr^9DPp7EoFbo!c8Df ztwIU|Bvp=a23 z22^1NzKbHS2fp8<^?|(4sZ^R1%eS4zi0K(FsWf&-g?V0QlCex$Uo@~;N*<+OI#@iZ zty<<-a(vsjw-;k&OTP@gL_%w@A6G{A_iJaOYG@F&HZY9(T%tLr)htYD0u4OvcjP8Eeo2+BQ9cGP(`3(wX#13e!)AW$$N ztS^cA;P1U2XJ^B_M8?gmcdOTL^Bg!!4p06GrM_h*1s3fFKV`Dx^kQY{O@YsWGbs(@ zu$z~e3Eroea61;ci4&d+i+pcT$ipS}`*5FXNdjPt^qpsXocvlsR9A*E2>iS4>IxC9 zWpiNnH&3S{Q?C0GI7T<++LMLAn`(}mJoB7tl43>EMnes)IsJNkJx(j{GglncYa-@5 zr1W)hV|T1*6hvFvOOt_-ZIEfL=s73t?s#;+iUe};DF+TN;{6p%Q9`#HkORp2dt z3;$$pVsF<_<_8`0XqJy@QIt3~Hg;LV$OjdaD|nITAbw%Ypj=i#21@)ik;~gn8lRO0 zm8k$6OyrJJ59*l>{4iYOkJ8(is}=(4$yJ^nH0m!>vA%wd7L4gzuB7IeS;q|9c`4uGD0JYsiq zA}sv!#zL-h?zr_EJzNuqUgB)>#y7f)tyZ`8iloh>5IxnmL;X8|+wZSqr7xrf|G9>t zQDY$__49e+9{+Bpr_s&DtpFG`V!Az&Db0w+@i;e;^_r`Dl57dW@@(56mY)rvh!-mf zQWICLC;fute|WkBmVj7jiLh7~?VBt_sJfMn3vEUFE&KftbBAFS ztA&RoqLtamjwF<}7m-%(uzOoJPCT_HL%9!^ZM}3!0sN!10?nCb zJ<*Kyyd|c1s(5ds4&V@z!Qj5DJ2ywyO^)uebYgaeEH_WI*-lt=<7}A$#ZKO)H|r$- zAuwppoxF#GYqxcBGu}4peNr5tV!d~C)orP;N(i3o+q-U@LJ2RS@jYISAdUqlOLceC zGN)3`xeS-4wI9pEP#a;>YC*YcoZOw*6G3ThewQ7e)6m!dDMt{(xJ0!(A8 zxjj!*TaZy$6*C>aw59KV%9~o%-_=JWTMyFt`+y~>2h#3u$4eO)4<%VZrz&_We;70cT$z=^G)LL?0T;7yCXhAzrZUsZ8ha=(My?vz_QyqZ!mg zT5>;uj~g;0KB%jAId7d>Zl_k?!p%(L};>WLU*(4t+6EH{{$Z{yOx;OEZ4t18t8lqm|vl)?)nw z>e`c>g=WX?x$1{IE3M;Cn$dyyA9L3n2XtJ{-RE$n+@r^~t0CE$%)8T&f0w-G?)g%2 ztC1DI_1HT)VQmI&^-RhqY+GrG1joUN?W`PR^@1#t#oJ%yVS+?~x-FJ8{291Y11^X< z&0ht2qo@!cLoSSkRs2@j#3Et&D))6>xWSFx;OO4wuD%lk;U+wYD1v@0TyW4*`XO?8 zfxF;~MXclm=J=C?cXHPC1k<&uob7#u@a!)tCo%;~RaUtrb*d0bqL8y@=q*p=J1d9j z)vV>QKwFdNDLkJttXP!=q3!9ei`oSMJjE^d2Kgn=3(ghBI7iQ{o|Ndu6Mz0n;|9~{ zE0l}N%{7X#_D|06dcT^2{y1fw5>AjbF1LX+HFQk6Sudny)X*2ivq*NeH>=^oa92CD zZR%K%*k(B^na^p`fh7rVnIzb5L6@&$XRr!B(%ApB8g1nTuSNz1*HmE@=E-55`Kf?f{YWVt7FoSY9Ej1+kstO3%Wa@85|%&f zzZBl;<|d-m>YD6iXL2yvFW7E2d6v7`s~Jg|I1Dfz$qPhxDD|4{AO-Sm&Fe7X&t&x? zIQY*iidATVb7^eM87DVJ3z@U2{AZzdSd=6;?I`o1Q^u26+$4i;Y$kmq!&FXXgS{e> zLu1((l*ZF2Xm~WJ)^Rw3aFGlM^7px~hR+6>|9Ox*7hMa!7xu`q1G}jEHbR?$ZU8kU z6Qk5q^*yT-zrFD;hm|se!DDyo`FHQ#PF5WK)pOH%vIL_b57dsubjvwK9sIe4W|(^y z{tByIk3ftl*{ekN&r{K#Ml@JX=>@um#dc;o1P1@6@cylQEArM0$&amrZq-Nq#6weA z;nW|Gf3q2=5>-Pp-1v(S7*r(->^=K9@xie9BE!`jekuu+%Pa1lh2zN4@RT5u?EQ~- zCeK7{G>dv`kHRkVPyBhT3 zMIMUxgmsXp?0t}%Z?dfb^ZeUjX{`Ms~Se(EH=7th==*6vwd4k^) z?Tl15{Oi2ovkwE@muJnY>}w^C9;0LwgqHir?qtG``i2)EzK5`(xthC1$@`e<+q?AE z+-0^nY(vq<9%m23wXY}8vg zgno097?{^NU|Q0e^KZ7qR-yFV^`9i6XvzoK{i~1Nt>Dw`)C58WXx_GwyS?s{EtUa# zM&773dhD?2)b7s(ZJ=2k%d6TZm&SCiZc$B4Ac`{?JBffy`YMs~Vv?bu$d1nD&=B35 zk9Qt7rvJ1Tz_}tpq$MZvpho3)(?I&KdIaAM8-U|j~A zzCmDtN*K3xxMcS1auJMfiHrn&K(5(gE(?5b*H;vSp0{uNFJq1?^tT@m1Tuk-cF#|k z!eRJ9I&S~-{L3Yo0;k$0oMhL_FJcT&U64vaNKD(xb_y2ZaAdOX&6qmN3mUva)+ zRiDd0RMg#$yoaQ620k@mp2w%M`25jcqJ{R?U%2gd;7p|GYq;;7sCpD#ew|XzCYDo)w;ViD|l0i`E<+S>y`q|9^dD}8@c;9OAiXjqpHhXyqy?(XO4 zd7U&68FzztmPd1Vj@+Y{aSjM2n4KIRPoW{ZOHHiw_x7xZ`}4m=gi7`@LNi!Q21W2( z!tLFU6kkfcr_ z{U^tXJUrFnO{)_e#HYuv@Vo#v8Zx<+Z<}pSSm0w5ip3A;I~#u)+@mh$?pJ!4KhELZ za$a9#*I1NxSk|~&#q<;tzAVWL&WY$^u&^6;Zj^4WILz9!N&~wv);Te^*sVuHv0NG* zW3Sv(F1-ybxDTFxU)JeUaBcUJjVOO;%HFUzv~tTu4b` z#$)GaqHOeeMSW#z#iq}-Te3XOrAJ$h=F&JMmHU?gT`KaoxH;klrHHO*Yd-T?iSYW) zq-r^)M@GKg(oN=INLsqqAtYV5V~Ez4b+Sj%+!2<#_nTrCB;@b$JH%6xNPk2WXH5xB znC8(%GFxODpLrLH7Tv&^cxelnc)25Cu?5=_8!3T)cd4`9t_SHueqSN{ z>ay*U%vs;pW<>(#bM=m`t1QK-lN~7e98w0O%j9E98zVt#q)Tg_SXLR z41{xS4d{z13*7`$o%VVrT1DS{-*JuE=C;h!+RsFgs2==z=F$G&TlT`k!}E%r^W+H8 zRTMPTC?S5Ujp90^1ft?Ig??#Ch+K!_N>7iW*!-PD?>_Oll6AL1JHvj{aaZfWkeoLI z;_kCvg?4)0U|Ukt?==AB=oEAHR|T<+mH>r^q#5mDQW!Cw?6%#B zZ_j|G^;B1*NSSHX(tb$7Oo0l6TUt#)O%8t?EA0Hpok_wgeZNYdur|ceBzhr@*7~l%1VUo zjwu3pHUA;e?aB(}v8?PgjRkSKGDQy0-BXT5Zj60F7F{x3TGemCqR| zMfRS2uOA#LLO5iND;QKd3sA6{U2afYdi{9*eaS(6+j$}xZ3r$OwBS>>K#cF;CZ%bd z<}<+U)}|l4djpLJv`I0X-`JDV=)3)jmn~IU;EYI}0Pj}y4Yr?O^^DE8&hYXaVDoN0 zM<9q~tl(83amQh`{OCiGO(-^hmJd+zswBzwxZ$qfOn^{5l4pL~Dsy5ylwiRdco&CN zPVFPQUpr!G-SraFtP3Bp=X1KL{O7D$bTR%)Y?E(nV|}NRhR~?U9vb&zv*7TQf^E$Nhym_4Oax%WjuM|A;jt?+G|QtwXOJUk`ZIwxA1NXPAz z7Wteo=6PDGT_8K(U9_i)q`@i0o)j=j^rvsKwW+z^eoIgl;NBD$JuMwaWN?-3O`fDh z!2?g=^Z1JfTz;-$giFl6q~jO*G<$Z2>(FaJ*x{bY&n&XUQpEbk;^B)30R`|xU%qPS zrQ#DQX%+7p40pXXi#BsHTN9nIq@PtYCczw=RT{5(^Er6~7x%>sp^i|apcB3v9tzAO z*Ft$6qle>T6e+S4)9dx+*5<~qzlQMZC>^SrK#Na54p*DN5sYk8Ug&Jx>O$C2Y>|*- z$s(SYosf%x?Qq0XdHs(F1j6lp(gE}lK(8y#o(78vzCI2r0AA#0zia1&4!`WLg>hx|^uI*9+~4t=^X-<47$7;YB1)H3ULb z8g_0YBq6++MK+*cXvB_WIFPz~L+oRap{_HDeG;MNpqcRTtkCcEfx_S%+$+wICx_9s z*UBA#dcr+>qaMC*tH-?5#Wtw3P?G@8+-WdGSCIv)r}JHho$_4RlM}L!CAxpK;!n0! zUR^+(Q7ldTmX;bJ*dd z`Be(3>V-1BXW!vprti5V6G(zzeR2HM2!SdU9>OwZH(!u*x@7U6d_>0e@~2IaQ2b3g ziYqCvTVt8%&$s!I>ecXB%n=CD-gZrArlfv9$5!A}LrIhl)3M+@uOZ$40H`V5Kn);H zHhnOm?imvHH4Hj+ag>DBdd38vJqoU(ss7jw2Pd%)bCpP`CF$X2(xc=FAUNBzd*Lkk ze~)}r*p>wvHHeWH76osKaCayOamm(L%oab1&?h-JB}Esj+$r4}u=Jx+=9gf*(#nP` zui!QyT|{FPwV6gtz4}Lff;Xo*%uKHi4B54I0KVOIS5dvamNvAhJS%jtz^LS^aWMaS zr0U$75`nOH8xNsQ%ReD|KHe{Ymj!P>%%`y)`FqQTyt>>eU;X&{;`2p-uIeUq(ooN!BwwU?R6O@=u`YWd33S7nY*ATrN@5FW*^1; z&r$xvmnduCf~$2hBfklh))dvUl@(##Z|bruanZ2HBI6wk%JAnV<$&;(L`?g2)A{As zqIk7xH{jNFm7%>7!}ZlfhR^0=zpi!Y{H2|k-lt!0Zp5ZJDa=w66FkKzbaWO-lA`Pq z#b~Q66-X6;v2DNsw0jvJ$tn<+u>UD&uZ<=~nN#DzwyNI@juYMqPbrf=@sU>+Qk6(X zP?zHrAtt~A16)2b473S>yObljlcs99aOXr@0^E?2i@MSWc?+fuc1 z^1EL*mHUyw-!8urszqVl>m$g7UaCcRf5#Fa)M15IWn!|9F^X$pnUwXAWMfsz=ZyvV zehMtRY;U#SxTZ8Llf}Wu$G7qH)EV1iv^s!Z{<%kO@5L_%{JWRWdnx0W9q@Q2j9F2m zbNpx7=Dc?TM{m?*Ik7@GPRs!}MFN~eF>Q9AU)Po7J2(dLIfh#c?IyW>v%V*RsR5<*B7S&m{a zIJSp<3J1A9w?5M>Pa)H!OH}+7qpeZPmeRyR8^O@7$whqG>KRJ0{_JBiF!7X4VecQr!}NJiB@H6-4yDxT~5?zS(TMQ z!Bmy2`T*X-qhJq5M@J!l)JQx^i>K8JZW8O>9P!`=CjgS+pt^4~UBWHGTZ@l+Xf{Qi zgdDQC$}_x1qpt8e3xm^JNUoy7Hxl#;<%J(@panji93sf*Z1Akj(Hve>mAN@k1I~$E zV`&;AaO7n+Iqtj6ebl65RBPB6wc`QKZ3BI@5LEXev=llIu0%md2LXk>+CW%`hmSg8 zLMuTH+uhB^I>p5Nzi@?|$_Wo;rOh+O-Glp~tcnn_x7JXl%H7%=LAJH9Y{e~>339`Y ziaeHEp;y)pK0Z@BzsbGRanyPNr@aYJ&2rdP$so0)JWx4`yEwL%{&1l)$$mp?GCA$N z_1g{c^w985dKSMoewHtN)<|OF%(-9oM!qn!jw9BJ_0C(a^1twWWk~;D?5HIx9_XAh z=)Bb9O*wr>FcU^Ocxg;i!`dIL8`Rc7pv~Uv=txjkksA`1tXUe8mOQZ)LqCr6!O#wP zOX03FxuGvG=}@$T_}t#HzX1}%mML#8a{jo=hMJZs&7u2=bZ|(e^c=ipjK2|cRg)J1 zSelTEUUre6HK=cXcu!{}#7I@oS3{&EqS?K=&~x6R%Y3Er+)S7NIGO8|U|G!j^S1sz z???F?i0wHjrVHz=`J30Vq=Ptj8&Y;HYkJQ@aJuvm^YuzZ?>rU>j@X#Qvb!AW?_02y zSJ2(tq?~*s)e$vpYr5mOcMUo~Mbo#isBlB)DRh+1(F(?nzJ&Uh70{HpyT{IE7#|iS z3imPJ+J*nOh;VX_n$Ne-*9`4+e^~xv_S>i32g)bs3f@a2dFsIU%`)xj(plg|-p5|z z?GzYHSxh1>Uu1|bj2$>;dZ3^jZ}SCxMjeEyDybXWq`#~r0g~mi61ejhQ0NeU-2lbs zuL&9-`DQZvaz}@oGRaN0SJr#<#`e0tA6&hzB58E5`^nC5pFo~UT=!gjLO0m0F9UTS z;_%kNH?jCw3>CmElsN4@UNMz?2mA!zmOMy#7X&4bS(S-!X((-i_svc5`!;5NZ6r)c zv%JK}OVHO9w^^}pD3ZX?|8eq!srzPQf^k5kIW%hw;f6ceg>Y|?oRF7Y>&dAI{q#o> z3|kBNQr>drt*+xaYR+rtN`wo{tw1SbE-<@190uw&Atp)qw=}xKQgHgLkOqU+QL#m-czCw()igrBp-XT4Bq2Qta;R!Q`KzS9Mha5$!B z*2mMarra41%NY-}lNhnun!KT1*w_jtYS}25-KU(k7zaLz_icpVEmH}XM?-n|+eTHal{83oHIG+lcyL$KdKKV10KvJd=*;Nw5*hO zL$D>GBBV0gvdfJK*V3!1^J0m^pCOF{LYs!KEMGI^6-k4sTH3A4&EZ~u+>9{qMPQBM zb>m)57_^+(M9Vz>c94;LmEsuB9e4Ra72nps=9gaXa1k(64TtxZLpDzW6vBTxShuXp z>PS|aeDcXY`9?q#~T+m8o}X~V`>*_4YY_`$X0Jxe^7GJY8I?v`8NTj6wBDSz z0Dqp$@khNV6Td|U(o?lH_Cwk1#QJQR`^ZE>e2W*S^G6ll;q~(6!b;awAYoSv@>i3N z5~w|szg4N}@!y}?(svEt0E1jNvDmF1W&u&WnR2pW1eOpT-j>hWvM+kgR`=HJ{bx(p zr&i`?Pn<%Iu($T|H}@jtkkfy4QbLZOqO71$e+Sbrb#?u*oQ`1DGs})UMbn7sCt^pz z&tqRw@T!h_^({URe9fnwRnr173B+ehcBZe(ALkWj{rK=j0PQMq!kd?(rcunVT6nqI zd>2>Jd=T!w5-VfdCI0hha>XK*$}={yP0^97Kujk}v|fB3))`*;(3CDHOs+UT3m(7t zqFIy(=?9a zv6`*-|JCU;@Et|8w)9Qx%*&5kPrxUBvYC_YpE+% z2_d~SGo9U>L~6t;_juGft|$s)OC0Qwe|?1RB!6d*FvfP1oF zD_(ls^ZB-dHhnPG)JDm#t`1_rI`lYfR0^+2zEqn_p?3P~$IZTk{9v-f*qbY8R`J36 z{OV>0%e_s?N6N05Mq0`bQ77ibn=EIA2p(9lAM-7QyY#lDSxgY>fL4&?sBu_^Evkx6ndzi zB3aO(KC+iVX$22$JZi5b0R8wc2E3H58D_JXS4wbY>Us@d)rg?Hy}XlLHkeGNVu{~+ z0c3FSElqCf!=V6>R7(uIo#u=EYZ-mxIRv~Rnfb!8^}G$o}O%(!YGia-%kf3lcpI{}mk@dkjqTXhVX$l6bxJd0$OX zMPU=p+1o<2lZ^|eLzh>uXLkiIS}{5VA!R>N$!6Jg&#UfctV%6Ivx(QStgb0d;cK`h?G%mKc%E% z4z2Af=O5^s;|vpwUge3O5UnK|drVX+Rs=D9`qw$f@0x_cU`#;>tVxSy({__K00(FX z%Wo*gPRx=5cgzA#IQnX2T~0Ot=vDsLN0OoUVGPu zpch_k)-`p4)qJP#{PyH1LiiD}19#j^4Hy>o09-NYgx^)Z?D(ltR=3(4KYx|>wN^st z@^S2*{&y7;naAqh1pf07X(?~CHFiR5^p?PAA=*a8F;2*k*WNh`B8IsfLV<49pi1Ao zkW><2;*Z~7!k*lN)-%g2|K{Erwx{;#rPC)O6aAx3YSkZa z@A=2hSdioAJ95R%bj)Z82O-~9PX$O&8dk2&pQWhP=hz{$)}vXYpxu#wHP}LKx60P; zrE6!dp6Y;?_C&bZ@0chzR{c8u?M^T)z~PxbNa)S&*qCR>E%0VF2p=kbV@3cpo_cJ_ zI=?fZHer7LE3Vz)%!+nw6Ug~M@N8j#v}DJ8;KXBaiU}@KH=c!nd;(8)6={QZq9sovTzUSXolXmeu|jt%a42Q=xc!mw`GKe*Y#L^MW)%6n$p?Ml#3nqeU^Zi zHxlpNkIrWd3d;W3m0UEsv36BDihmx*p_w)vsa!&}^09K5W4DpPsckwb&Ew#T>}Ft) zuL#0L^VF?=a7y^NFjGFEbR4p7Rr0~ZjXEDE>v6CTt?H*a!=;sO_|GNB29CnY3)zd) z(!JpjwGCF7F|?TtHwKr|zR)^hChT9Sz_UMKe!~x51b7rICh<25k<65q0YZ2!9Ut?S zW?^;ee~X|PMckR_1rSFQQck>WL$#$(NB1XC3u0w)XqEV+MRKvqs3$Eip@WQdAw2X; zSYU%nZ`Gph=6uyMQ=%C(;rVDu`=SSLol5}5ZS;k)g(k^GN_f$y`?5Y`ev6~p{gHw7 zfEs$Lx{B^OCF-cO{8g!+pL%v&f_ya@LbUC?&Bkel&KgUc{{6i2Yz*-7Qfh5&&D{W|qX0HHNxF?YA8zwDOIeiq?Q? z{D9oOuV%;gc;nq`^iMHr%>D%zEjMp5l!b3R0$nRECkK34ql)~0n=O6c=}EL7_+mW- z7jA*tOXSG3cxCDqNIP!3%)@$?rNd}MEMsAe-YprMdx`vIrG2-4UK>Ep@tc}smv5<$ zyYwh~2-JHnmQIQ8ef(Vgoda0hnI)rH7c?u$xu3N}lPQj+?02Uy8Da$X&X%F;JKp(N z^z%0?i2m+jA7XkfyXZB1Q&aI`z%GdIw`6WqAV0lCum0C7z> zHyUp2_d@wC2CY_eQ9SEg@uJ*fkk0EVF2`D2X)C#)kodL@^M;>?4tI%7`y#GsCMdaz zJtx`B<`2Bl)E80I&Pj5*NwPQw8UBt_l<F)no@N zN(x($q8&$ai1yW{w@FQPMO@6wO!hnVh&m?fxe{Waqt9j&h7#`t;u8v~px~BLCfA)MtNBHjLC7mIIPsMmv%}Aq*jF zVeAYY+Y+xkA)2-Lvzldei<#dwY(_<)t-#*WG3vXPFnW)7_(vHq8TaiAEOv$6zMW8i zEwJ|$RYyg18F2#{cCPjE40}Xa^lO|aoYBwXw5A=GBIhb9|&QQ!}RbG|lGqFOaT)s znt>u}l1F8}HBk%()iZHM>J&%bn3&4K)0fj$7MF?1LSM)uYG?;T=5`vyPh z_el>j3HsO%JpKpwc~tUr#oMy&CfQ)VbGo&>efQotSx#q-);{s2r@=+* z%m$HH#%U?B%&U5vqn@*AuVvO{*x61Y@4i< zp0VXc9^nAngoeyJ!&jzqhx)*u9dyEG1}!;(0>W$YCQ<~3tI)?eH#D+v=I#eMpBtRL z6VThb56jF*yNpT(K_l>+ClB}?TDJ3ok(l? z)f;aeu{*x^7Siwi@`l(a1e#_?X$+!wTIJXLTbB9!oD&3e;(ob#fh6h^eH!_h;>G^a z*z4+Iw09l|b&SNq%e3wXYtCQq>O2_PbiMak?Kmah$!Hl5le^XU|%qZ|w5hnl5 z1c9nJoJ9*~BP-Ze7t+&-e#TWO)8^;Mcj^|j9ARY#TeX~Z52u}@EE)%p=YYr%fP`VD zZSn*kk(ee{!67D0t0gqr%7#BdKEYc2ckrT4om2ImNJTDn(X6y1sC~#+ zmdHYMEo8}Kb7^wNJTvzqaFBSJo6s;wxW%mqkdgMx;tEL#43SSP7>lQwvZf~c{AN^W zVs=$h61WO|oX$ccBPTW8+gnk=7=me8o2119qJi9js7>+TL>N}GFH){vJ6;CbeWC5| zrz(~Lwsg#zS?mO--;#$jxwkFWQt3Bg&5qQ7b zXkW#-CWo6H+RjMHx_$dk0U8Y@<088s$M$S{ta!xpHGGWY^>aZ2!KEiD^nuZu)5);W z$mlZ7k8#kW5W<#@#W$=ozS>?v*ZK{hsW1oig;A&MU#7j#N$_s7?y?t>YpNAL7Uq*m z-mH=Gp}=va;k8Mq>27MG_w-_M@bTWmctrSQ=^2x3hRgLgCl>%ifj3AKwWvxK2Ei^!RPsUpUomi=T=t#ul{O((UQ$&k|@CE?tj0vL`fB_Nv$O z8W5f!-xr6k`l)=rXht?LGk|7x@$W`0>%p}4%y&G3@QMk2iNn+N38%P zw=Umy6K%Uh+QhWAn|2@43Q1j%DC5-zXfQ2~wAS6O@JF!opV?WZl8Kc#Y8azR6pyO| zU!zJkf;dZQN#E&2hP%AHKarUdS^=rX7L}1 z$k5VRhV|bwbQK!4{!u6V=LX5W0zMHuvhP!-f!tdCxz{CVpeV)%XD>AIvP_TX z`NW3TnSf)MJZ}6qZ;_X0lI%;;6m;1pRgj^=q$9+ewS?1*;Lw&v4BemOHCLNCV{0Kv zI845`(1ae?J2z!9;bPAHPmMh@i}rZ!ssT8#7c(Y^o;?VQ={1fTiYwFWcQG1*yO!iR`1?1(O5 zXr!ygxx0C^M`q85!TtM5Ca6ASZeDm(NcqrDz>8ZSx2L%C#az&u-TgKLITRnwglZ2j5YF@7Kp&n| ztcvr+O|xvQ_xWXIC?rso2v-7Q*;nS0$ppn(SO?W$aijluT=hihTn&ic^_crT%$v*I zOJ0Z0yyH6wB~@W04}2LR7pr@AIpU5CzOwnC4YkyBx&UwPaM|5Fu;Y)ie|F$k#gRUh z+q*<5T43y!7h{vBo>Jvzo>9pENk5N$Ii$3n)08pC_&=O9wQ}%y+*z8=eBP1^*xd@%wziHF^N{Y zF5-3x$pq_Giow?W6O9lU^0rWqz9bxO30B=bZaI=RD69EX&-+%F`dBt1cq4 zr4Pa_3U3;Q($8(hkc6u91^n?2{HRy7>g=r=;AIZ#Jac5Bzr)&6|Lj=VAI4kRA4J`B zwYXvfw;wPqysN?#bpstN;sSYw`PS^&vdAI*>2Z3;)0y<5*&;vnirdU`e8N3S=TGU^ zxk}GMcj|4p2=IgsowJRxj4(94O zA%UGeX?{ev2?9NGb#`9ykTgF0QMw~$+Y2MNgFE^see>EKTe~681==KF(hthDErT8e zB2jQh+r)&z`i+6|EY))kJWL{q@~UIrpEro=a-VL#X%nP)7lzFCQN*o>qUIEh$(v&> z>bp4Owz+x6=!D6DjaX-qlX7^G=PRy2IDPiYH=F>B{k@DlcWk-`2aeUtsQ!if*51L| z{&2({e9;+A<(Fe(VmkpLNnKiBi0F$MVf-JkT7^oaR4sI@T_ImAhavcS_z1wtX8*1e z>-|fUV!W`|s5Cfxaw6pTCKlcV463)#04+o-rqVz6M7d4zz8@bFr}r@0J?rxth%$nhVdbgDK+Hlx7Kh-%r zM=;qLs{hH478DZAWsa&9^=Qr>Xo@P2@S$=NHf#7oEq>SjfY=gD4YZKHh|PJK(ha%! z{r>%pNN<4t?Ou!B7I8&=k;cPY9{9?M^fOVcDr|m$Cpg7jg-J2IBcGJ1LLU@>$9@rZ zF};jkpW97s7X(*5a?Ta3&abzCEW2XYEKzT_T7VQ*rD>DjL#xLQrv6v-KL-hMU z0A`48O(%I)RlE|}wzlx+(oMRnEdNxmah}q}ZW4O#a`gKP24AqGi8u{>Zw!exG40KY zeoWw}to99qgVbV0g-&YCI2PGxc0FThcCCt=q8Vc-F+l$&keUNX8soOMw(O0Ze(h0! zu@Mr)^{(3n(Q4@8e2!C4(CE=PP|zExC%(Rip;nCtlchV|-q{p~YEPb9&(u6k{U6^( z2M)2KTwY}nM2ENMJ!!^;Ky|LmG+)K6}=7hSap73J}*GdH?q#K3pUEu!^@ z=}<~I!E8(+$FH@m;^$U#MH^zMLZrUg|B8n-#(!;y;6y8S8I^m3GsV+2X@=9tA2d9y+F0Z$fpb)a zr(c0&rJ(iB!=WH5^jI6kLZ?_>DtcW#Xo86=sh^%!RC=m%&Y>D3If?awgeJ*`5`?Os zQ*qggj>$1>G8|VQ3n-5a;#)7+x+rI`siI|rc{<8Bb7=nT7mUvCt!1006P(izyvIaL zQncxo>BU)4U;r{H6^+%-;C3H!Ta{)rUy?^9YP}NJCY?ea+fG_1F9 z`0N>K;1^im>9h}Fo|+F^&Y!xhtSNwpJgQ!PXIFW4dsH)(dvsxuY{MUqfVJDd9FrSc z8go6*sgPRZxtxE!{FfaU0aS_kYbV(`m0ta8EQ$9Oyqxqc8s2(UEF&WN4UQ6QjuHhp zcuvLM>JGNGx1V=gjtYbCbr8jrmZEX zqC6(8s`zX~7e=Hf-z_DI%87DHCV%5SDZFB@%T;}P`x#W#quFBP(*xq;50J!YLVA-o zcQvST=fno7?cCIsgX`vTim0-*EPX0hB?wdY*X0esD0`m# zb!%s$aNeW~M(`(MgN*SfkwBa+a5I^>880vKv@u!e(sUfn%(D|VO;7ixKdZb{3@t78 z<92UPiFJi_N^8n^W+T)n{G;hLk&uNAcjs;ZcjD=uWRJNZ@fmI?RtvYvp~KbqBmsyq zG*)lD>I>cp7)G{Qs~)6AF9t$u|21*o$h!w%8Z9=8_=?0py<)AI!KiA~O)Z``Rm&c7 zx8+VLv~=S6N|q1UyYq++sj#rm+AtAv3EUK~#7EX-LkvEn0BuU>8u{D@Veu;}E()mX z{JzInYa?fo_nA->LCMh5(aEWsG2;`d!~0ZLJv$(nBLY1Dg@(4-9`3-NAKZa(zub8s zP@U-YXkVpiOP<9*kKEjb7uXDg*uv+7=v=F&s-pkphA`}V5fRWPGJM8f#$)Lt^@QG7 z39jb@JkG&za2lUB#2#(a+tkfNP+*zyXG1sy__PJ{6d2!o!EIH;R7VFYJk|!;SXk0( zP2}X^Uew!{39sJrpi?8?&?<;OSIBfiUL}md^7jK+b2e6S%(h9jo9B)0Yt=6j;FmlW zMWT4n?Uf{&l6qiT*TXN}9o42|1Tp+u(%0Jt8edp=N%S-Bo792&*8TbC9SeWMvt$4| z*?vE=#b(}$PZYJ3<+in zzKxzuV>O^fmclc5vPiAZuxgB6lf}&Ou?3qskv?i2Va3$f^wI(w<63k2Nh&rr3jRd; z=@fmQKw5zPZKSlo4U?f{h;@?-@M3sld@N^E`bdkzaQ^H6rXUkIKFO|eu`ns>4DLl! z-9u+$+N#aH-`cRC@6MyeH}M|Nfyz| z|9V>kx||uF6-BZy^oXSwL4Hna2`quG?Q5Q7%7N;YcjjC^d&Y(hvteg>AL>IM+T93xoY zu`q_B6y*JH(f-XMNg?m(LQ$9GhL%E|h_fX=E1%507e$P_|6SgyDtIc&mc6g!b$a=0 zmFe$&H6!qPo~Jt!la|{yy(yS7Kc|n*txf7vwC*-A^sp?Ndf?yJI5Z7s4_4Xv(xr>a z+ybsNDB(Dj_8rUTW&Bz`D8lSwcpN&b<8#URhr?13m!YeqwDXfe4!gQk6}vu_-%8GJj$9a7>KN_*Ffud$y&n^LO!+ z3yiWdSsh_XoKK9-))0LUm4M-~POPhTX6Z|^fND)hABTJ^8WD}(@AEe7Li?YP=nL-7 zVQyfoInpvaFn-**dc3=F5QcY@n{5G#I{f6E+gU`^ANv^$jaiilOdPP4^Rav9UR~{S z0V##wTi8Ai^hhP;I|9BH>O?^4CiPZ!l=#1?c3tNJZm`W?Z*KS@cLLI2q;IF#T2)xB>zEUOU z8YedOr8yvNy29?@YSd-i+6^?r^~}2t&7?4Sl~)HEumbFm|?yuz@l9?mSkz+O(Vq`IC? z%G|tO_T(8(n2X9`O|S={{C**i_K(t z1x8mXr1X>=fjZsYDd9+k5vmekdOC~xybAX9XI!+)Xuu?r+vp@+Gl>*H z9HPe_48-vmAdgtT9o6b(*v(SnTG6Q*EG*Pqy_O6OiB9cfkis;qz5-}uIiW+bWL)2hW{9KMJ&meCmu~+QB>K5S_320Sj{;t1yTW#_e%k(I*1lU*po6 zTT-?{Fww0{Gu3p9gsE-?A&kKH@C}%11i9&RWCfMzkZ|Xn0W-3nIUV&}34l~|M7Q6cNOJnR(zd1UFvKUFt2HsIm zaB9*bxij{v&cKoif0twgHtR?Agw?Hotau%Zx@6S#wNRDe)TUO!o6PQa=HO)oE4k`4g1b>-z~nd ze#!V*3w98?jhL^!3!ZY_0c8Wq>CHO&q7EK#4raz}1X`Tm6)$ z-ln~-gk&Y3ksRmx*W)Zk1{ay?cm38g?U4CE4Q13NJo~$2DK2o) zZCcSieq^CBz};Tol0WS3=JxY6gA8KS%fd?TCU2qth_fmNxO3)e|NV(ScIe>X0KMk- zTZ23Y(e}T_-m5QlPZNxfgyufF_u>j33<4P(BYPkW1B|;FEuI|Llon(hDB&St?ZxRp zg|T19xgQ?v4>E|9j{e^HzvsTK?o9zO5ZZtej;0TSK$svHcX?E@V~?aXuHw2=EWN19 zDZ}U{=Nln~(iLaz0RQ-1@c1bKvS&bs6fu`@O0dD1n(CiT{Mh|3O8=8nI;Tn}?8ujX zryVDjfl(*OKvY(yp;m2{;pWqzdtcMI-f;tOO;Do9U(Nj(NrKe82DUddMto`Un1@w` zE!_bKF+mu)zhW}c(XsHASssai%OS8n{%j|K;-bI%NW2iqOsTSn-QfP9h+1(0V3z-V j{&xcZpHE=IbnWmT!XI(2GBW4%(H}*s>A>?<%>DiYu*bW0 literal 0 HcmV?d00001 diff --git a/resources/web/device_page/icons/exclamationMark.svg b/resources/web/device_page/icons/exclamationMark.svg new file mode 100644 index 00000000000..5c88103422b --- /dev/null +++ b/resources/web/device_page/icons/exclamationMark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/web/device_page/icons/extruderBackground.svg b/resources/web/device_page/icons/extruderBackground.svg new file mode 100644 index 00000000000..c956cd26c74 --- /dev/null +++ b/resources/web/device_page/icons/extruderBackground.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/web/device_page/icons/help.svg b/resources/web/device_page/icons/help.svg new file mode 100644 index 00000000000..8861c0f8f73 --- /dev/null +++ b/resources/web/device_page/icons/help.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconAuxiliaryCooling.svg b/resources/web/device_page/icons/iconAuxiliaryCooling.svg new file mode 100644 index 00000000000..06a686b4c86 --- /dev/null +++ b/resources/web/device_page/icons/iconAuxiliaryCooling.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/resources/web/device_page/icons/iconEdit.svg b/resources/web/device_page/icons/iconEdit.svg new file mode 100644 index 00000000000..26e89b95db2 --- /dev/null +++ b/resources/web/device_page/icons/iconEdit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconExtruder1.svg b/resources/web/device_page/icons/iconExtruder1.svg new file mode 100644 index 00000000000..2eeadcc675a --- /dev/null +++ b/resources/web/device_page/icons/iconExtruder1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconExtruder2.svg b/resources/web/device_page/icons/iconExtruder2.svg new file mode 100644 index 00000000000..4427dcaa0e9 --- /dev/null +++ b/resources/web/device_page/icons/iconExtruder2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconExtruder3.svg b/resources/web/device_page/icons/iconExtruder3.svg new file mode 100644 index 00000000000..2057db7f31c --- /dev/null +++ b/resources/web/device_page/icons/iconExtruder3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconExtruder4.svg b/resources/web/device_page/icons/iconExtruder4.svg new file mode 100644 index 00000000000..b6f40c3dec6 --- /dev/null +++ b/resources/web/device_page/icons/iconExtruder4.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconExtruderHead.svg b/resources/web/device_page/icons/iconExtruderHead.svg new file mode 100644 index 00000000000..453913fc753 --- /dev/null +++ b/resources/web/device_page/icons/iconExtruderHead.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/web/device_page/icons/iconFan.svg b/resources/web/device_page/icons/iconFan.svg new file mode 100644 index 00000000000..596a33a9516 --- /dev/null +++ b/resources/web/device_page/icons/iconFan.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/web/device_page/icons/iconFilamentCheck.svg b/resources/web/device_page/icons/iconFilamentCheck.svg new file mode 100644 index 00000000000..299cba963a0 --- /dev/null +++ b/resources/web/device_page/icons/iconFilamentCheck.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconFilamentEdit.svg b/resources/web/device_page/icons/iconFilamentEdit.svg new file mode 100644 index 00000000000..b180a37520a --- /dev/null +++ b/resources/web/device_page/icons/iconFilamentEdit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconFile.svg b/resources/web/device_page/icons/iconFile.svg new file mode 100644 index 00000000000..b1b25cebaa4 --- /dev/null +++ b/resources/web/device_page/icons/iconFile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconHome.svg b/resources/web/device_page/icons/iconHome.svg new file mode 100644 index 00000000000..31d6ecdb82d --- /dev/null +++ b/resources/web/device_page/icons/iconHome.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconHotBedTemperature.svg b/resources/web/device_page/icons/iconHotBedTemperature.svg new file mode 100644 index 00000000000..b819a630123 --- /dev/null +++ b/resources/web/device_page/icons/iconHotBedTemperature.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/resources/web/device_page/icons/iconLed.svg b/resources/web/device_page/icons/iconLed.svg new file mode 100644 index 00000000000..e402e527d7a --- /dev/null +++ b/resources/web/device_page/icons/iconLed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconLog.svg b/resources/web/device_page/icons/iconLog.svg new file mode 100644 index 00000000000..bd07eb8359e --- /dev/null +++ b/resources/web/device_page/icons/iconLog.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/web/device_page/icons/iconMainCooling.svg b/resources/web/device_page/icons/iconMainCooling.svg new file mode 100644 index 00000000000..45d77fd0172 --- /dev/null +++ b/resources/web/device_page/icons/iconMainCooling.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/web/device_page/icons/iconModelFileFolder.svg b/resources/web/device_page/icons/iconModelFileFolder.svg new file mode 100644 index 00000000000..1d4136aa92e --- /dev/null +++ b/resources/web/device_page/icons/iconModelFileFolder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/iconNozzle.svg b/resources/web/device_page/icons/iconNozzle.svg new file mode 100644 index 00000000000..c65c2cb5001 --- /dev/null +++ b/resources/web/device_page/icons/iconNozzle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/web/device_page/icons/iconPurifier.svg b/resources/web/device_page/icons/iconPurifier.svg new file mode 100644 index 00000000000..c98e46cb412 --- /dev/null +++ b/resources/web/device_page/icons/iconPurifier.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/resources/web/device_page/icons/iconScan.svg b/resources/web/device_page/icons/iconScan.svg new file mode 100644 index 00000000000..30b4e52339c --- /dev/null +++ b/resources/web/device_page/icons/iconScan.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/web/device_page/icons/iconSpeed.svg b/resources/web/device_page/icons/iconSpeed.svg new file mode 100644 index 00000000000..8c692e10873 --- /dev/null +++ b/resources/web/device_page/icons/iconSpeed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/web/device_page/icons/iconTimeLapse.svg b/resources/web/device_page/icons/iconTimeLapse.svg new file mode 100644 index 00000000000..21235d469a6 --- /dev/null +++ b/resources/web/device_page/icons/iconTimeLapse.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/web/device_page/icons/keyboardArrowDropDown.svg b/resources/web/device_page/icons/keyboardArrowDropDown.svg new file mode 100644 index 00000000000..38f12badf42 --- /dev/null +++ b/resources/web/device_page/icons/keyboardArrowDropDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/web/device_page/icons/liveCamera.svg b/resources/web/device_page/icons/liveCamera.svg new file mode 100644 index 00000000000..5772402c6d8 --- /dev/null +++ b/resources/web/device_page/icons/liveCamera.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/web/device_page/icons/printHistory.svg b/resources/web/device_page/icons/printHistory.svg new file mode 100644 index 00000000000..79b54914bea --- /dev/null +++ b/resources/web/device_page/icons/printHistory.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/web/device_page/icons/printPreferenceArrow.svg b/resources/web/device_page/icons/printPreferenceArrow.svg new file mode 100644 index 00000000000..3f5257be4d1 --- /dev/null +++ b/resources/web/device_page/icons/printPreferenceArrow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/web/device_page/icons/refresh.svg b/resources/web/device_page/icons/refresh.svg new file mode 100644 index 00000000000..6397d02edb2 --- /dev/null +++ b/resources/web/device_page/icons/refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/web/device_page/icons/settings.svg b/resources/web/device_page/icons/settings.svg new file mode 100644 index 00000000000..2f219e01337 --- /dev/null +++ b/resources/web/device_page/icons/settings.svg @@ -0,0 +1,5 @@ + + + + diff --git a/resources/web/device_page/icons/videoCall.svg b/resources/web/device_page/icons/videoCall.svg new file mode 100644 index 00000000000..0141e881f13 --- /dev/null +++ b/resources/web/device_page/icons/videoCall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/videoImgPlay.svg b/resources/web/device_page/icons/videoImgPlay.svg new file mode 100644 index 00000000000..0c499001617 --- /dev/null +++ b/resources/web/device_page/icons/videoImgPlay.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/icons/videoPlay.svg b/resources/web/device_page/icons/videoPlay.svg new file mode 100644 index 00000000000..2b04b201839 --- /dev/null +++ b/resources/web/device_page/icons/videoPlay.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/web/device_page/index.html b/resources/web/device_page/index.html new file mode 100644 index 00000000000..ea5d365dece --- /dev/null +++ b/resources/web/device_page/index.html @@ -0,0 +1,42 @@ + + + + + +Snapmaker U1 — Device + + + + + + +

+ + + +
+ +
+ +
+ + + + + diff --git a/resources/web/device_page/js/app.js b/resources/web/device_page/js/app.js new file mode 100644 index 00000000000..a063d138aee --- /dev/null +++ b/resources/web/device_page/js/app.js @@ -0,0 +1,397 @@ +/* + * app.js - wiring. + * + * Startup sequence mirrors the shipped page's own order: + * 1. sw_GetConnectedMachine who are we talking to + * 2. sw_SetSubscribeFilter declare the 24-object field filter + * 3. sw_GetMachineState full snapshot + * 4. sw_SubscribeMachineState stream deltas (needs event_id) + */ +'use strict'; + +import * as multiACE from '../../shared/js/multiACE.js'; +import { CMD, SUBSCRIBE_OBJECTS, NAMED, LIMITS, TASK_CONFIG, PRINT_PREFERENCES, + asDeviceList, deviceLabel, DEVICE, hasTlsMaterial, + CAMERA_DOMAIN, CAMERA_INTERVAL, cssColor, timelapseUrl } + from '../../shared/js/protocol.js'; +import { openMenu, openDialog, openBlockingDialog, toggleField, numberField } + from './core/overlay.js'; +import { connect as connectDevice, disconnect as disconnectDevice } from './core/connection.js'; +import { Sswcp, isTimeout } from '../../shared/js/sswcp.js'; +import { MachineState } from '../../shared/js/state.js'; +import { machineActivity, isBusy } from '../../shared/js/activity.js'; +import { installMock } from './core/mock.js'; +import { Pending } from './core/pending.js'; +import { createStore } from './core/store.js'; +import { createSession } from './core/session.js'; +import { DIAG, createLog } from './core/diag.js'; +import { mountBuildBadge } from '../../shared/js/buildinfo.js'; +import { $ } from './core/dom.js'; +import { renderRail } from './widgets/rail.js'; +import { makeTrace } from './widgets/trace.js'; +import { buildShell, paint } from './shell.js'; +// aliased: this file has its own render() +import * as renderPrims from './core/render.js'; +import * as cmdPage from './page-commands.js'; +import * as cmdControl from './views/device-control/control/control-commands.js'; +import * as cmdTask from './views/device-control/task/task-commands.js'; +import * as cmdCamera from './views/device-control/camera/camera-commands.js'; +import * as cmdFilament from './views/device-control/filament/filament-commands.js'; +import * as cmdStorage from './views/storage/storage/storage-commands.js'; +import * as cmdFault from './views/fault/fault-commands.js'; +import * as cmdDevice from './widgets/rail-commands.js'; + +const qs = new URLSearchParams(location.search); +const wantMock = qs.get('mock') === '1'; + +const state = new MachineState(); +// What the machine says, what the page knows, and what has been asked for but not yet +// confirmed. Three stores because they answer three different questions - see ctx below. +const store = createStore(); +let bridge = null; +let trace = () => {}; +// Every control that has asked the machine for something and is waiting to be told it +// happened. One store, because the request-vs-mirror bug has now been found in three +// separate controls; see pending.js. +const pending = new Pending({ onChange: () => render() }); + +// Diagnostics and the session both start before the bridge does, and both take it as a +// getter for that reason - the first thing worth logging is often that it never arrived. +const hostLog = createLog(() => bridge); + +// Every command, merged, and empty until the modules below fill it. Declared up here +// because the session is built before them and holds a reference: it needs the device +// list re-read on a reconnect, which is a page-level command. +const all = {}; + +/** + * Having a printer on the other end of the bridge: the connect path, whether the machine + * is still there, reconnecting when it is not, the heartbeat, and the state stream. + * + * Injected rather than imported, because the dependencies run the other way - the + * session asks this file to repaint and to say things. + */ +const session = createSession({ + bridge: () => bridge, + state, store, setStatus, hostLog, + render: () => render(), + refresh: () => all.refresh(), + handlers: all, +}); + +function setStatus(text, kind = '') { + const n = $('#status'); + n.textContent = text; + n.className = 'status ' + kind; +} + +async function boot() { + trace = makeTrace($('#trace')); + + // Use the real host when present; fall back to the simulator otherwise. + let mock = null; + if (wantMock || !Sswcp.hasHost()) { + mock = installMock({ log: trace }); + window.__devicePage.mock = mock; // lets tests/screenshots drive the simulation + setStatus(mock ? 'simulated printer (no Orca host)' : 'connected', mock ? 'mock' : 'ok'); + } else { + setStatus('connected to Orca', 'ok'); + } + $('#mode').textContent = mock ? 'MOCK' : 'LIVE'; + $('#mode').className = 'mode ' + (mock ? 'mock' : 'live'); + + bridge = new Sswcp({ + log: (kind, packet) => { + trace(kind, packet); + const p = (packet && packet.payload) || {}; + // Never trace the log command itself: hostLog sends sw_FileLog, which the + // tracer would log, which sends another sw_FileLog. That is a live loop, + // not a theoretical one — it filled the sink in under a second. + if (!DIAG || p.cmd === CMD.FILE_LOG) return; + if (kind === 'tx') hostLog(`→ ${p.cmd}${p.event_id ? ' [sub]' : ''}`, 'debug'); + }, + }); + + // Two different questions, and asking only the first is why a saved-but-idle + // machine never appeared: sw_GetConnectedMachine returns a device ONLY when its + // connected flag is set, while sw_GetLocalDevices returns everything Orca knows. + try { + const u = await bridge.request(CMD.GET_USER_LOGIN_STATE, {}); + if (u && u.status === 'online') store.loginUser = { userid: u.userid, nickname: u.nickname }; + } catch (e) { /* not signed in; pairing still works with empty identifiers */ } + + try { + store.devices = asDeviceList(await bridge.request(CMD.GET_LOCAL_DEVICES, {})); + } catch (e) { + console.warn('[app] device list:', e.message); + } + try { + const c = await bridge.request(CMD.GET_CONNECTED_MACHINE, {}); + store.device = (c && Object.keys(c).length) ? c : null; + } catch (e) { + console.warn('[app] no connected machine:', e.message); + } + // Fall back to a saved machine, so the page names the printer it is about even + // while nothing is connected. + if (!store.device) store.device = store.devices.find((d) => d[DEVICE.CONNECTED]) || store.devices[0] || null; + if (!store.device) { + setStatus('no printer configured', 'warn'); + } else if (!store.device[DEVICE.CONNECTED]) { + setStatus(`${deviceLabel(store.device)} \u2014 not connected`, 'warn'); + } + + try { + await bridge.subscribe(CMD.SUBSCRIBE_LOCAL_DEVICES, {}, (d) => { + const list = asDeviceList(d); + if (!list.length) return; + store.devices = list; + const conn = list.find((x) => x[DEVICE.CONNECTED]); + if (conn) store.device = conn; + else if (store.device) store.device = list.find((x) => x[DEVICE.SN] === store.device[DEVICE.SN]) || store.device; + render(); + }); + } catch (e) { + console.warn('[app] device subscription:', e.message); + } + + // Fails harmlessly when nothing is connected yet; the connect path re-runs it. + await session.startStateStream('boot'); + + state.onChange(render); + render(); + session.supervise(); + + // The shipped page brings a session up by itself on load - the very first + // harness capture caught it emitting sw_create_mqtt_client unprompted. Match + // that, but only where it can succeed without prompting: a machine that + // already has keys, or a signed-in account that can be issued them from the + // cloud. Pairing needs a human reading a code off the printer, so it stays + // a deliberate action. + if (store.device && !state.lastUpdate) { + // A LAN machine with an address can authorise itself with the fixed code, so + // stored keys and a signed-in account are no longer preconditions. + if (store.device[DEVICE.IP] && store.device[DEVICE.SN]) { + setStatus(`Connecting to ${deviceLabel(store.device)}\u2026`); + session.connect(store.device, { silent: true }); + } else { + setStatus(`${deviceLabel(store.device)} \u2014 not paired`, 'warn'); + } + } + + // Reconstruction marker: shows which surface this is and which build it + // reports, so a rebuilt page is identifiable on sight. + mountBuildBadge($('#build-badge'), 'Device', bridge) + .then((info) => { window.__devicePage.build = info; }) + .catch(() => {}); +} + +/* ---- control handlers -------------------------------------------- */ + + +async function send(cmd, params, label) { + try { + await bridge.request(cmd, params); + return true; + } catch (e) { + console.error(`[app] ${label} failed:`, e.message); + setStatus(`${label} failed: ${e.message}`, 'err'); + return false; + } +} + +/** + * A heater setpoint: send it, then say so. + * + * The row holds the asked-for value on screen until the machine echoes it back, so the + * one thing it cannot see for itself is a command that never left. Returning whether + * the bridge accepted it lets the row stop waiting the moment it did not, instead of + * sitting on a value the printer never heard. + */ +async function setpoint(cmd, params, label, said) { + const ok = await send(cmd, params, label); + if (ok) setStatus(said); + return ok; +} + + +/* + * The command modules, one per panel plus two that belong to no panel. + * + * Each panel is handed its own module merged with the page-level one, and nothing else - + * so a renderer reaching for a command its panel does not own is a TypeError rather than + * a silent cross-panel dependency. `handlers` used to be one flat bag of forty-three + * functions given whole to every renderer, and the only record of who used what was a + * hand-written `sends` list in the registry. + * + * That is the point of the split: check_coverage.py reads the CMD references out of + * `commands/.js` and attributes them to that panel because that is the module the + * panel is given. The attribution is a fact about the imports rather than a promise. + * + * `all` is how the modules reach each other - showFiles opens Storage, a reconnect + * re-reads the device list. Those crossings are few and are meant to stay visible. + */ +const MODULES = { + page: cmdPage, control: cmdControl, task: cmdTask, camera: cmdCamera, + filament: cmdFilament, storage: cmdStorage, fault: cmdFault, device: cmdDevice, +}; +const deps = { + // a getter: boot() decides between the real host and the simulator, long after this + get bridge() { return bridge; }, + // and the simulator itself, for the one thing that does not go over the bridge at all. + // Camera frames are plain HTTP to the printer, so with no printer there is nothing to + // answer them - the simulator has to, or the camera is the one panel a simulated run + // cannot exercise. + get mock() { return window.__devicePage.mock; }, + state, store, pending, session, cmd: all, + send, setpoint, setStatus, render: () => render(), +}; +const byModule = {}; +for (const [name, mod] of Object.entries(MODULES)) { + byModule[name] = mod.create(deps); + Object.assign(all, byModule[name]); +} + +/** What one panel may call: its own commands, plus the page-level ones. */ +const commandsFor = (id) => ({ ...byModule.page, ...(byModule[id] || {}) }); + +/** + * What a panel is handed, and the only thing it may read. + * + * Four things, and they are four because they answer four different questions: + * + * state what the MACHINE says. A mirror, not a memory. + * store what the PAGE knows - which view, which tab, what it has fetched. + * pending what has been ASKED FOR and not yet confirmed. Neither of the above: + * keeping a request in the thing that mirrors the machine is the bug + * that has now been found in three separate controls. + * commandsFor what THIS panel may do. Scoped, so the answer to "who calls this" is + * the module list rather than a search. + * + * The store is passed rather than copied. Panels hold this object from the moment they + * mount, and a frame's worth of values copied into a fresh object every repaint is how + * a stale `device` reaches a renderer that has been alive longer than the copy. + */ +const ctx = { + state, store, pending, commandsFor, + // the rail's device menu is not a panel, so it takes its module directly + handlers: { ...byModule.page, ...byModule.device }, +}; + +/* ---- render ------------------------------------------------------- */ + + +let raf = 0; +function render() { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + const conn = state.connection(); + + // DeviceInfo.connected is NOT a reliable "is it reachable" signal: AppConfig + // force-clears it for every device on each config save (AppConfig.cpp:887), + // so it is false on disk by construction and only meaningful within a run. + // Live machine state is the real evidence — if objects are arriving, we are + // talking to a printer. + store.reachable = session.isLive() && (!conn.state || conn.state === 'ready'); + + renderRail(store.device, conn, store.devices, store.reachable); + paint(ctx, store.view); + }); +} + +/* ---- trace pause toggle ------------------------------------------- */ +/* ---- connecting ----------------------------------------------------- */ + + +/* ---- chrome interactions ------------------------------------------- */ + + +/** + * The store.device selector's menu. + * + * The only chrome left in this file: it is about the *page's* relationship to Orca - + * which machines are saved, connect, pair, rename, forget, discover - rather than about + * any one panel, so it has no panel to belong to. Panel headers are declared in + * js/panels/registry.js and built by js/shell.js. + */ +function wireDeviceMenu() { + const sel = $('#device-select'); + sel.setAttribute('data-menu-anchor', ''); + sel.onclick = () => { + const items = store.devices.length + ? store.devices.map((d) => ({ + label: deviceLabel(d) + (d[DEVICE.CONNECTED] ? '' : ' (not connected)'), + icon: 'deviceControl', + muted: !d[DEVICE.CONNECTED], + onClick: () => { + store.device = d; + render(); + if (!d[DEVICE.CONNECTED]) session.connect(d); + }, + })) + : [{ label: 'No printers saved', icon: 'deviceControl', muted: true }]; + items.push(null); + // Disconnect is only offered for a session this page created - the engine is + // addressed by the id sw_create_mqtt_client handed back, and a session Orca + // brought up on its own has no id we can name. + // Gated on live evidence, not on DeviceInfo.connected: that flag is force-cleared + // on every config save (AppConfig.cpp:887), so it read false for a machine that was + // right there - and could read true for a session that had died. Connect was being + // offered, and withheld, on the strength of a field that answers neither question. + if (store.device && session.engineId && session.isLive()) { + items.push({ label: 'Disconnect', icon: 'iconHome', onClick: () => session.disconnect() }); + } else if (store.device) { + items.push({ + label: hasTlsMaterial(store.device) ? 'Connect now' : 'Pair and connect…', + icon: 'deviceControl', + onClick: () => { session.resetBackoff(); session.connect(store.device); }, + }); + } + if (store.device) { + items.push({ label: 'Rename…', icon: 'iconEdit', + onClick: () => ctx.handlers.renameDevice(store.device) }); + items.push({ label: 'Forget this printer…', icon: 'delete', + onClick: () => ctx.handlers.forgetDevice(store.device) }); + } + items.push(null); + items.push({ label: 'Printer information…', icon: 'deviceControl', + onClick: () => ctx.handlers.showSystemInfo() }); + items.push({ label: 'Defect detection…', icon: 'exclamationMark', + onClick: () => ctx.handlers.showDefectSettings() }); + items.push(null); + items.push({ label: 'Find printers…', icon: 'iconScan', + onClick: () => ctx.handlers.findMachines() }); + items.push({ label: 'Add Device', icon: 'iconHome', + onClick: () => send(CMD.ADD_DEVICE, {}, 'add device') }); + items.push({ label: 'Connect another machine…', icon: 'iconHome', + onClick: () => ctx.handlers.connectOther() }); + openMenu(sel, items); + }; +} + +window.addEventListener('DOMContentLoaded', () => { + buildShell(ctx); + wireDeviceMenu(); + boot().catch((e) => { + console.error(e); + setStatus(`startup failed: ${e.message}`, 'err'); + }); +}); + +// expose for console poking during RE work, and for the screenshot harness +// `render` is here for the harness rather than for console poking: keyedList is DOM +// reconciliation, so the only honest place to test it is a real DOM, and run_webkit.py's +// checks run as a classic script that cannot import a module. +window.__devicePage = { get state() { return state; }, get bridge() { return bridge; }, + get device() { return store.device; }, + get devices() { return store.devices; }, + store, pending, render: renderPrims, + // `ace` is not on the stream, so a script that changes the + // simulated ACE has to ask the page to look again - which is what + // every control that changes it does too. + session, + // The one module a drive script has to be able to call directly: + // its pure functions are the model, and a check that re-implements + // them inline is checking its own copy rather than the page's. + multiACE, + handlers: all, byModule, mock: null }; diff --git a/resources/web/device_page/js/core/connection.js b/resources/web/device_page/js/core/connection.js new file mode 100644 index 00000000000..8967ab73928 --- /dev/null +++ b/resources/web/device_page/js/core/connection.js @@ -0,0 +1,350 @@ +/* + * connection.js - bring an MQTT session to the printer up. + * + * The page owns the transport; Orca owns the socket. Every step below is a + * bridge command, and the whole sequence is recovered from SSWCP.cpp and + * MoonRaker.cpp - see docs/u1-webui/02-device-page/06-connection.md + * + * Two paths, chosen by whether the saved device already carries mTLS material: + * + * already paired first-time LAN pairing + * -------------- ---------------------- + * sw_GetPincode printer shows a PIN + * (user reads it off the machine) + * sw_create_mqtt_client sw_create_mqtt_client mqtts:// vs mqtt://:1884 + * sw_mqtt_connect sw_mqtt_connect + * sw_mqtt_set_engine sw_mqtt_set_engine + code: + * + * `sw_mqtt_set_engine` is where the real work happens on the C++ side: it builds + * a Moonraker_Mqtt host, attaches the engine, and - when a `code` is supplied - + * runs Moonraker_Mqtt::ask_for_tls_info(), which publishes `server.request_key` + * and receives {state, sn, clientid, ca, cert, key, port}. Orca then stores that + * on the device record and flips `connected` to true, which reaches us as a + * push on sw_SubscribeLocalDevices. + */ +'use strict'; + +import { CMD, DEVICE, PAIR_PORT, LAN_AUTH_CODE, MQTT_KEEPALIVE, hasTlsMaterial } + from '../../../shared/js/protocol.js'; + +/* + * Cloud hosts. The `.com` / `.cn` split is region selection; Orca appends the + * locale to this page's own URL, so the region can be read from there. + * docs/u1-webui/05-printer-protocol/05-cloud-api.md + */ +function cloudBase() { + const loc = new URLSearchParams(location.search).get('locale') || ''; + return /(^|-)(cn|CN)$/.test(loc) || /zh/i.test(loc) + ? 'https://api.snapmaker.cn/api' + : 'https://api.snapmaker.com/api'; +} + +/** + * Ask the cloud for a device's mTLS material. + * + * This is the counterpart of the LAN `server.request_key` handshake: both end + * with {ca, cert, key, port, clientId}, after which the transport is identical. + * Used when the user is signed in and the saved device has no keys of its own — + * which is the normal state, because Orca force-clears `connected` on every + * config save and never persists cloud-issued keys. + */ +export async function fetchCloudCert(device, user) { + if (!user || !user.token) return null; + const sn = device[DEVICE.SN]; + const url = `${cloudBase()}/user/device/getMqttCert?sn=${encodeURIComponent(sn)}`; + const r = await fetch(url, { + headers: { Authorization: `Bearer ${user.token}`, Accept: 'application/json' }, + }); + if (!r.ok) throw new Error(`cloud responded ${r.status}`); + const j = await r.json(); + const d = (j && (j.data || j.result)) || j; + if (!d || !d.ca || !d.cert || !d.key) return null; + return { + [DEVICE.CA]: d.ca, + [DEVICE.CERT]: d.cert, + [DEVICE.KEY]: d.key, + [DEVICE.PORT]: d.port || 8883, + [DEVICE.CLIENT_ID]: d.clientId || d.clientid || device[DEVICE.CLIENT_ID], + }; +} + +/** + * Ask the printer for its mTLS material over the plain connection. + * + * This has to happen here, in the page. `sw_mqtt_set_engine` looks like it would + * do it — Moonraker_Mqtt::connect() calls ask_for_tls_info() — but that path is + * dead: SSWCP.cpp:6643 hardcodes `bool res = true;` and never calls connect(). + * The host expects to be handed an ALREADY-CONNECTED mTLS engine, which is + * exactly what a captured session from the shipped page shows it doing. + * + * Wire form, both on `/config/...`: + * → {"jsonrpc":"2.0","method":"server.request_key","params":{"clientid":…},"id":n} + * ← {"jsonrpc":"2.0","id":n,"result":{state,sn,clientid,ca,cert,key,port}} + */ +async function requestKeys(bridge, engineId, clientId, trace, timeoutMs = 30000) { + const reqTopic = `${LAN_AUTH_CODE}/config/request`; + const resTopic = `${LAN_AUTH_CODE}/config/response`; + const id = Date.now() % 100000000; + + let resolveKeys; + const keys = new Promise((res) => { resolveKeys = res; }); + + await bridge.subscribe(CMD.MQTT_SUBSCRIBE, + { id: engineId, topic: resTopic, qos: 1, event_id: resTopic }, + (data) => { + // The push carries the raw MQTT payload as a string. + const raw = (data && (data.data !== undefined ? data.data : data)) || ''; + let msg; + try { msg = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return; } + const r = msg && (msg.result || msg.params || msg); + trace(`config/response payload: ${JSON.stringify(msg).slice(0, 300)}`); + if (r && r.ca && r.cert && r.key) { + trace(`request_key: got material (state=${r.state || '?'}, port=${r.port || '?'})`); + resolveKeys(r); + } + }); + trace(`subscribed ${resTopic}`); + + // TimeSyncManager::addTimeFields stamps every request the C++ sends, and the + // printer expects them: cli_time in unix seconds, dev_time -1 until the clocks + // have been synced. Without these the request is ignored silently. + // Which auth method a firmware implements is not recoverable from the bundle — + // it ships call sites for several. The printer is the oracle: an unsupported + // method comes back as -32601, so try each and keep the one it accepts. + // + // request_lan_auth is first because it is the one verified against real + // hardware; server.request_key — the method the C++ itself sends — is answered + // with "Method not found" by this firmware. + const appId = `orca-${Date.now() * 1000}`; + const candidates = [ + { method: 'server.client_manager.request_lan_auth', + params: { clientid: clientId, app_id: appId } }, + { method: 'server.client_manager.request_pin_code', + params: { userid: '', nickname: 'Snapmaker Orca', app_id: appId } }, + { method: 'server.request_key', params: { clientid: clientId } }, + ]; + + for (const c of candidates) { + const body = JSON.stringify({ + jsonrpc: '2.0', method: c.method, params: c.params, id: id + candidates.indexOf(c), + // TimeSyncManager stamps every request the C++ sends and the printer + // expects them: cli_time in unix seconds, dev_time -1 until synced. + cli_time: Math.floor(Date.now() / 1000), + dev_time: -1, + }); + await bridge.request(CMD.MQTT_PUBLISH, + { id: engineId, topic: reqTopic, qos: 1, payload: body }); + trace(`tried ${c.method}`); + const got = await Promise.race([ + keys, + new Promise((r) => setTimeout(() => r(null), 6000)), + ]); + if (got) return got; + } + + return Promise.race([ + keys, + new Promise((_, rej) => + setTimeout(() => rej(new Error(`no key material within ${timeoutMs}ms`)), timeoutMs)), + ]); +} + +/** The create payload the shipped page sends, minus the credentials. */ +function mqttParams(address, clientId, device, sn, user) { + return { + server_address: address, + clientId, + // The shipped page sends both spellings. + clientid: clientId, + keepAlivePeriod: MQTT_KEEPALIVE, + clean_session: false, + link_mode: device[DEVICE.LINK_MODE] || 'lan', + sn, + userid: (user && user.userid) || '', + nickname: (user && user.nickname) || '', + id: device[DEVICE.ID] || '', + }; +} + +export class ConnectError extends Error { + constructor(step, cause) { + super(`${step}: ${cause}`); + this.name = 'ConnectError'; + this.step = step; + } +} + +/** A client id is required; reuse the saved one so the printer recognises us. */ +function clientIdFor(device) { + const saved = device[DEVICE.CLIENT_ID]; + if (saved) return saved; + const rnd = (crypto && crypto.randomUUID) + ? crypto.randomUUID() + : String(Date.now()) + Math.random().toString(16).slice(2); + return `orca-${rnd}`; +} + +/** + * Optional diagnostic sink. Set `opts.trace` and every step reports its params + * and the host's reply, which is the only way to see what a real printer said + * when the simulator disagrees with it. + */ +let TRACE = () => {}; + +async function step(name, promise) { + try { + const r = await promise; + TRACE(`${name}: ok ${summarise(r)}`); + return r; + } catch (e) { + TRACE(`${name}: FAILED ${e.message || String(e)}`); + throw new ConnectError(name, e.message || String(e)); + } +} + +/** Short, and never leaks key material into a log file. */ +function summarise(v) { + if (v == null) return '(no data)'; + if (typeof v !== 'object') return String(v).slice(0, 120); + const out = {}; + for (const [k, val] of Object.entries(v)) { + if (/^(ca|cert|key|password|token)$/i.test(k)) { + out[k] = val ? `<${String(val).length} chars>` : ''; + } else if (typeof val === 'string') { + out[k] = val.length > 60 ? `${val.slice(0, 60)}…` : val; + } else if (typeof val !== 'object') { + out[k] = val; + } + } + return JSON.stringify(out).slice(0, 300); +} + +/** + * Connect to `device`. + * + * @param bridge the WCP client + * @param device a DeviceInfo record from sw_GetLocalDevices + * The key exchange needs no human for a machine that already trusts our client + * id, which is why there is no PIN parameter: the auth code is fixed. + * @param opts.user { userid, nickname } for the pairing request + * @param opts.onStep progress callback (text) + */ +export async function connect(bridge, device, opts = {}) { + const { user = {}, onStep = () => {}, trace } = opts; + TRACE = trace || (() => {}); + const ip = device[DEVICE.IP]; + const sn = device[DEVICE.SN]; + if (!ip) throw new ConnectError('precheck', 'the saved device has no IP address'); + if (!sn) throw new ConnectError('precheck', 'the saved device has no serial number'); + + let target = device; + + // A signed-in account can be issued keys by the cloud, which avoids the PIN + // entirely. Try it before falling back to LAN pairing. + if (!hasTlsMaterial(target) && user && user.token) { + onStep('Fetching credentials from your account…'); + try { + const cert = await fetchCloudCert(target, user); + if (cert) target = Object.assign({}, target, cert); + } catch (e) { + // Not fatal: LAN pairing still works. + onStep(`Cloud credentials unavailable (${e.message}); pairing over the LAN instead`); + } + } + + let device_ = target; + const paired = hasTlsMaterial(device_); + const clientId = clientIdFor(device_); + + + // ---- phase 1: plain MQTT on :1884, to obtain credentials --------------- + // + // Skipped entirely when the device already carries keys. + let material = null; + if (!paired) { + const pairAddr = `mqtt://${ip}:${PAIR_PORT}`; + onStep(`Opening ${pairAddr}…`); + // The shipped page uses a throwaway id for this leg and keeps the saved one + // for the session proper. + const tryId = `orca-try-${Date.now()}`; + const p1 = await step('create pairing client', bridge.request(CMD.CREATE_MQTT_CLIENT, + mqttParams(pairAddr, tryId, device_, sn, user))); + if (!p1 || !p1.id) throw new ConnectError('create pairing client', 'no engine id'); + await step('connect pairing client', bridge.request(CMD.MQTT_CONNECT, { id: p1.id })); + + onStep('Exchanging keys…'); + try { + material = await requestKeys(bridge, p1.id, clientId, TRACE); + } catch (e) { + throw new ConnectError('request key', e.message); + } finally { + bridge.request(CMD.MQTT_DISCONNECT, { id: p1.id }).catch(() => {}); + } + device_ = Object.assign({}, device_, { + [DEVICE.CA]: material.ca, + [DEVICE.CERT]: material.cert, + [DEVICE.KEY]: material.key, + [DEVICE.PORT]: material.port || device_[DEVICE.PORT] || 8883, + }); + } + + // ---- phase 2: mTLS on :8883, the session proper ------------------------ + const port = device_[DEVICE.PORT] || 8883; + const address = `mqtts://${ip}:${port}`; + onStep(`Opening ${address}…`); + + const params = mqttParams(address, clientId, device_, sn, user); + params.ca = device_[DEVICE.CA]; + params.cert = device_[DEVICE.CERT]; + params.key = device_[DEVICE.KEY]; + params.username = device_[DEVICE.USER] || ''; + params.password = device_[DEVICE.PASSWORD] || ''; + + TRACE(`create client params ${summarise(params)}`); + const created = await step('create client', bridge.request(CMD.CREATE_MQTT_CLIENT, params)); + const id = created && created.id; + if (!id) throw new ConnectError('create client', 'the host returned no engine id'); + + onStep('Connecting…'); + await step('connect', bridge.request(CMD.MQTT_CONNECT, { id })); + + // The host routes inbound traffic by topic, so the session's topics have to be + // subscribed before it is handed over. + for (const suffix of ['status', 'response', 'notification']) { + const topic = `${sn}/${suffix}`; + await step(`subscribe ${suffix}`, bridge.subscribe(CMD.MQTT_SUBSCRIBE, + { id, topic, qos: 1, event_id: topic }, () => {})); + } + + // ---- hand the engine to Orca's Moonraker host --------------------------- + onStep('Attaching…'); + const engineParams = { + engine_id: id, + ip, + port, + sn, + clientId, + ca: device_[DEVICE.CA], + cert: device_[DEVICE.CERT], + key: device_[DEVICE.KEY], + user: device_[DEVICE.USER] || '', + password: device_[DEVICE.PASSWORD] || '', + link_mode: device_[DEVICE.LINK_MODE] || 'lan', + // Leave our own page alone: with need_reload true the host reloads the + // Device webview mid-sequence, which would tear this down halfway. + need_reload: false, + }; + TRACE(`set engine params ${summarise(engineParams)}`); + await step('set engine', bridge.request(CMD.MQTT_SET_ENGINE, engineParams)); + + onStep('Connected'); + return { engineId: id, paired: true }; +} + +/** Tear the session down. Best effort - a dead engine id is not an error worth raising. */ +export async function disconnect(bridge, engineId) { + if (!engineId) return; + try { + await bridge.request(CMD.MQTT_DISCONNECT, { id: engineId }); + } catch { /* already gone */ } +} diff --git a/resources/web/device_page/js/core/diag.js b/resources/web/device_page/js/core/diag.js new file mode 100644 index 00000000000..6b3bce4cbca --- /dev/null +++ b/resources/web/device_page/js/core/diag.js @@ -0,0 +1,37 @@ +/* + * diag.js - the diagnostics beacon. + * + * Per-command tracing is opt-in (?diag=1) because it is far too chatty for normal use. + * + * It beacons to a local collector as well as to Orca's log, and that is not redundancy: + * sw_FileLog travels over the very bridge being diagnosed, so when that stalls the + * evidence disappears exactly when it is needed. + * + * Never log the logging command. sw_FileLog tracing its own traffic is a live feedback + * loop, not a theoretical one - it filled the sink in under a second. + */ +'use strict'; + +import { CMD } from '../../../shared/js/protocol.js'; + +/** A plain HTTP POST that shares nothing with SSWCP. Harmless with no collector. */ +const DIAG_URL = 'http://127.0.0.1:8799/'; + +/** Per-command tracing is opt-in (?diag=1): it is far too chatty for normal use. */ +export const DIAG = new URLSearchParams(location.search).get('diag') === '1'; + +/** + * @param bridge () => the live client, or null. A getter: diagnostics start before the + * bridge does, and the first thing worth logging is often that it never arrived. + */ +export function createLog(bridge) { + return function hostLog(text, level = 'warning') { + if (DIAG) { + try { navigator.sendBeacon(DIAG_URL, `${level}: ${text}`); } catch { /* no sink */ } + } + try { + bridge().request(CMD.FILE_LOG, + { level, content: `[rebuilt-device] ${text}` }).catch(() => {}); + } catch { /* no bridge yet */ } + }; +} diff --git a/resources/web/device_page/js/core/dom.js b/resources/web/device_page/js/core/dom.js new file mode 100644 index 00000000000..84c014d50d2 --- /dev/null +++ b/resources/web/device_page/js/core/dom.js @@ -0,0 +1,26 @@ +/* + * dom.js - the three primitives every panel builds with. + * + * These lived inside the one big ui.js, where only that file could reach them. Every + * panel's view needs the same three, and a second copy is how two spellings of `icon()` + * start. + */ +'use strict'; + +export const $ = (sel, root = document) => root.querySelector(sel); +export const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + +export function el(tag, cls, text) { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; +} + +/** An icon from this surface's own set. Paths are relative to the page, not to js/. */ +export function icon(name, cls) { + const i = el('img', cls); + i.src = `icons/${name}.svg`; + i.alt = ''; + return i; +} diff --git a/resources/web/device_page/js/core/mock.js b/resources/web/device_page/js/core/mock.js new file mode 100644 index 00000000000..69a2a20b979 --- /dev/null +++ b/resources/web/device_page/js/core/mock.js @@ -0,0 +1,20 @@ +/* + * mock.js - the Device tab's simulated host. + * + * Everything this surface needs is already in the shared simulation, so this + * is a thin adapter. It exists as a named seam: device-only commands (camera, + * timelapse, file browser) get added here without touching the shared host or + * the print-processing surface. + */ +'use strict'; + +import { installMockHost } from '../../../shared/js/mockhost.js'; + +/** Commands only the Device tab issues. */ +const DEVICE_HANDLERS = { + // sw_exception_query: () => ({ exceptions: [] }), +}; + +export function installMock({ log = () => {} } = {}) { + return installMockHost({ log, handlers: DEVICE_HANDLERS }); +} diff --git a/resources/web/device_page/js/core/overlay.js b/resources/web/device_page/js/core/overlay.js new file mode 100644 index 00000000000..6b6254ac18f --- /dev/null +++ b/resources/web/device_page/js/core/overlay.js @@ -0,0 +1,336 @@ +/* + * overlay.js - menus and modal dialogs for the Device tab. + * + * The shipped page opens a small anchored menu from the device selector and + * modal sheets for editing values. This provides both, so the reconstruction + * behaves like the original instead of falling back to window.prompt(). + */ +'use strict'; + +let openMenuEl = null; + +function el(tag, cls, text) { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; +} + +export function closeMenu() { + if (openMenuEl) { openMenuEl.remove(); openMenuEl = null; } +} + +document.addEventListener('click', (e) => { + if (openMenuEl && !openMenuEl.contains(e.target) + && !(e.target.closest && e.target.closest('[data-menu-anchor]'))) { + closeMenu(); + } +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { closeMenu(); closePopover(); if (!blocking) closeDialog(); } +}); + +/** + * Open a menu under `anchor`. + * + * items: [{label, icon, glyph, onClick, muted, cmd, title}] - a null entry draws a + * divider. `glyph` is a node built by the caller, for a drawing that has no icon file - + * the ACE's own, which is geometry from a visual standard rather than an asset. + * `cmd` prints the macro or object behind the item, right-aligned; `title` is the + * hover text, and defaults to `cmd`. + * + * `head` names the menu's scope. The Filament panel has two `...` a few pixels apart - + * one about the printer, one about a single toolhead - and two identical glyphs meaning + * two different things is a real ambiguity. Saying which costs a line that was going to + * be there anyway, so it is worth having wherever a page grows a second menu. + */ +export function openMenu(anchor, items, { head = null } = {}) { + closeMenu(); + const r = anchor.getBoundingClientRect(); + const m = el('div', 'menu'); + m.style.left = `${Math.round(r.left + 16)}px`; + m.style.top = `${Math.round(r.bottom - 8)}px`; + + if (head) m.appendChild(el('div', 'menu-head', head)); + items.forEach((it) => { + if (!it) { m.appendChild(el('div', 'menu-sep')); return; } + const row = el('button', 'menu-item' + (it.muted ? ' is-muted' : '')); + if (it.glyph) { + row.appendChild(it.glyph); + } else if (it.icon) { + const i = el('img'); + i.src = `icons/${it.icon}.svg`; + i.alt = ''; + row.appendChild(i); + } + row.appendChild(el('span', null, it.label)); + if (it.cmd) row.appendChild(el('span', 'mcmd', String(it.cmd).split(' ')[0])); + if (it.title || it.cmd) row.title = it.title || it.cmd; + row.onclick = (ev) => { ev.stopPropagation(); closeMenu(); it.onClick && it.onClick(); }; + m.appendChild(row); + }); + + document.body.appendChild(m); + openMenuEl = m; + // A control that opened a menu IS a menu anchor, and the document listener above needs + // to know: without this the click that opened the menu goes on bubbling to document, + // which closes it again in the same tick. Only the device selector had the attribute + // written on it by hand, so every menu added since would have opened and shut. + anchor.setAttribute('data-menu-anchor', ''); + + // A menu opened from the right-hand edge of a panel would otherwise hang off the + // window; the anchor is often 20px from it. + const box = m.getBoundingClientRect(); + if (box.right > window.innerWidth - 8) { + m.style.left = `${Math.round(Math.max(8, window.innerWidth - box.width - 8))}px`; + } + if (box.bottom > window.innerHeight - 8) { + m.style.top = `${Math.round(Math.max(8, window.innerHeight - box.height - 8))}px`; + } + return m; +} + +/* ---- modal dialog --------------------------------------------------- */ + +let openDialogEl = null; +let blocking = false; // a blocking dialog ignores Escape, the scrim and the X + +export function closeDialog() { + if (openDialogEl) { openDialogEl.remove(); openDialogEl = null; } + blocking = false; +} + +/** + * Modal sheet. `build(body)` fills the body; `onConfirm()` returning false + * keeps it open (so a validator can reject). + * + * `cancel: false` drops the Cancel button, for a sheet that only tells you something: + * offering both Cancel and Close for one dismissal is a choice that is not one. + */ +export function openDialog({ title, build, confirmLabel = 'OK', onConfirm, wide = false, + cancel = true }) { + closeDialog(); + const scrim = el('div', 'scrim'); + const box = el('div', 'dialog' + (wide ? ' wide' : '')); + + const head = el('div', 'dialog-head'); + head.appendChild(el('h3', null, title)); + const x = el('button', 'dialog-x', '×'); + x.onclick = closeDialog; + head.appendChild(x); + box.appendChild(head); + + const body = el('div', 'dialog-body'); + build(body); + box.appendChild(body); + + const foot = el('div', 'dialog-foot'); + if (cancel) { + const no = el('button', 'btn', 'Cancel'); + no.onclick = closeDialog; + foot.appendChild(no); + } + const ok = el('button', 'btn primary', confirmLabel); + ok.onclick = () => { if (onConfirm() !== false) closeDialog(); }; + foot.appendChild(ok); + box.appendChild(foot); + + scrim.appendChild(box); + scrim.onclick = (e) => { if (e.target === scrim && !blocking) closeDialog(); }; + document.body.appendChild(scrim); + openDialogEl = scrim; + + const first = box.querySelector('input, select, button.primary'); + if (first) first.focus(); + return box; +} + +/** A labelled number field, returned with a .value getter. */ +export function numberField(parent, { label, value, min, max, unit, hint }) { + const wrap = el('label', 'field'); + wrap.appendChild(el('span', 'field-label', label)); + const row = el('div', 'field-row'); + const input = el('input'); + input.type = 'number'; + input.value = String(value ?? 0); + if (min != null) input.min = String(min); + if (max != null) input.max = String(max); + row.appendChild(input); + if (unit) row.appendChild(el('span', 'field-unit', unit)); + wrap.appendChild(row); + if (hint) wrap.appendChild(el('span', 'field-hint', hint)); + parent.appendChild(wrap); + return input; +} + +export function toggleField(parent, { label, checked }) { + const row = el('label', 'field-toggle'); + row.appendChild(el('span', null, label)); + const input = el('input'); + input.type = 'checkbox'; + input.checked = !!checked; + row.appendChild(input); + parent.appendChild(row); + return input; +} + +/** + * A modal that cannot be dismissed, for an operation the machine is in the middle of. + * + * The shipped page blocks the surface while a toolchange runs, and it is right to: the + * gantry is moving, and a second command sent into that window is not something the + * user meant. No close button, no scrim click, no Escape - it goes away when the caller + * says the operation is over. + * + * Returns { update(text), fail(text), close() }. + */ +export function openBlockingDialog({ title, message }) { + closeDialog(); + const scrim = el('div', 'scrim'); + const box = el('div', 'dialog blocking'); + + const head = el('div', 'dialog-head'); + head.appendChild(el('h3', null, title)); + box.appendChild(head); + + const body = el('div', 'dialog-body'); + const spinner = el('div', 'spinner'); + body.appendChild(spinner); + const msg = el('p', 'blocking-msg', message || ''); + body.appendChild(msg); + box.appendChild(body); + + scrim.appendChild(box); + document.body.appendChild(scrim); + openDialogEl = scrim; + blocking = true; + + return { + update(text) { msg.textContent = text; }, + fail(text) { + msg.textContent = text; + spinner.remove(); + blocking = false; // let the user out again + const foot = el('div', 'dialog-foot'); + const ok = el('button', 'btn primary', 'Close'); + ok.onclick = closeDialog; + foot.appendChild(ok); + box.appendChild(foot); + }, + close() { blocking = false; closeDialog(); }, + }; +} + +/* ---- anchored popover ------------------------------------------------ */ + +let openPopEl = null; +let openPopAnchor = null; +let openPopBuild = null; +let openPopSig = null; +let openPopSeen = null; + +export function closePopover() { + if (openPopAnchor) openPopAnchor.removeAttribute('aria-expanded'); + if (openPopEl) openPopEl.remove(); + openPopEl = null; + openPopAnchor = null; + openPopBuild = null; + openPopSig = null; + openPopSeen = null; +} + +/** + * Re-run an open popover's `build` when what it draws has changed. + * + * A popover lives on `document.body`, outside every panel, so `paint()` never reaches + * it: it was built once from the state at the moment it opened and then went stale. That + * is invisible for a slider, whose thumb carries its own position - which is why the + * Control panel's four never showed it - and plainly broken for anything with a + * selected-state marker. The camera's settings shipped that way: clicking a view or a + * transport did the thing and left the tick where it was until the panel was reopened. + * + * Signature-guarded, and the signature is the caller's, because rebuilding on every + * frame is the bug `render.js` exists to prevent - it would take focus, hover and a + * slider mid-drag with it. A popover that passes no `sig` is never rebuilt, so the + * existing four behave exactly as they did. + */ +export function repaintPopover() { + if (!openPopEl || !openPopSig || !openPopBuild) return; + const now = String(openPopSig()); + if (now === openPopSeen) return; + openPopSeen = now; + const body = openPopEl.querySelector('.popover-body'); + if (!body) return; + body.innerHTML = ''; + openPopBuild(body); +} + +/** + * A panel anchored under the control that opened it. + * + * The anchor keeps its place and takes `aria-expanded`, which is what the highlight + * hangs off: without it a floating box reads as belonging to the page rather than to + * the thing that was clicked. One at a time, because two open panels have no way to + * say which control each came from. + * + * Placement is measured against the viewport rather than assumed: the panel is wider + * than the 126px column it usually hangs from, so it is clamped to stay on screen, and + * it flips above the anchor when there is no room below. + */ +export function openPopover(anchor, { title, build, width = 320, sig = null }) { + const reopening = openPopAnchor === anchor; + closePopover(); + closeMenu(); + if (reopening) return null; // clicking the open control closes it + + const pop = el('div', 'popover'); + pop.style.width = `${width}px`; + + const head = el('div', 'popover-head'); + head.appendChild(el('span', 'popover-title', title)); + const x = el('button', 'popover-x', '\u00D7'); + x.setAttribute('aria-label', 'Close'); + x.onclick = closePopover; + head.appendChild(x); + pop.appendChild(head); + + const body = el('div', 'popover-body'); + build(body); + pop.appendChild(body); + + document.body.appendChild(pop); + + const a = anchor.getBoundingClientRect(); + const p = pop.getBoundingClientRect(); + const margin = 8; + let left = a.left; + left = Math.min(left, window.innerWidth - p.width - margin); + left = Math.max(margin, left); + let top = a.bottom + 6; + if (top + p.height > window.innerHeight - margin) { + const above = a.top - p.height - 6; + if (above > margin) top = above; + else top = Math.max(margin, window.innerHeight - p.height - margin); + } + pop.style.left = `${Math.round(left)}px`; + pop.style.top = `${Math.round(top)}px`; + // the caret points back at the anchor, so it has to track the clamped position + pop.style.setProperty('--caret', `${Math.round(a.left - left + Math.min(22, a.width / 2))}px`); + + anchor.setAttribute('aria-expanded', 'true'); + openPopEl = pop; + openPopAnchor = anchor; + openPopBuild = build; + openPopSig = sig; + openPopSeen = sig ? String(sig()) : null; + return pop; +} + +document.addEventListener('click', (e) => { + if (!openPopEl) return; + if (openPopEl.contains(e.target)) return; + if (openPopAnchor && openPopAnchor.contains(e.target)) return; + closePopover(); +}, true); + +window.addEventListener('resize', closePopover); diff --git a/resources/web/device_page/js/core/pending.js b/resources/web/device_page/js/core/pending.js new file mode 100644 index 00000000000..8734110f94d --- /dev/null +++ b/resources/web/device_page/js/core/pending.js @@ -0,0 +1,157 @@ +/* + * pending.js - hold what was asked for until the machine says it happened. + * + * The bug this exists to stop has now been found three times, in three unrelated + * controls, and it is always the same sentence: **the request was stored in the thing + * that mirrors the machine**, so the next state push wrote the pre-click value back over + * what the user had just asked for. + * + * - tool selection - the click's own re-render put the old tool back + * - a temperature - the push lands ~1s after the commit and the printer echoes in + * 552-1516ms, so a set target vanished or came back as 0 + * - the chamber LED - measured over three runs against the printer: the echo took + * 236ms, 2029ms and 620ms, and the switch reverted to its old + * value in two of the three + * + * Each was fixed on the spot with its own mechanism - a blocking dialog and a poll loop + * in one case, DOM datasets and timers in another, nothing at all in the third. This is + * the one mechanism, so the fourth control gets it for free. + * + * A pending request has exactly three ends: + * + * confirmed the machine reports the value that was asked for. Stop holding it. + * refused the command itself failed. Stop immediately - there is nothing coming. + * lost the command succeeded and the machine never reported the change. This is + * the dangerous one and the reason a timeout exists at all: an instant `ok` + * is indistinguishable from success, so a silently-ignored setpoint looks + * exactly like an applied one. It is reported, not hidden. + * + * Nothing here touches the DOM, so it runs in JavaScriptCore under unit_jsc.py against + * whatever clock the test hands it. + */ +'use strict'; + +/** + * How long a sent value may go unechoed before the control stops claiming it. + * + * The bridge gives a command 15s; a control gives the machine 10s to report the result. + * The shorter clock is deliberate - a request that is still in flight has not been lost, + * and the command's own failure path stops the wait sooner than this ever will. + */ +export const CONFIRM_MS = 10000; + +/** How long a control keeps saying a value did not take, before going quiet. */ +export const LOST_MS = 8000; + +/** + * One value asked for, and the machine's answer. + * + * `state` is the vocabulary the rest of the page reads: + * '' nothing outstanding; show the machine + * 'sent' waiting; show `asked` + * 'lost' it did not take; show the machine, and say which value was dropped + */ +export class Pending { + /** + * `onChange` fires when a request starts or is refused - the two moments the page has + * something new to show and nothing on the stream to prompt a repaint. It is + * deliberately NOT fired from `resolve`, which runs *inside* a repaint: a confirmation + * that scheduled another repaint would never stop. + */ + constructor({ confirmMs = CONFIRM_MS, lostMs = LOST_MS, + now = () => Date.now(), onChange = null } = {}) { + this.confirmMs = confirmMs; + this.lostMs = lostMs; + this.now = now; + this.onChange = onChange; + this.entries = new Map(); + } + + /** Record that `value` was asked for, replacing anything already outstanding. */ + set(key, value) { + this.entries.set(key, { asked: value, at: this.now(), state: 'sent', until: 0 }); + if (this.onChange) this.onChange(key); + return value; + } + + /** + * Give up on `value`, and say so. + * + * Guarded on the value still being the one outstanding: a refusal that arrives after + * the user has moved on must not overwrite what they moved on to. + */ + fail(key, value) { + const e = this.entries.get(key); + if (!e || e.state !== 'sent' || !same(e.asked, value)) return; + e.state = 'lost'; + e.until = this.now() + this.lostMs; + if (this.onChange) this.onChange(key); + } + + /** Confirm without waiting for the mirror - for a control the machine cannot echo. */ + clear(key) { this.entries.delete(key); } + + /** + * Wire a command's outcome in. + * + * `send()` resolves false rather than rejecting, so both shapes are handled: a false + * resolution and a rejection mean the same thing to a control. + */ + track(key, value, sent) { + this.set(key, value); + if (!sent || typeof sent.then !== 'function') return sent; + return sent.then( + (ok) => { if (ok === false) this.fail(key, value); return ok; }, + (e) => { this.fail(key, value); throw e; }, + ); + } + + /** + * The tick: what should this control show, now that the machine reports `mirror`? + * + * Called on every frame, and it is where the state machine advances - a confirmation + * and an expiry are both things that happen when a value is next looked at, not on a + * timer of their own. + */ + resolve(key, mirror) { + const e = this.entries.get(key); + if (!e) return { value: mirror, state: '', asked: undefined }; + + const t = this.now(); + if (e.state === 'sent') { + if (same(e.asked, mirror)) { + this.entries.delete(key); + return { value: mirror, state: '', asked: undefined }; + } + if (t - e.at > this.confirmMs) { + e.state = 'lost'; + e.until = t + this.lostMs; + } else { + return { value: e.asked, state: 'sent', asked: e.asked }; + } + } + if (t > e.until) { + this.entries.delete(key); + return { value: mirror, state: '', asked: undefined }; + } + // Still saying it was dropped, but showing the machine: the value on screen must be + // true even while the message explains why it is not what was asked for. + return { value: mirror, state: 'lost', asked: e.asked }; + } + + /** Is anything outstanding for this key? Cheap, and does not advance the machine. */ + waiting(key) { + const e = this.entries.get(key); + return !!e && e.state === 'sent'; + } +} + +/** + * Machine values arrive as whatever the wire carried, so 200 and "200" are the same + * answer and `Object.is` would go on waiting for one of them forever. + */ +function same(a, b) { + if (typeof a === 'boolean' || typeof b === 'boolean') return !!a === !!b; + if (typeof a === 'number' || typeof b === 'number') return Number(a) === Number(b); + return a === b; +} diff --git a/resources/web/device_page/js/core/render.js b/resources/web/device_page/js/core/render.js new file mode 100644 index 00000000000..6ea12b3372b --- /dev/null +++ b/resources/web/device_page/js/core/render.js @@ -0,0 +1,138 @@ +/* + * render.js - one answer to "the state changed, what do I do with the DOM". + * + * There were four, all in the same page, and every focus, scroll and popover-anchor bug + * in the log is downstream of the difference between them: + * + * status card built once on a signature, then values patched in place + * storage signature-guarded, then rebuilt whole, with scrollTop saved by hand + * the other five `innerHTML = ''` on every frame, about once a second + * + * The third is the one that hurts. A node rebuilt under the user cannot hold focus, a + * selection, a scroll position, an open popover anchored to it, or a hover. Each of + * those was noticed separately and fixed with a guard invented on the spot - which is + * how there came to be four disciplines rather than one. + * + * The rule these primitives encode: **build structure once, then write only what + * differs.** Structure changes are keyed by a signature, so they happen when the shape + * genuinely changes rather than on every tick; lists are reconciled by key, so a list + * that gains an item at the end does not throw away the items already on screen. + */ +'use strict'; + +/** + * Rebuild `root` only when `sig` changes. + * + * Returns whether it rebuilt, because a caller usually has wiring to redo when it did. + * `data-built` is the same attribute the status card already used - the stylesheet and + * the structural DOM dump both read it. + */ +export function rebuildOn(root, sig, build) { + if (root.dataset.built === sig) return false; + root.dataset.built = sig; + root.innerHTML = ''; + build(root); + return true; +} + +/** + * Reconcile `root`'s children against `items`, matching by key. + * + * What this buys over `innerHTML = ''`: the container is never replaced, so its scroll + * position survives without being saved and restored by hand; a node that is still in + * the list is the *same* node, so anything the browser is holding on it - focus, a text + * selection, a hover, a popover anchored to it - survives too; and a list that grew by + * ten items rebuilds ten nodes rather than all of them. + * + * key identity: the same item across frames must give the same key + * sig optional; when it changes the node is rebuilt. Use it for a card whose + * contents change but which is not worth patching field by field. + * create build a fresh node + * update optional; patch an existing node in place + */ +export function keyedList(root, items, { key, sig, create, update }) { + const have = new Map(); + for (const n of Array.from(root.children)) { + if (n.dataset.key != null) have.set(n.dataset.key, n); + } + + let prev = null; + const used = new Set(); + (items || []).forEach((it, i) => { + // A key the caller did not make unique. Two nodes cannot share one, so the second + // is created fresh every frame while the first stays unreachable - not in `have`, + // so the sweep below cannot see it either. It leaks one node per duplicate per + // repaint, silently and without limit: Storage's grid drew 72 cards for 60 + // recordings before the key that caused it was fixed. + // + // The key is still the caller's business - see cardKey in storage-view.js for what + // a right one costs - but getting it wrong should cost a rebuilt card, which is + // what this makes it, and not an unbounded DOM. + let k = String(key(it, i)); + if (used.has(k)) k = `${k}#${i}`; + used.add(k); + const s = sig ? String(sig(it, i)) : null; + let n = have.get(k); + if (n) { + have.delete(k); + // Same item, different contents: cheaper to rebuild one card than to write a + // patcher for every field of every kind of card. + // + // The old node has to be taken OUT, not merely passed over. It was dropped from + // `have` on the line above, so the sweep at the end of this function can no longer + // see it - abandoning it here left it in the DOM for good, and a list whose + // contents changed grew by one node per change per repaint. The Filament panel + // reached ten slots where there are four. + if (s != null && n.dataset.sig !== s) { + n.remove(); + n = null; + } + } + if (!n) { + n = create(it, i); + n.dataset.key = k; + if (s != null) n.dataset.sig = s; + } + if (update) update(n, it, i); + + // Put it where it belongs, and only if it is not already there - a move is a + // reparent, which costs whatever the browser was holding on the node. + const at = prev ? prev.nextElementSibling : root.firstElementChild; + if (n !== at) root.insertBefore(n, at); + prev = n; + }); + + have.forEach((n) => n.remove()); +} + +/** + * Write text only when it differs. + * + * Assigning `textContent` replaces the text node even when the string is identical, + * which collapses any selection inside it. At one repaint a second that makes text + * inside a live panel impossible to select. + */ +export function text(node, value) { + const v = value == null ? '' : String(value); + if (node.textContent !== v) node.textContent = v; +} + +/** Set an attribute, or remove it when the value is null/undefined/false. */ +export function attr(node, name, value) { + if (value == null || value === false) { + if (node.hasAttribute(name)) node.removeAttribute(name); + return; + } + const v = String(value); + if (node.getAttribute(name) !== v) node.setAttribute(name, v); +} + +/** Same, for a `data-*` entry, which is how this page carries state the CSS reads. */ +export function data(node, name, value) { + if (value == null || value === false || value === '') { + if (node.dataset[name] !== undefined) delete node.dataset[name]; + return; + } + const v = String(value); + if (node.dataset[name] !== v) node.dataset[name] = v; +} diff --git a/resources/web/device_page/js/core/session.js b/resources/web/device_page/js/core/session.js new file mode 100644 index 00000000000..402c8899e6c --- /dev/null +++ b/resources/web/device_page/js/core/session.js @@ -0,0 +1,410 @@ +/* + * session.js - keeping a printer on the other end of the bridge. + * + * Everything about *having* a connection rather than about showing one: the connect + * path, whether the machine is still there, reconnecting when it is not, the heartbeat, + * and the state stream that feeds MachineState. No panel reads any of it, which is + * exactly why it can leave app.js. + * + * Three facts here were expensive to learn and are load-bearing: + * + * 1. DeviceInfo.connected says nothing about reachability - AppConfig force-clears it + * for every device on each config save (AppConfig.cpp:887). Live machine state is + * the only evidence, which is what isLive() is. + * 2. sw_mqtt_set_engine does not connect anything: SSWCP.cpp:6643 hardcodes + * "bool res = true;", so the host expects an already-connected mTLS engine and the + * page has to build one itself. + * 3. Certificates are never persisted - SSWCP.cpp blanks them - so every start re-runs + * the key exchange. + * + * 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. + */ +'use strict'; + +import { CMD, SUBSCRIBE_OBJECTS, DEVICE, deviceLabel, hasTlsMaterial } + from '../../../shared/js/protocol.js'; +import { openDialog } from './overlay.js'; +import { connect as connectDevice, disconnect as disconnectDevice } from './connection.js'; + +/** + * @param bridge () => the live Sswcp client. A getter, because the session is built + * before the bridge is and outlives any one of them. + * @param state MachineState - the mirror this fills. + * @param store page state; the session writes device, devices and reachable. + * @param setStatus the one-line host status in the dev aids. + * @param render ask for a repaint. + * @param refresh re-read Orca's device list. + * @param hostLog the diagnostics beacon; see diag.js. + * @param handlers the command bag, for the page-level actions a reconnect implies. + */ +export function createSession({ bridge, state, store, setStatus, render, refresh, + hostLog, handlers }) { + // The session's own internals. Nothing outside reads them: a socket, a subscription id + // and two timer handles are not state a panel can have an opinion about. + let engineId = null; // the MQTT engine this page created + let subscription = null; + let connecting = false; + let heartbeat = null; + + /** + * How long the machine may say nothing before the page stops vouching for it. + * + * Measured on an idle U1: **2 status pushes in 30 seconds**, gaps of 4s to 14s across + * runs. Klipper only pushes fields that change and a machine doing nothing changes + * almost nothing, so this has to be several times the idle gap or a quiet printer + * would flicker as if it had gone away. 45s is about 3x the widest gap seen, and the + * heartbeat's own 30s covers a machine that has genuinely nothing to say. + */ + const STALE_MS = 45000; + /** When the printer last answered a heartbeat. Its own evidence, not the stream's. */ + let lastPong = 0; + /** + * Are we talking to a printer *now*? + * + * `state.lastUpdate > 0` used to stand in for this, which is a claim about the past: + * once anything had arrived the page went on saying "connected" for the rest of the + * session, and a rebooting printer kept its last snapshot on screen as if current. + * + * Two independent pieces of evidence, either sufficient: something arrived on the + * stream recently, or the printer answered a heartbeat recently. + */ + function isLive(now = Date.now()) { + if (!Object.keys(state.objects).length) return false; + return state.age(now) < STALE_MS || now - lastPong < STALE_MS; + } + /** + * How long to wait between attempts, backing off and then holding. + * + * A printer that is off is off for minutes, not milliseconds, and a failed attempt + * costs a 15s TCP connect - so the ladder starts short enough to feel immediate when + * someone flips the switch and settles somewhere that is not a poll loop. + */ + const RETRY_MS = [5000, 10000, 20000, 30000]; + let retryAt = 0; + let retryStep = 0; + /** A manual attempt starts over: the user knows something the backoff does not. */ + function resetBackoff() { + retryAt = 0; + retryStep = 0; + } + /** + * Keep the session up, and repaint when it is not. + * + * Two jobs on one clock, because both need the same thing - something that ticks when + * nothing is arriving: + * + * 1. Repaint on the flip. Every other repaint here is triggered by a message, which + * is exactly what a printer that has gone away stops sending, so the page would + * otherwise hold its last frame forever. + * 2. Reconnect. The page used to make 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. + */ + function superviseConnection() { + let was = null; + setInterval(() => { + const live = isLive(); + if (live !== was) { + was = live; + if (!live && store.device) { + setStatus(`${deviceLabel(store.device)} \u2014 not responding`, 'warn'); + } + render(); + } + if (live) { resetBackoff(); return; } + if (connecting || !store.device) return; + // Pairing needs a human reading a code off the machine, so a device that cannot + // authorise itself is not something to retry at. + if (!store.device[DEVICE.IP] || !store.device[DEVICE.SN]) return; + if (Date.now() < retryAt) return; + // Schedule the next attempt before starting this one: a failing connect can take + // 15s by itself, and the interval is between attempts, not between failures. + retryStep = Math.min(retryStep + 1, RETRY_MS.length); + retryAt = Date.now() + RETRY_MS[retryStep - 1]; + reconnect(); + }, 2000); + } + /** + * Bring the session back. + * + * The dead engine is dropped first. It still holds a socket at the host, and the next + * connect makes a new one - a printer rebooted a few times would otherwise leave a + * line of them behind, all subscribed to topics nothing will publish. + */ + function reconnect() { + if (engineId) { + const dead = engineId; + engineId = null; + disconnectDevice(bridge(), dead).catch(() => { /* already gone; that was the point */ }); + } + return doConnect(store.device, { silent: true, retrying: true }); + } + /** Ask the user for the code the printer is displaying. */ + function askForPin() { + return new Promise((resolve) => { + let input; + let settled = false; + openDialog({ + title: 'Pairing code', + build: (b) => { + const p = document.createElement('p'); + p.style.cssText = 'margin:4px 0 14px;font-size:13px;line-height:1.55;color:#39434F'; + p.textContent = 'The printer is showing a code on its screen. Enter it here to ' + + 'finish pairing. Orca stores the keys it receives, so this is only needed once.'; + b.appendChild(p); + const f = document.createElement('label'); + f.className = 'field'; + const lab = document.createElement('span'); + lab.className = 'field-label'; + lab.textContent = 'Code'; + f.appendChild(lab); + const row = document.createElement('div'); + row.className = 'field-row'; + input = document.createElement('input'); + input.setAttribute('inputmode', 'numeric'); + input.autocomplete = 'off'; + row.appendChild(input); + f.appendChild(row); + b.appendChild(f); + }, + confirmLabel: 'Pair', + onConfirm: () => { + const v = input.value.trim(); + if (!v) { input.focus(); return false; } + settled = true; + resolve(v); + return true; + }, + }); + // Cancelling the dialog resolves empty, which connection.js treats as a cancel. + const scrim = document.querySelector('.scrim'); + if (scrim) { + const obs = new MutationObserver(() => { + if (!document.body.contains(scrim)) { + obs.disconnect(); + if (!settled) resolve(''); + } + }); + obs.observe(document.body, { childList: true }); + } + }); + } + async function doConnect(target, opts = {}) { + if (connecting) return; + connecting = true; + render(); + hostLog(`connect start: sn=${target[DEVICE.SN]} ip=${target[DEVICE.IP]} ` + + `port=${target[DEVICE.PORT]} link=${target[DEVICE.LINK_MODE]} ` + + `clientId=${target[DEVICE.CLIENT_ID] || '(none)'} ` + + `hasKeys=${hasTlsMaterial(target)} token=${!!store.loginUser.token} ` + + `silent=${!!opts.silent}`); + try { + const res = await connectDevice(bridge(), target, { + // An automatic attempt never prompts; pairing is a deliberate action. + requestPin: opts.silent ? null : askForPin, + user: store.loginUser, + onStep: (t) => { setStatus(t); hostLog(t); }, + trace: (t) => hostLog(t, 'warning'), + }); + engineId = res.engineId; + hostLog(`connected to ${deviceLabel(target)} (engine ${res.engineId})`); + setStatus(`${deviceLabel(target)} — connected`, 'ok'); + await refresh(); + // The session only exists now, so this is the first point the state + // commands can succeed. + await startStateStream('after connect'); + startHeartbeat(); + handlers.queryException(); + } catch (e) { + // ConnectError names the step that failed, which is the useful half. + if (opts.silent) { + // Time left on the clock, not the interval it was set to: the attempt that just + // failed may have taken longer than the gap it was scheduled with. + const left = Math.max(0, Math.round((retryAt - Date.now()) / 1000)); + const again = opts.retrying + ? (left ? ` — trying again in ${left}s` : ' — trying again') : ''; + setStatus(`${deviceLabel(target)} — not connected (${e.message})${again}`, 'warn'); + } else { + setStatus(`connect failed — ${e.message}`, 'err'); + } + hostLog(`connect failed at step "${e.step || '?'}": ${e.message}`, 'error'); + console.error('[app] connect', e); + } finally { + connecting = false; + render(); + } + } + async function doDisconnect() { + if (!engineId) { + setStatus('this session was not opened by the page — nothing to disconnect', 'warn'); + return; + } + await disconnectDevice(bridge(), engineId); + engineId = null; + if (heartbeat) { clearInterval(heartbeat); heartbeat = null; } + store.exception = null; + await refresh(); + setStatus('disconnected', 'warn'); + } + /** Keep the session warm while we hold one, as the shipped page does. */ + function startHeartbeat() { + if (heartbeat) clearInterval(heartbeat); + heartbeat = setInterval(() => { + // Not fire-and-forget any more: this round trip is the only evidence a machine + // with nothing to report is still there, and its failure is evidence it is not. + bridge().request(CMD.HEARTBEAT, {}) + .then(() => { lastPong = Date.now(); }) + .catch(() => { /* the staleness window decides; nothing to record */ }) + .then(() => render()); + // `ace` is not on the stream, and humidity and a running dryer both move without + // anyone asking. One 277 ms read per heartbeat is what keeps them from going stale + // on screen; everything that CHANGES the ACE re-reads it for itself. + refreshAce(); + }, 30000); + } + /** + * Declare the field filter, take a snapshot, and open the live stream. + * + * All three must run *after* a session exists — sw_GetMachineState and + * sw_SubscribeMachineState both need `get_connect_host`, so at boot with no + * host they fail and the page is left with no data and no subscription. Running + * only a snapshot after connecting is not enough either: the UI would show one + * frozen frame and never update. + */ + /** + * Fetch `toolhead` once, outside the subscription. + * + * `homed_axes` lives only on that object and the shipped page does not subscribe it, so + * the stream never carries it. It only changes when the machine homes, which makes a + * one-shot query at connect and after G28 sufficient - and keeps the subscription list + * byte-identical to the bundle's, which the conformance suite enforces. + */ + async function refreshToolhead() { + return refreshWaitState(); + } + /** + * Re-read the objects a wait depends on that the stream does not carry. + * + * `toolhead` holds `homed_axes`, and `extruder_offset_calibration` holds the docking + * calibration step that makes a toolchange slow. Neither is subscribed - the shipped + * page does not subscribe them and the object list is pinned to the bundle's - so they + * are fetched explicitly. One query for both, because a wait needs them together. + */ + async function refreshWaitState() { + try { + const snap = await bridge().request(CMD.GET_MACHINE_STATE, { + objects: { + // `homed_axes` is the toolchange's real progress signal - it walks + // "" -> z -> "" -> y -> xy across the long homing phase - and `toolhead` is not + // subscribed, so it has to be asked for. + toolhead: ['extruder', 'position', 'homed_axes'], + // `activating_move` marks the moment the head is being grabbed. It is on an + // object the page does subscribe, but not among EXTRUDER_FIELDS, and that list + // is pinned to the bundle's - so it is fetched rather than subscribed. + extruder: ['state', 'activating_move'], + extruder1: ['state', 'activating_move'], + extruder2: ['state', 'activating_move'], + extruder3: ['state', 'activating_move'], + extruder_offset_calibration: null, + }, + }); + state.applyPayload(snap); + } catch (e) { + /* a wait must not fail because a status read did; it just learns nothing */ + } + } + /** + * Fetch `ace` once, outside the subscription. + * + * The multiACE state - which unit feeds which head, what is in every bay, humidity, + * the dryer - is a Klipper object, and it is not in SUBSCRIBE_OBJECTS: that list is + * pinned to the shipped bundle's and the conformance suite holds it there. So it is + * asked for, the same way `toolhead` is. Measured at 277 ms against the printer, so + * it is cheap enough to re-read on the heartbeat and after every ACE command rather + * than to poll on a clock of its own. + * + * A machine with no multiACE plugin answers with nothing for it, which is the honest + * signal that there is no ACE here: `state.ace().present` is false and the Filament + * panel draws the four slots it always did. + * + * `ace_bg_swap` rides along in the same call. It is a SECOND Klipper object - which + * head may run a background swap is not in `ace` at all, all 38 of whose top-level keys + * were read on the machine - and one request that asks for two objects costs what one + * that asks for one does. A firmware without the plugin's bg module answers with + * nothing for it, and every background verb stays refused, which is the safe way round. + * + * And `save_variables` is a THIRD, for one field: `ace__mode`, the mode multiACE has + * saved. A normal-mode switch writes that immediately, then swaps three Klipper extras + * on disk and RAISES - so `ace.mode` goes on reporting the old mode until the machine is + * restarted. The disagreement between the two is the only thing that says a restart is + * owed, and it costs nothing to carry it in a call that was already being made. + */ + async function refreshAce() { + try { + const snap = await bridge().request(CMD.GET_MACHINE_STATE, + { objects: { ace: null, ace_bg_swap: null, + save_variables: null } }); + state.applyPayload(snap); + } catch (e) { + /* no multiACE, or no printer; either way the panel degrades rather than fails */ + } + } + async function startStateStream(reason) { + // The three calls are independent and must not be chained. sw_SetSubscribeFilter + // forwards `printer.objects.setSubscribeFilter` to the printer and waits for a + // reply; against a real U1 that reply does not always come, and awaiting it + // starved the two calls that actually matter. It is only an optimisation — + // query and subscribe both carry their own object map — so fire it and move on. + bridge().request(CMD.SET_SUBSCRIBE_FILTER, { objects: SUBSCRIBE_OBJECTS }) + .then(() => hostLog(`filter accepted (${reason})`)) + .catch((e) => hostLog(`filter skipped (${reason}): ${e.message}`)); + + let ok = false; + try { + const snap = await bridge().request(CMD.GET_MACHINE_STATE, { objects: SUBSCRIBE_OBJECTS }); + state.applyPayload(snap); + hostLog(`snapshot ok (${reason}): ${Object.keys(state.objects).length} objects`); + ok = Object.keys(state.objects).length > 0; + refreshToolhead(); // homed_axes is not in the subscribed set + refreshAce(); // nor is `ace`; see refreshAce above + // And what is IN each bay is not machine state at all - the Klipper object carries + // no per-bay identity, so multiACE's own record of it is fetched off the printer's + // file server. Same shape as queryException(): a cross-cutting read the session + // starts and a panel owns. + if (handlers.syncBays) handlers.syncBays(); + } catch (e) { + hostLog(`snapshot failed (${reason}): ${e.message}`, 'error'); + } + + try { + if (subscription && subscription.cancel) subscription.cancel(); + subscription = await bridge().subscribe(CMD.SUBSCRIBE_MACHINE_STATE, {}, (d) => { + state.applyPayload(d); + }); + hostLog(`subscribed (${reason})`); + } catch (e) { + hostLog(`subscribe failed (${reason}): ${e.message}`, 'error'); + } + + render(); + return ok; + } + return { + isLive, + connect: doConnect, + disconnect: doDisconnect, + reconnect, + resetBackoff, + supervise: superviseConnection, + startStateStream, + startHeartbeat, + refreshToolhead, + refreshWaitState, + refreshAce, + /** Whether this page brought the session up, and can therefore take it down. */ + get engineId() { return engineId; }, + }; +} diff --git a/resources/web/device_page/js/core/store.js b/resources/web/device_page/js/core/store.js new file mode 100644 index 00000000000..ffbb52afdf7 --- /dev/null +++ b/resources/web/device_page/js/core/store.js @@ -0,0 +1,144 @@ +/* + * store.js - everything the page knows that the machine does not. + * + * This was sixteen module-level `let`s in app.js, and nothing listed them. The registry + * answers "what is on this page"; this answers "what does the page remember", which is + * the other half of the same question and was the harder one to get at: a panel reading + * `cam.error` gave no clue where `cam` was written, and eleven of the sixteen were + * written from more than one place. + * + * What belongs here: state the *page* owns. What does not: the machine's own state, + * which is `MachineState` and is a mirror rather than a memory, and the session's + * internals - sockets, timers, subscription ids - which no panel reads and which stay + * with the code that manages them. + * + * The distinction that matters, and the one this page kept getting wrong: **a request is + * not a mirror**. Anything asked for and not yet confirmed belongs in `pending.js`, not + * here and not in `MachineState` - see the note at the top of that file for the three + * separate controls that learned it the hard way. + */ +'use strict'; + +/** + * A plain declared object, deliberately not an observable one. + * + * Making every write notify is the obvious next step and it is not taken here, because + * it would be a second way to ask for a repaint alongside `render()` - and one page with + * two mechanisms for one job is the thing this restructure exists to undo. Mutation + * sites call `render()`, as they already did. If that turns out to be forgotten in + * practice, wrapping this in a Proxy is a change to this function and nothing else. + */ +export function createStore() { + const s = { + /** Which destination the rail is on. */ + view: 'control', + + /** Which of Storage's three kinds is showing. */ + storageKind: 'timelapses', + + /** + * Whether a FURTHER page is in flight, as opposed to a first one. + * + * Separate from each source's own `loading`, because the two mean different things + * on screen: a first read has nothing to show and replaces the grid, while a + * further one has everything already and must not disturb it - it only puts a + * spinner in the footer where the button was. + */ + storageMore: false, + + /** + * Which toolhead jog and extrude are aimed at. + * + * The user's choice, not the machine's - picking a head to jog does not change the + * tool - so nothing on the stream ever sets it. + */ + activeTool: 0, + + /** + * Whether we are talking to a printer *right now*. + * + * Computed once per frame from live evidence, never from `DeviceInfo.connected`, + * which AppConfig force-clears on every config save (AppConfig.cpp:887) and which is + * therefore false on disk by construction. + */ + reachable: false, + + /** The machine whose state we show. */ + device: null, + + /** Every machine Orca has saved, connected or not. */ + devices: [], + + /** + * What multiACE says is in each ACE bay, keyed `"_"`. + * + * Here rather than in `MachineState` because it is not machine state: the `ace` + * Klipper object carries no per-bay identity at all, and this is a file the PAGE + * fetched off the printer's own file server. Null until it has been asked for, and + * null again if the printer has no such file - which is a real answer, not a + * failure: a bay nobody has named stays drawn as a bay nobody has named. + */ + aceBays: null, + + /** Machines LAN discovery has turned up. */ + found: [], + + /** `{ userid, nickname }` - the pairing request wants both. */ + loginUser: {}, + + /** The active fault, from `sw_exception_query`. */ + exception: null, + + /** + * The camera. + * + * Frames are polled rather than pushed on every transport this page can actually + * use. The stock one is `camera.start_monitor`, which returns a URL and then has the + * printer rewrite that one file every two seconds - half a frame a second. A printer + * running the extended firmware answers `/server/webcams/list` with real cameras + * whose stills can be re-fetched as fast as the engine will take them; 14.0 fps + * measured in WebKitGTK. + * + * cams what /server/webcams/list said, filtered to things with a still + * caps what THIS engine can decode - see engineCaps(). Probed once + * transport which of CAMERA_TRANSPORT is asked for; AUTO resolves per frame + * view how many pictures at once - CAMERA_VIEW + * picked camera names, in tile order. Longer than the view needs is fine + * fps what the focused tile asks for. Others get CAMERA_FPS_UNFOCUSED + * focus which tile index is being watched, so the rest can poll slowly + * frames name -> the URL its should point at + * + * `frameUrl` is kept because the monitor transport has exactly one frame and no + * camera name to hang it off. + */ + cam: { mode: 'live', streaming: false, frameUrl: null, + error: '', + cams: [], caps: null, transport: 'auto', + view: 'single', picked: [], fps: 15, focus: 0, frames: {} }, + + /** + * Recordings, from the printer's camera service. Paged on `page_index`. + * + * These lived in `cam` next to the live view, and shared `cam.error` with it: a + * failed listing printed "could not list recordings" across the camera viewport, + * and starting the camera wiped Storage's error. Two panels, two subjects, one + * field. They are Storage's, and this is Storage's slice. + */ + timelapses: { loading: false, error: '', items: [], hasMore: false, page: 0 }, + + /** A machine-file listing, for Storage's file-backed kind. Paged on `page_number`. */ + files: { loading: false, error: '', root: '', roots: [], items: [], + hasMore: false, page: 0 }, + + /** Finished prints, from Moonraker's history store. Paged, so `start` appends. */ + history: { loading: false, error: '', items: [], hasMore: false }, + + /** + * The printing file's thumbnail, keyed on the filename so a card that repaints every + * second asks the printer nothing. + */ + jobThumb: { file: null, data: null }, + }; + + return s; +} diff --git a/resources/web/device_page/js/core/thumbs.js b/resources/web/device_page/js/core/thumbs.js new file mode 100644 index 00000000000..e6c0ddfa301 --- /dev/null +++ b/resources/web/device_page/js/core/thumbs.js @@ -0,0 +1,30 @@ +/* + * commands/util.js - the one thing two command modules genuinely share. + * + * Kept to one function on purpose. Anything that lands here is something two panels + * both need, which is worth noticing rather than letting spread. + */ +'use strict'; + +/** Thumbnail responses vary by firmware; sniff the plausible shapes. */ +/** + * Pull image bytes out of a thumbnail reply, if it has any. + * + * `server.files.thumbnails_base64` puts them in `data`; `thumbnail_base64` is the + * timelapse listing's spelling. `server.files.thumbnails` and the directory listing + * carry only `thumbnail_path` / `relative_path`, which are paths on the printer and + * not bytes - this returns null for those rather than handing a filename to an + * that would render it as a broken image. + */ +export function pickThumb(r) { + if (!r) return null; + const d = r.data !== undefined ? r.data : r; + if (typeof d === 'string' && d.length > 64) return d; + if (Array.isArray(d) && d.length) return pickThumb(d[0]); + for (const k of ['thumbnail_base64', 'thumbnail', 'thumb', 'image', 'base64', 'data']) { + const v = d && d[k]; + if (typeof v === 'string' && v.length > 64) return v; + if (Array.isArray(v) && v.length) return pickThumb(v[0]); + } + return null; +} diff --git a/resources/web/device_page/js/page-commands.js b/resources/web/device_page/js/page-commands.js new file mode 100644 index 00000000000..cc8fab2a012 --- /dev/null +++ b/resources/web/device_page/js/page-commands.js @@ -0,0 +1,79 @@ +/* + * commands/page.js - Commands that belong to no single panel: refreshing everything, and moving + * between the rail's destinations. + * + * Small on purpose. Anything that lands here is something two panels share, and that is + * worth noticing rather than letting it spread. + * + * check_coverage.py reads the CMD references out of this file to answer "can a user + * reach this command", and attributes them to the page panel because this is the module + * that panel is handed. That makes the attribution a fact about the imports rather than + * a promise in a declaration - which is the difference that let a handler nothing called + * count as implemented for as long as it did. + */ +'use strict'; + +import { CMD, asDeviceList, DEVICE } + from '../../shared/js/protocol.js'; + +export function create(deps) { + // `bridge` is deliberately NOT destructured: it does not exist yet when these are + // built - boot() decides between the real host and the simulator - so it is reached + // through deps each time rather than captured as null once. + const { state, store, pending, session, cmd, + send, setpoint, setStatus, render } = deps; + + /** + * Re-read everything the page shows. This is what the header refresh buttons do. + * + * Measured by clicking the shipped page's own refresh pill and watching what left it: + * it re-subscribes, re-declares the field filter, takes a fresh snapshot, then re-reads + * the system info, the file status, the store.exception state and the file roots. Both of its + * pills - Control and Filament - send the identical set. + * + * The rebuild's buttons used to call `refresh()`, which re-reads Orca's DEVICE BOOK: + * two commands that say nothing about the machine, so pressing refresh changed nothing + * a user could see. + */ + async function refreshAll() { + await refresh(); // the device list, as it always did + if (!state.lastUpdate && !session.engineId) return; // nothing to re-read yet + await session.startStateStream('refresh'); // filter + snapshot + subscription, and it + // re-reads homed_axes on its own way out + cmd.queryException(); + // `startStateStream` above re-reads the ACE and its bays on its own way out, so + // there is nothing to ask for here - two triggers for one read is how a subsystem + // ends up defined in more than one place. + deps.bridge.request(CMD.GET_MACHINE_SYSTEM_INFO, {}).catch(() => {}); + deps.bridge.request(CMD.FILE_STATUS, {}).catch(() => {}); + deps.bridge.request(CMD.FILES_ROOTS, {}).catch(() => {}); + render(); + } + + async function refresh() { + try { + store.devices = asDeviceList(await deps.bridge.request(CMD.GET_LOCAL_DEVICES, {})); + const c = await deps.bridge.request(CMD.GET_CONNECTED_MACHINE, {}); + store.device = (c && Object.keys(c).length) ? c + : (store.devices.find((d) => d[DEVICE.CONNECTED]) || store.devices[0] || null); + } catch (e) { + setStatus(`refresh failed: ${e.message}`, 'err'); + } + render(); + } + + function showView(next) { + store.view = next; + render(); + if (next === 'storage') cmd.openStorage(store.storageKind); + } + + return { + refreshAll: () => refreshAll(), + /** Orca's device list. session.js needs it too, on a reconnect. */ + refresh: () => refresh(), + + showView: (v) => showView(v), + + }; +} diff --git a/resources/web/device_page/js/registry.js b/resources/web/device_page/js/registry.js new file mode 100644 index 00000000000..8438fd86b92 --- /dev/null +++ b/resources/web/device_page/js/registry.js @@ -0,0 +1,81 @@ +/* + * registry.js - what this page is made of. + * + * The Device page grew a panel at a time, and 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 and a slice of module state in app.js, and a click handler in + * wireChrome. Nothing tied the five together and nothing listed them, so there was no + * answer to "what is on this page, and what does each part do" short of reading all + * of it. + * + * This file is that answer, and everything one panel is made of now lives together in + * js/views/// : + * + * panel.js the declaration below, plus mount/update + * view.js its DOM - built once, then patched + * commands.js everything it can ask the machine to do + * + * Every panel declares: + * + * id unique; also the
id when `sectionId` is set + * title the text in the panel header + * view which destination it belongs to - 'control', 'storage', or null for + * chrome that sits above both + * bodyId the element a renderer is handed (kept stable: CSS and the test + * harness both address these by id) + * bodyClass the class on that element + * panelClass extra class on the
+ * bare no
/
at all; the body element is the whole thing + * header header controls, declared - see shell.js for the vocabulary + * column which column of its destination it sits in, for destinations that + * declare columns. Ignored otherwise + * grow this panel takes the column's leftover height. One per column + * reads which state objects and store slices this panel renders from + * mount(root) build the body's fixed structure, once + * update(root) patch it, on every frame + * + * What a panel can SEND is deliberately not here. It was, briefly: a `sends` array per + * panel, which made ownership a promise - nothing stopped a panel claiming a command it + * never issued, and one did. Each panel is now handed `js/commands/.js` and nothing + * else, so the module IS the answer and check_coverage.py reads it there. Referencing a + * command is the only way to claim it. + * + * That closes what 07-parity.md got wrong: it reported the command surface complete on + * the strength of `CMD.NAME` appearing somewhere in the source, which proves a command + * is *mentioned* - not that a control exists to issue it. A panel with no button counted + * every command it would have called. + * + * `reads` stays declared, because there is nothing to derive it from. + * + * Order is render order and paint order. + */ +'use strict'; + +import fault from './views/fault/fault-panel.js'; +import camera from './views/device-control/camera/camera-panel.js'; +import control from './views/device-control/control/control-panel.js'; +import task from './views/device-control/task/task-panel.js'; +import filament from './views/device-control/filament/filament-panel.js'; +import storage from './views/storage/storage/storage-panel.js'; + +export const PANELS = [fault, camera, control, task, filament, storage]; + +/** + * The rail's destinations, in the order they appear in it. + * + * `columns` is what makes a destination two columns rather than a grid of equal cells. + * The four control panels used to sit in a 2x2 grid where every cell was locked to + * `aspect-ratio: 830/548`, so a wider window bought four wider squares and the camera - + * the only panel whose content actually scales - gained nothing the others did not. + * + * Naming them here rather than in the stylesheet is what lets a panel say which column + * it belongs to instead of the CSS guessing from source order. + */ +export const VIEWS = [ + { id: 'control', label: 'Device control', icon: 'deviceControl', + columns: ['main', 'side'] }, + { id: 'storage', label: 'Storage', icon: 'iconModelFileFolder' }, +]; + +export const panelsIn = (view) => PANELS.filter((p) => p.view === view); +export const bareChrome = () => PANELS.filter((p) => p.view === null); diff --git a/resources/web/device_page/js/shell.js b/resources/web/device_page/js/shell.js new file mode 100644 index 00000000000..184167b1ce9 --- /dev/null +++ b/resources/web/device_page/js/shell.js @@ -0,0 +1,319 @@ +/* + * shell.js - build the page from the registry, then keep it painted. + * + * The panel chrome - the
, the 40px header, the title, the buttons in the bar - + * used to be hand-written in index.html and wired by id in wireChrome(). Six panels + * meant six copies of the same four elements and six places to keep in step; adding one + * meant remembering all of it. Here the shape is written once and the differences are + * data, in registry.js. + * + * Header vocabulary, all of it: + * + * { kind: 'gap' } a 36px hole + * { kind: 'spacer' } push what follows to the right edge + * { kind: 'sep' } the vertical rule + * { kind: 'icon', id, cls, icon, title, on } an icon button + * { kind: 'pill', id, cls, label, chev, on, enabled?, title? } the Print + * Preferences pill. `label` may be a + * function, and is then kept in sync + * { kind: 'tabs', group, cls, items, active, on } one-of-N picker + * { kind: 'status', id, cls, text, state, title } a live label, no click + * + * `on` is called with the live context; for `tabs` it also gets the value clicked. + */ +'use strict'; + +import { $, el, icon } from './core/dom.js'; +import { text, data } from './core/render.js'; +import { repaintPopover } from './core/overlay.js'; +import { PANELS, VIEWS } from './registry.js'; + +/* ---- header controls ------------------------------------------------- */ + +function iconBtn(spec, ctx) { + const b = el('button', spec.cls || 'tab'); + if (spec.id) b.id = spec.id; + if (spec.title) b.title = spec.title; + const im = icon(spec.icon); + im.alt = spec.alt != null ? spec.alt : (spec.title || ''); + b.appendChild(im); + b.onclick = () => spec.on(ctx); + return b; +} + +/** + * The Print Preferences pill. + * + * `label` may be a string or a function of the context. A function is synced through + * `headerSync` for the same reason a tab is: a header has no renderer, so a pill that + * reports something - the ACE mode - would otherwise draw the value it was built with + * for ever. `enabled` is synced alongside it, because a control for hardware that is + * not attached should say so rather than send a macro the machine would refuse. + */ +function pillBtn(spec, ctx) { + const b = el('button', spec.cls || 'pref-pill'); + if (spec.id) b.id = spec.id; + const label = document.createTextNode(''); + b.appendChild(label); + if (spec.chev) b.appendChild(icon(spec.chev, 'chev')); + b.onclick = () => spec.on(ctx); + if (typeof spec.label === 'function' || spec.enabled || spec.title) { + const sync = () => { + label.nodeValue = `${typeof spec.label === 'function' ? spec.label(ctx) : spec.label} `; + if (spec.enabled) b.disabled = !spec.enabled(ctx); + if (spec.title) b.title = spec.title(ctx); + }; + sync(); + headerSync.push(sync); + } else { + label.nodeValue = `${spec.label} `; + } + return b; +} + +/** + * A one-of-N picker. + * + * `is-active` follows the state rather than the click. Moving it in the click handler + * was almost right and quietly wrong: anything that changed the selection *without* a + * click left the wrong tab lit. The job card's "choose a file to print" does exactly + * that - it opens Storage on another kind - and the header went on highlighting + * time-lapses over a grid of something else. + */ +function tabGroup(spec, ctx, into) { + const made = []; + const group = spec.group || 'value'; + spec.items.forEach((it) => { + const b = el('button', spec.cls || 'tab'); + b.dataset[group] = it.value; + if (it.title) b.title = it.title; + const im = icon(it.icon); + im.alt = it.title || ''; + b.appendChild(im); + b.onclick = () => spec.on(ctx, it.value); + made.push(b); + into.appendChild(b); + }); + const sync = () => { + const cur = String(spec.active ? spec.active(ctx) : ''); + const hit = made.some((b) => b.dataset[group] === cur); + made.forEach((b, i) => b.classList.toggle( + 'is-active', hit ? b.dataset[group] === cur : i === 0)); + }; + sync(); + headerSync.push(sync); +} + +/** + * A label in the header that follows the state. + * + * The Printing Task card carried its own status - a badge on a row of its own, above a + * second row repeating the machine's name that the rail already shows. Both rows were + * furniture around one word. The word belongs in the header, beside the panel's title, + * where a panel says what it is and what it is doing in the same line. + * + * It syncs through `headerSync` for the same reason a tab does: a header has no + * renderer, so without this nothing would ever change it except a click, and this one + * has no click at all. + */ +function statusChip(spec, ctx) { + const n = el('span', spec.cls || 'head-status'); + if (spec.id) n.id = spec.id; + const sync = () => { + text(n, spec.text(ctx)); + if (spec.state) data(n, 'state', String(spec.state(ctx))); + if (spec.title) n.title = spec.title(ctx); + }; + sync(); + headerSync.push(sync); + return n; +} + +function headerItem(spec, ctx, into) { + switch (spec.kind) { + case 'gap': into.appendChild(el('span', 'gap-36')); return; + case 'spacer': into.appendChild(el('span', 'spacer')); return; + case 'sep': into.appendChild(el('span', 'sep', '|')); return; + case 'icon': into.appendChild(iconBtn(spec, ctx)); return; + case 'pill': into.appendChild(pillBtn(spec, ctx)); return; + case 'tabs': tabGroup(spec, ctx, into); return; + case 'status': into.appendChild(statusChip(spec, ctx)); return; + default: throw new Error(`shell: unknown header item "${spec.kind}"`); + } +} + +/* ---- panels ----------------------------------------------------------- */ + +function buildPanel(panel, ctx) { + const body = el('div', panel.bare ? panel.bodyClass + : `panel-body ${panel.bodyClass || ''}`.trim()); + body.id = panel.bodyId || `${panel.id}-body`; + if (panel.hiddenAtRest) body.hidden = true; + if (panel.mount) panel.mount(body, ctx); + if (panel.bare) return body; + + const section = el('section', `panel ${panel.panelClass || ''}`.trim()); + if (panel.sectionId) section.id = panel.sectionId; + const head = el('header', 'panel-head'); + head.appendChild(el('span', 'panel-title', panel.title)); + (panel.header || []).forEach((spec) => headerItem(spec, ctx, head)); + section.appendChild(head); + section.appendChild(body); + return section; +} + +/* ---- the page ---------------------------------------------------------- */ + +/** + * Fill `.content` and the rail's nav from the registry. + * + * `#view-control` and `#view-storage` stay direct children of `.content`, and the fault + * banner stays their sibling: `.content:has(> #view-storage:not([hidden]))` depends on + * exactly that nesting. + * + * A destination that declares `columns` (see registry.js) gets `.view-cols` holding one + * `.view-col` each, and every panel lands in the one it names. `#view-control` used to + * be `display: contents` so its panels were direct children of a 2x2 grid; it is a real + * box again now, because the two columns have to distribute height independently - + * Control shrinking to its cluster is what gives Filament the room to grow. + */ +export function buildShell(ctx) { + const content = $('.content'); + const nav = $('.rail nav'); + + VIEWS.forEach((v) => { + const n = el('div', 'nav-item'); + n.id = `nav-${v.id}`; + n.dataset.view = v.id; + n.appendChild(icon(v.icon)); + n.appendChild(el('span', null, v.label)); + n.onclick = () => ctx.commandsFor('page').showView(v.id); + nav.appendChild(n); + }); + + PANELS.filter((p) => p.view === null) + .forEach((p) => content.appendChild(buildPanel(p, ctxFor(ctx, p.id)))); + + VIEWS.forEach((v) => { + const box = el('div', 'view'); + box.id = `view-${v.id}`; + const panels = PANELS.filter((p) => p.view === v.id); + + if (v.columns) { + // A destination with columns gets one wrapper per column inside a row, and the + // row is a separate element from the destination itself so the development aids + // below can sit under it rather than beside it. + // + // A panel's column comes from its own declaration, not from source order: + // ordering the four by which column they land in would have made the registry's + // list - which is also paint order - a statement about layout, and those two + // stopped being the same thing the moment the columns were unequal. + const row = el('div', 'view-cols'); + const cols = new Map(v.columns.map((c) => { + const w = el('div', `view-col col-${c}`); + row.appendChild(w); + return [c, w]; + })); + panels.forEach((p) => { + const section = buildPanel(p, ctxFor(ctx, p.id)); + if (p.grow) section.classList.add('grows'); + (cols.get(p.column) || row).appendChild(section); + }); + box.appendChild(row); + } else { + panels.forEach((p) => box.appendChild(buildPanel(p, ctxFor(ctx, p.id)))); + } + + content.appendChild(box); + }); + + // Development aids, not part of the shipped page, and deliberately last: a one-line + // host status and the live WCP trace. They belong to the control destination because + // that is where the work happens. + $('#view-control').appendChild(devAids()); + $('#view-control').appendChild(tracePane()); +} + +function devAids() { + const line = el('div', 'status-line'); + const s = el('span', null, 'starting…'); s.id = 'status'; + const m = el('span', 'mode', '—'); m.id = 'mode'; + line.appendChild(s); + line.appendChild(m); + return line; +} + +function tracePane() { + const d = el('details', 'trace-panel'); + d.appendChild(el('summary', null, 'WCP trace')); + const t = el('div'); t.id = 'trace'; t.dataset.paused = '0'; + d.appendChild(t); + return d; +} + +/** + * Header controls that reflect state - the pickers. A header belongs to no renderer, so + * without this nothing would ever move a tab except the click that made it. + */ +const headerSync = []; + +/** + * What each panel last failed with, so a paint that throws on every frame - which is the + * usual shape - reports once rather than sixty times a second. + */ +const paintFailed = new Map(); + +/** + * The context one panel sees: the shared one, with `handlers` narrowed to that panel's + * own commands plus the page-level ones. + * + * Prototype delegation rather than a copy, so `state`, `store` and `pending` stay the + * one live object and only `handlers` differs. Built once per panel and held, because + * header controls and mount-time closures capture it. + */ +const scoped = new Map(); +function ctxFor(ctx, id) { + if (!scoped.has(id)) { + const c = Object.create(ctx); + c.handlers = ctx.commandsFor(id); + scoped.set(id, c); + } + return scoped.get(id); +} + +/** + * Repaint. Only the destination on screen is painted - the other one's panels are behind + * `hidden`, so painting them costs a full rebuild that nothing can see. + */ +export function paint(ctx, view) { + document.querySelectorAll('.nav-item').forEach( + (n) => n.classList.toggle('is-active', n.dataset.view === view)); + + VIEWS.forEach((v) => { $(`#view-${v.id}`).hidden = v.id !== view; }); + headerSync.forEach((f) => f()); + // A popover is on document.body, so the panel loop below never reaches it. It has its + // own signature guard; without this call it draws the state it was opened with, for as + // long as it stays open. + repaintPopover(); + + PANELS.forEach((p) => { + if (p.view !== null && p.view !== view) return; + const root = document.getElementById(p.bodyId || `${p.id}-body`); + if (!root) return; + try { + p.update(root, ctxFor(ctx, p.id)); + } catch (e) { + // One panel's throw used to take the rest of the frame with it, in silence: the + // whole paint runs inside one requestAnimationFrame callback, so a ReferenceError + // halfway through left the page half-built with nothing in the console. It + // happened during this restructure - a renderer kept a reference to a module + // variable that had moved - and the page simply stopped having a motion column. + // Now the panel that failed says so and the others still paint. + if (paintFailed.get(p.id) !== e.message) { + paintFailed.set(p.id, e.message); + console.error(`[shell] ${p.id} panel failed to paint:`, e); + } + root.dataset.paintError = e.message; + } + }); +} diff --git a/resources/web/device_page/js/views/device-control/camera/camera-commands.js b/resources/web/device_page/js/views/device-control/camera/camera-commands.js new file mode 100644 index 00000000000..72287fa7c6b --- /dev/null +++ b/resources/web/device_page/js/views/device-control/camera/camera-commands.js @@ -0,0 +1,332 @@ +/* + * camera-commands.js - everything the Camera panel can ask for. + * + * There are two printers behind this panel and only one of them is the machine Orca was + * written for. + * + * A stock U1 answers `camera.start_monitor` with a URL, then rewrites that one file + * every CAMERA_INTERVAL seconds. Two seconds. **Half a frame per second**, and the whole + * of what this panel could do before. + * + * A U1 running paxx12's Extended Firmware also runs a camera service: two real cameras, + * a still endpoint per camera that answers in 44-81 ms, and two streaming transports. + * Chained in WebKitGTK the case camera reached **14.0 fps at 1920x1080** - 28x the + * monitor file - and neither streaming transport worked at all, because that engine has + * no H.264 decoder and no RTCPeerConnection. + * + * So the shape of this module is: **detect, do not configure.** Ask Moonraker for the + * camera list; if something answers, the extended firmware is there and the direct + * transports are on offer. If nothing does, fall back to the monitor file, which is what + * every stock machine has. Nobody is asked to know which firmware they are running. + * + * Three things measured on the machine that shape the code rather than just the comments: + * + * - **nginx on :80 sends no CORS header; Moonraker on :7125 sends one.** So the list + * is fetched from 7125 and frames are only ever displayed through , never + * fetched. An loads a cross-origin still fine; `fetch()` of the identical URL + * fails "Load failed". + * - **The printer caps itself at 15 fps** (`target_fps` on every webcam entry), so the + * scale stops there and asking for 30 buys nothing. + * - **A grid is one poll per tile.** Three tiles at 15 fps is 4.7 MB/s and the case + * camera's 226 KB frame is nearly all of it, so only the focused tile polls fast. + */ +'use strict'; + +import { CMD, CAMERA_DOMAIN, CAMERA_INTERVAL, CAMERA_TRANSPORT, CAMERA_VIEW, + CAMERA_VIEW_TILES, CAMERA_FPS_UNFOCUSED, CAMERA_FPS_DEFAULT, + engineCaps, pickTransport, transportUsable, isCamera, snapshotUrl, + webcamListUrl, CAMERA_SCREEN_NAME } + from '../../../../../shared/js/protocol.js'; +import { cameraFrameUrl, tileImage } from './camera-view.js'; + +export function create(deps) { + // `bridge` is deliberately NOT destructured: it does not exist yet when these are + // built - boot() decides between the real host and the simulator - so it is reached + // through deps each time rather than captured as null once. + const { store, render } = deps; + + let camSub = null; + /** name -> {timer, url, gen}. One poller per visible tile, never one for the panel. */ + const pumps = new Map(); + /** Bumped on every stop, so a frame that decodes late cannot restart a dead pump. */ + let generation = 0; + + /* ---- what is available ------------------------------------------------ */ + + /** + * Ask the printer what cameras it has. + * + * Answering at all is the detection: a stock U1 has no camera service and this fails, + * which is not an error worth showing - it just means the monitor file is the only + * transport. `AbortSignal.timeout` rather than an open-ended fetch, because an + * unreachable printer would otherwise hold the panel at "looking" until TCP gave up. + */ + async function discover() { + // With no printer there is nothing to answer plain HTTP, so a simulated run would + // always fall back to the monitor file and never execute the tile grid, the + // transport list or the focus rule. The simulator answers this one directly. + if (deps.mock && deps.mock.webcams) { + return deps.mock.webcams().filter(isCamera).sort( + (a, b) => (a.name === CAMERA_SCREEN_NAME) - (b.name === CAMERA_SCREEN_NAME)); + } + const url = webcamListUrl(store.device); + if (!url) return []; + try { + const r = await fetch(url, { signal: AbortSignal.timeout(4000) }); + if (!r.ok) return []; + const body = await r.json(); + const list = (body && body.result && body.result.webcams) || []; + // Moonraker's list is a registry, not a camera list: this machine also carries the + // printer's own touchscreen and a multiACE web panel in it. A still to fetch is + // what makes an entry usable; the touchscreen has one and is kept, labelled and + // sorted last, because it does show what the machine is doing. + return list.filter(isCamera).sort( + (a, b) => (a.name === CAMERA_SCREEN_NAME) - (b.name === CAMERA_SCREEN_NAME)); + } catch { + return []; // no camera service; the monitor file still works + } + } + + /** Which transport is actually in use, resolving AUTO against what both ends can do. */ + function activeTransport() { + const caps = store.cam.caps || (store.cam.caps = engineCaps()); + const direct = store.cam.cams.length > 0; + if (store.cam.transport === CAMERA_TRANSPORT.AUTO) return pickTransport(caps, direct); + return transportUsable(store.cam.transport, caps, direct) + ? store.cam.transport + : pickTransport(caps, direct); + } + + /** The cameras this view draws, in tile order, capped at what the view can show. */ + function tiles() { + const want = CAMERA_VIEW_TILES[store.cam.view] || 1; + const by = new Map(store.cam.cams.map((c) => [c.name, c])); + const chosen = store.cam.picked.map((n) => by.get(n)).filter(Boolean); + // Anything not explicitly picked fills the remaining tiles in the printer's own + // order, so a fresh page shows pictures rather than empty cells. + const rest = store.cam.cams.filter((c) => !store.cam.picked.includes(c.name)); + return chosen.concat(rest).slice(0, want); + } + + /* ---- polling ---------------------------------------------------------- */ + + /** + * Defeat the cache, but only where there is a cache to defeat. + * + * Every transport here overwrites one file in place, so the URL never changes and the + * browser would serve its first frame forever. A `data:` URL has no cache and no query + * string: appending `?t=` to one appends to the DATA, which silently corrupts it - + * base64 often survives the trailing bytes, and an SVG never does. That is exactly how + * the simulated cameras rendered nothing while every other check passed. + */ + const bust = (url) => (/^(data:|blob:)/i.test(url) + ? url + : `${url}${url.includes('?') ? '&' : '?'}t=${Date.now()}`); + + /** + * Re-point one tile's as fast as it will decode, and no faster. + * + * The next fetch is scheduled from `onload`, not from a fixed interval: a timer that + * fires faster than the frames decode queues requests the engine has to serve anyway, + * and on a slow link that turns into unbounded lag rather than dropped frames. Waiting + * for the decode makes the rate self-limiting - it settles at whatever the printer and + * the network can actually do, which measured at 14.0 fps against a 15 fps cap. + * + * The element is re-pointed rather than replaced so the visible frame is only swapped + * once the next one has decoded, which is what keeps a tile from flickering. + */ + function pumpTile(cam, index) { + const base = snapshotUrl(store.device, cam); + if (!base) return; + const gen = generation; + const rec = { timer: null, url: base, gen }; + pumps.set(cam.name, rec); + + const step = () => { + if (rec.gen !== generation || !store.cam.streaming) return; + const im = tileImage(cam.name); + // render() is rAF-batched, so the first attempt usually lands before the paint. + if (!im) { rec.timer = setTimeout(step, 100); return; } + const focused = index === store.cam.focus; + const fps = Math.max(1, focused ? (store.cam.fps || CAMERA_FPS_DEFAULT) + : CAMERA_FPS_UNFOCUSED); + const wait = Math.max(0, Math.round(1000 / fps)); + const again = () => { + if (rec.gen !== generation) return; + rec.timer = setTimeout(step, wait); + }; + im.onload = again; + // A camera that stops answering must not spin: back off to a second, and keep + // trying, because the usual cause is the printer rebooting rather than the URL + // being wrong. + im.onerror = () => { + im.dataset.failed = '1'; + if (rec.gen === generation) rec.timer = setTimeout(step, 1000); + }; + delete im.dataset.failed; + im.src = bust(base); + }; + step(); + } + + function stopPumps() { + generation += 1; + pumps.forEach((r) => { if (r.timer) clearTimeout(r.timer); }); + pumps.clear(); + } + + /** Start a poller for every visible tile, and none for anything else. */ + function startPumps() { + stopPumps(); + tiles().forEach((cam, i) => pumpTile(cam, i)); + } + + /* ---- the monitor file, for a printer with no camera service ----------- */ + + function startMonitorPump() { + const gen = generation; + const rec = { timer: null, gen }; + pumps.set('__monitor', rec); + const step = () => { + if (rec.gen !== generation || !store.cam.streaming || !store.cam.frameUrl) return; + const im = tileImage('__monitor'); + if (im) im.src = bust(store.cam.frameUrl); + rec.timer = setTimeout(step, CAMERA_INTERVAL * 1000); + }; + step(); + } + + async function startMonitor() { + // Not a stream and not a frame push. The monitor answers once with a URL, then the + // printer rewrites that one file every `interval` seconds and the frames are ours + // to fetch. domain must be 'lan' - '' is refused -32000. See protocol.js CAMERA_*. + // + // That answer can arrive down either channel: SSWCP.cpp hands the printer's reply to + // on_mqtt_msg_arrived, which is the push path, but the bridge also acks the command. + // Only the MQTT leg has been watched directly, so take the URL from whichever + // channel produces it first and ignore the second. + const useUrl = (payload) => { + const url = cameraFrameUrl(payload, store.device); + if (!url || url === store.cam.frameUrl) return; + store.cam.frameUrl = url; + render(); + startMonitorPump(); + }; + + camSub = await deps.bridge.subscribe(CMD.CAMERA_START, + { domain: CAMERA_DOMAIN, interval: CAMERA_INTERVAL, expect_pw: false }, + (data, payload) => useUrl(data !== undefined ? data : payload)); + if (camSub && camSub.ack !== undefined && camSub.ack !== null) useUrl(camSub.ack); + } + + /* ---- what the panel calls --------------------------------------------- */ + + /** + * Turn the camera on. + * + * Discovery happens here rather than at boot: a printer that is not reachable at boot + * would answer nothing, and the panel would then believe forever that this machine has + * no cameras. Asking when the user presses play means the answer is as old as the press. + * + * A local function rather than a method reached through the returned object, because + * changing transport has to do all of this again and a module that calls itself + * through its own export is one the reachability check cannot read. + */ + async function start() { + store.cam.error = ''; + store.cam.streaming = true; + store.cam.frameUrl = null; + store.cam.caps = store.cam.caps || engineCaps(); + render(); + + try { + if (!store.cam.cams.length) store.cam.cams = await discover(); + if (activeTransport() === CAMERA_TRANSPORT.MONITOR) { + await startMonitor(); + } else { + render(); // paint the tiles before pointing them anywhere + startPumps(); + } + } catch (e) { + store.cam.streaming = false; + store.cam.error = `camera failed: ${e.message}`; + render(); + } + } + + return { + startCamera: start, + + stopCamera: async () => { + store.cam.streaming = false; + store.cam.frameUrl = null; + stopPumps(); + if (camSub && camSub.cancel) camSub.cancel(); + camSub = null; + render(); + try { + await deps.bridge.request(CMD.CAMERA_STOP, { domain: CAMERA_DOMAIN }); + } catch { /* already off, or never a monitor in the first place */ } + }, + + /** Re-ask the printer what cameras it has. The only reason the gear needs a button. */ + rescanCameras: async () => { + store.cam.cams = await discover(); + store.cam.caps = engineCaps(); + if (store.cam.streaming) startPumps(); + render(); + }, + + setCameraView: (view) => { + if (!Object.values(CAMERA_VIEW).includes(view)) return; + store.cam.view = view; + if (store.cam.focus >= (CAMERA_VIEW_TILES[view] || 1)) store.cam.focus = 0; + render(); + if (store.cam.streaming) startPumps(); + }, + + /** + * Put a camera in a tile. + * + * Picking one already on screen swaps it to the front rather than adding it twice - + * the same click means "show me this one" whether or not it is already up. + */ + pickCamera: (name, slot = 0) => { + const order = store.cam.picked.filter((n) => n !== name); + order.splice(Math.max(0, slot), 0, name); + store.cam.picked = order; + render(); + if (store.cam.streaming) startPumps(); + }, + + setCameraTransport: (t) => { + store.cam.transport = t; + // A transport change is a restart: the pumps, the subscription and the tile shape + // all belong to the one that was running. + stopPumps(); + render(); + if (store.cam.streaming) start(); + }, + + setCameraFps: (n) => { + store.cam.fps = Math.max(1, Number(n) || CAMERA_FPS_DEFAULT); + render(); + }, + + /** Which tile gets the full frame rate. Everything else drops to 1 fps. */ + focusTile: (i) => { + if (store.cam.focus === i) return; + store.cam.focus = i; + render(); + if (store.cam.streaming) startPumps(); + }, + + /** For the view and the settings sheet, which both need to say what is in use. */ + cameraStatus: () => ({ + transport: activeTransport(), + caps: store.cam.caps || (store.cam.caps = engineCaps()), + direct: store.cam.cams.length > 0, + tiles: tiles(), + }), + }; +} diff --git a/resources/web/device_page/js/views/device-control/camera/camera-panel.js b/resources/web/device_page/js/views/device-control/camera/camera-panel.js new file mode 100644 index 00000000000..10e818198c5 --- /dev/null +++ b/resources/web/device_page/js/views/device-control/camera/camera-panel.js @@ -0,0 +1,36 @@ +/* + * The Camera panel. + * + * Two printers behind one panel. A stock U1 gives half a frame per second through + * `camera.start_monitor`; one running the extended firmware gives 14 fps from either of + * two real cameras, and the difference is DETECTED rather than configured - see + * camera-commands.js. Everything the user can change about that lives behind the gear, + * anchored under it, so the picture stays up while it is being changed. + */ +'use strict'; + +import { renderCamera, openCameraSettings } from './camera-view.js'; + +export default { + id: 'camera', + title: 'Camera', + view: 'control', + column: 'main', + grow: true, + bodyId: 'camera', + bodyClass: 'camera-body', + + header: [ + { kind: 'spacer' }, + { kind: 'icon', id: 'camera-settings', cls: 'icon-only', icon: 'settings', + title: 'Camera settings', + on: (ctx) => openCameraSettings(document.getElementById('camera-settings'), + ctx.store.cam, ctx.handlers) }, + ], + + reads: ['store.cam', 'store.device', 'reachable'], + + update(root, ctx) { + renderCamera(root, ctx.store.reachable, ctx.store.cam, ctx.handlers, ctx.store.device); + }, +}; diff --git a/resources/web/device_page/js/views/device-control/camera/camera-view.js b/resources/web/device_page/js/views/device-control/camera/camera-view.js new file mode 100644 index 00000000000..587304ddc32 --- /dev/null +++ b/resources/web/device_page/js/views/device-control/camera/camera-view.js @@ -0,0 +1,325 @@ +/* + * The Camera panel's DOM. + * + * One viewport holding one to four tiles. A tile is an that a poller in + * camera-commands.js re-points; nothing here fetches anything. + * + * The panel used to be a single black box with one round play button in it, which is + * what the shipped page shows. That is still what it looks like with one camera and + * nothing playing - the extra machinery only appears when there is something to choose + * between. + */ +'use strict'; + +import { $, el } from '../../../core/dom.js'; +import { DEVICE, MOONRAKER_HTTP_PORT, CAMERA_FRAME_ROOT, CAMERA_FRAME_FILE, + CAMERA_TRANSPORT, CAMERA_TRANSPORT_ORDER, CAMERA_TRANSPORT_TEXT, + CAMERA_VIEW, CAMERA_VIEW_TILES, CAMERA_FPS_CHOICES, + transportUsable, cameraLabel, snapshotUrl } + from '../../../../../shared/js/protocol.js'; +import { illustration } from '../../../widgets/art.js'; +import { rebuildOn, text } from '../../../core/render.js'; +import { openPopover } from '../../../core/overlay.js'; + +/** + * Where a MONITOR frame comes from, measured rather than guessed. + * + * `camera.start_monitor` replies `{ state, url }` and then sends nothing further - there + * is no frame push to wait for. The printer rewrites one file at the monitor interval + * and the image is fetched over HTTP; see CAMERA_* in protocol.js for the whole finding. + * + * The reply's own `url` is relative to the printer's :80 web UI, which answers that path + * with its SPA shell rather than an image, so it is used only when it is already + * absolute - which is how the simulator hands back a data: URI. Otherwise the frame is + * addressed on Moonraker's file server, keeping the filename the printer named. + * + * This is the STOCK path. A printer with the extended firmware's camera service never + * comes through here: its stills are addressed by `snapshotUrl()` instead. + */ +export function cameraFrameUrl(payload, device) { + const d = payload && payload.data !== undefined ? payload.data : payload; + const raw = d && (d.url || d.path || d.frame_url); + if (typeof raw === 'string' && /^(data:|blob:|https?:)/i.test(raw)) return raw; + + const ip = device && device[DEVICE.IP]; + if (!ip) return null; + const name = (typeof raw === 'string' && raw.split('/').filter(Boolean).pop()) + || CAMERA_FRAME_FILE; + return `http://${ip}:${MOONRAKER_HTTP_PORT}` + + `/server/files/${CAMERA_FRAME_ROOT}/${encodeURIComponent(name)}`; +} + +/** The a poller re-points. Addressed by camera name so a repaint cannot orphan it. */ +export function tileImage(name) { + return $(`.cam-tile[data-cam="${CSS.escape(String(name))}"] img.cam-frame`); +} + +/** + * What the shipped bundle says, recovered from main.dart.js rather than written here. + * The rebuild had invented "Camera is off"; the original says "Camera not on". + */ +export const CAMERA_TEXT = { + off: 'Camera not on', + loading: 'Camera loading failed. Please try again', + started: 'Camera started successfully', + failed: 'Camera start failed', + noTimelapse: 'Time lapse camera is not supported', + noFourth: 'No fourth camera', +}; + +export function renderCamera(root, connected, cam, handlers, device) { + const status = handlers.cameraStatus ? handlers.cameraStatus() : null; + const running = !!cam.streaming; + const shape = !connected ? 'off' : 'view'; + + // The tile set is part of the structure, so it is in the signature: adding a camera or + // switching from one picture to four is a rebuild, while a new FRAME is not - the + // is re-pointed in place by the pump and must not be rebuilt under it. + const names = status ? status.tiles.map((c) => c.name) : []; + const sig = [shape, running ? 1 : 0, cam.view, names.join(','), + cam.frameUrl || '', status ? status.transport : ''].join('|'); + + rebuildOn(root, sig, () => { + if (shape === 'off') { + const wrap = el('div'); + wrap.style.textAlign = 'center'; + illustration(wrap); + root.appendChild(wrap); + return; + } + + // The body is BLACK and the control is a round play button inside it, which is what + // the shipped page shows: a viewport that is dark whether or not a frame is in it. + const view = el('div', 'cam-view'); + view.dataset.view = cam.view; + + if (running) { + const grid = el('div', 'cam-tiles'); + const want = CAMERA_VIEW_TILES[cam.view] || 1; + const monitor = status && status.transport === CAMERA_TRANSPORT.MONITOR; + + if (monitor) { + // One file, no camera list, no name to hang a tile off. It is still a tile, so + // that one piece of markup serves both printers. + grid.appendChild(tile('__monitor', null, cam.frameUrl, 0, cam, handlers)); + } else { + status.tiles.forEach((c, i) => grid.appendChild( + tile(c.name, cameraLabel(c), snapshotUrl(device, c), i, cam, handlers, c))); + // A 2x2 that draws three cameras leaves one cell. Saying why is better than a + // three-up layout that reflows the moment a second USB camera is plugged in - + // and the extended firmware supports exactly that. + for (let i = status.tiles.length; i < want; i++) { + const empty = el('div', 'cam-tile is-empty'); + empty.appendChild(el('div', 'cam-tile-msg', CAMERA_TEXT.noFourth)); + grid.appendChild(empty); + } + } + view.appendChild(grid); + } + + // One control, centred, the way the original has it. While a frame is playing it + // only appears on hover, because the original shows an unobstructed picture. + const btn = el('button', 'cam-play'); + btn.type = 'button'; + if (running) btn.dataset.on = '1'; + btn.title = running ? 'Stop the camera' : 'Start the camera'; + btn.setAttribute('aria-label', btn.title); + btn.innerHTML = running + ? '' + : ''; + btn.onclick = () => (running ? handlers.stopCamera() : handlers.startCamera()); + view.appendChild(btn); + + if (!running) view.appendChild(el('div', 'cam-msg', '')); + root.appendChild(view); + }); + + // The message is patched rather than keyed on: an error arriving is not a change of + // shape, and rebuilding for it would take the live with it. + const msg = root.querySelector('.cam-msg'); + // The bundle's own wording, not ours: CAMERA_TEXT holds every string it uses here. + if (msg) text(msg, cam.error || (running ? CAMERA_TEXT.loading : CAMERA_TEXT.off)); + + // The overlay chips read the frame rate and the camera name, which change without the + // structure changing, so they are patched every paint rather than built into the key. + root.querySelectorAll('.cam-tile[data-cam]').forEach((t) => { + const i = Number(t.dataset.index || 0); + t.classList.toggle('is-focus', running && i === cam.focus); + }); +} + +/** One picture, its name, and the hit target that makes it the focused one. */ +function tile(name, label, url, index, cam, handlers, entry) { + const t = el('div', 'cam-tile'); + t.dataset.cam = name; + t.dataset.index = String(index); + + const im = el('img', 'cam-frame'); + im.alt = label ? `${label} camera` : 'Live view'; + // No src here. The poller sets it - starting with one would fetch a frame the pump is + // about to replace, and on the monitor transport it would fetch the cached one. + if (url && index === 0 && !entry) im.src = url; + t.appendChild(im); + + if (label) { + t.appendChild(el('span', 'cam-tile-name', label)); + // Clicking a tile is how it becomes the one that polls fast. Everything else drops + // to 1 fps, which is what makes a grid cost 3.5 MB/s instead of 4.7. + t.tabIndex = 0; + t.title = `Watch ${label} at full frame rate`; + const take = () => handlers.focusTile(index); + t.onclick = take; + t.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); take(); } }; + } + return t; +} + +/* ---- the settings popover -------------------------------------------- */ + +/** + * Anchored under the gear that owns it, and the picture stays up behind it. + * + * That is the whole argument for a popover over a settings tab here: the settings are + * about what the picture looks like, and a sheet that replaces the picture hides the + * thing being adjusted at exactly the moment it is being adjusted. + */ +export function openCameraSettings(anchor, cam, handlers) { + openPopover(anchor, { + title: 'Camera', + width: 330, + + // What this panel DRAWS, so `repaintPopover` knows when it has gone stale. Without + // it the popover is built once and never again: every control here worked and none + // of them moved their own tick, because the tick is drawn from `store.cam` and a + // popover sits outside every panel `paint()` walks. + // + // Not a repaint-every-frame: this rebuilds the body, which would take focus and + // hover with it on a tick the user did not cause. + sig: () => [cam.view, cam.transport, cam.fps, cam.focus, + cam.picked.join(','), cam.cams.length, + handlers.cameraStatus().transport].join('|'), + + build: (b) => { + b.classList.add('cam-set'); + // Read inside build, not outside it: this runs again on every change, and a value + // captured when the popover opened is exactly the staleness being fixed. + const st = handlers.cameraStatus(); + + group(b, 'Views', (g) => { + seg(g, [ + [CAMERA_VIEW.SINGLE, 'Single'], [CAMERA_VIEW.SPLIT, 'Split'], + [CAMERA_VIEW.GRID, 'Grid'], [CAMERA_VIEW.PIP, 'PiP'], + ], cam.view, (v) => handlers.setCameraView(v)); + }); + + if (st.direct) { + group(b, 'Cameras', (g) => { + const row = el('div', 'cam-pick-row'); + st.tiles.forEach((c, i) => { + const on = i < (CAMERA_VIEW_TILES[cam.view] || 1); + row.appendChild(camChip(c, on, i === cam.focus, + () => handlers.focusTile(i))); + }); + cam.cams.filter((c) => !st.tiles.some((t) => t.name === c.name)) + .forEach((c) => row.appendChild( + camChip(c, false, false, () => handlers.pickCamera(c.name, 0)))); + g.appendChild(row); + }); + } + + group(b, 'Transport', (g) => { + const list = el('div', 'cam-modes'); + [CAMERA_TRANSPORT.AUTO, ...CAMERA_TRANSPORT_ORDER].forEach((t) => { + const usable = t === CAMERA_TRANSPORT.AUTO + || transportUsable(t, st.caps, st.direct); + const chosen = cam.transport === t; + const r = el('button', 'cam-mode'); + r.type = 'button'; + if (chosen) r.dataset.on = '1'; + if (!usable) r.dataset.off = '1'; + r.disabled = !usable; + + const info = CAMERA_TRANSPORT_TEXT[t]; + r.appendChild(el('span', 'cam-mode-dot')); + const body = el('div', 'cam-mode-body'); + body.appendChild(el('span', 'cam-mode-name', info.name)); + body.appendChild(el('span', 'cam-mode-hint', info.hint)); + r.appendChild(body); + + // Auto reports what it landed on, so naming the transports is still doing work + // even for someone who never changes this. + if (t === CAMERA_TRANSPORT.AUTO && chosen) { + r.appendChild(el('span', 'cam-mode-tag', + CAMERA_TRANSPORT_TEXT[st.transport].name)); + } else if (!usable) { + // The reason lives next to the greyed thing, not in its name. This is the + // whole reason the transports are named after transports. + r.appendChild(el('span', 'cam-mode-tag is-no', info.absent || 'unavailable')); + } + + if (usable) r.onclick = () => handlers.setCameraTransport(t); + list.appendChild(r); + }); + g.appendChild(list); + }); + + group(b, 'Frames per second', (g) => { + seg(g, CAMERA_FPS_CHOICES.map((n) => [n, String(n)]), cam.fps, + (n) => handlers.setCameraFps(n)); + const note = el('div', 'cam-note'); + // Both halves measured: the printer's own target_fps is 15, and three tiles of + // its 226 KB / 87 KB / 710 B frames at 15 is 4.7 MB/s. + note.textContent = st.direct + ? `The printer's own limit is 15. Tiles you are not watching drop to 1, ` + + `which is what keeps a grid near 3.5 MB/s instead of 4.7.` + : 'This printer has no camera service, so the monitor file sets the rate.'; + g.appendChild(note); + }); + + const foot = el('div', 'cam-set-foot'); + const scan = el('button', 'btn', 'Look for cameras again'); + scan.type = 'button'; + scan.onclick = () => handlers.rescanCameras(); + foot.appendChild(scan); + b.appendChild(foot); + }, + }); +} + +function group(parent, label, build) { + const g = el('div', 'cam-set-group'); + g.appendChild(el('div', 'cam-set-label', label)); + build(g); + parent.appendChild(g); + return g; +} + +function seg(parent, items, current, on) { + const s = el('div', 'cam-seg'); + items.forEach(([value, label]) => { + const b = el('button', 'cam-seg-btn', label); + b.type = 'button'; + if (String(value) === String(current)) b.dataset.on = '1'; + b.onclick = () => on(value); + s.appendChild(b); + }); + parent.appendChild(s); + return s; +} + +function camChip(c, shown, focused, on) { + const b = el('button', 'cam-chip'); + b.type = 'button'; + if (shown) b.dataset.on = '1'; + if (focused) b.dataset.focus = '1'; + b.appendChild(el('span', 'cam-chip-th')); + const m = el('div', 'cam-chip-meta'); + m.appendChild(el('span', 'cam-chip-name', cameraLabel(c))); + // The resolution is the honest way to say which of two cameras this is - the names + // the printer gives them ("case", "usb") mean nothing to someone looking at a picture. + const dim = c.extra_data && c.extra_data.resolution; + m.appendChild(el('span', 'cam-chip-sub', dim || c.service || '')); + b.appendChild(m); + b.onclick = on; + return b; +} diff --git a/resources/web/device_page/js/views/device-control/control/control-commands.js b/resources/web/device_page/js/views/device-control/control/control-commands.js new file mode 100644 index 00000000000..2c542958e3c --- /dev/null +++ b/resources/web/device_page/js/views/device-control/control/control-commands.js @@ -0,0 +1,307 @@ +/* + * commands/control.js - Commands the Control panel issues: heaters, fans, light, purifier, speed, + * motion, and which toolhead is live. + * + * Two of them are not simple sends. A setpoint is held until the machine echoes it (see + * pending.js), and a toolhead action waits on the MACHINE rather than on its own request + * - sw_SendGCodes does not return until Klipper has finished the move, and the bridge + * gives up at 15s while a cold toolchange measured 31s. + * + * check_coverage.py reads the CMD references out of this file to answer "can a user + * reach this command", and attributes them to the control panel because this is the module + * that panel is handed. That makes the attribution a fact about the imports rather than + * a promise in a declaration - which is the difference that let a handler nothing called + * count as implemented for as long as it did. + */ +'use strict'; + +import { CMD, NAMED, LIMITS, PRINT_PREFERENCES } + from '../../../../../shared/js/protocol.js'; +import { openDialog, openBlockingDialog, toggleField } from '../../../core/overlay.js'; +import { isTimeout } from '../../../../../shared/js/sswcp.js'; +import { machineActivity, isBusy } from '../../../../../shared/js/activity.js'; + +export function create(deps) { + // `bridge` is deliberately NOT destructured: it does not exist yet when these are + // built - boot() decides between the real host and the simulator - so it is reached + // through deps each time rather than captured as null once. + const { state, store, pending, session, cmd, + send, setpoint, setStatus, render } = deps; + + const clampTo = (lim, v) => Math.min(lim.max, Math.max(lim.min, Math.round(v))); + + // A toolchange is mechanical. 60s is what the printer's own UI allows: its handler + // raises an "Extruder N operating..." overlay with a 60 timeout before dispatching. + const TOOL_CHANGE_TIMEOUT_MS = 60000; + + /** + * Fire a toolhead command and wait for the machine, not for the request. + * + * Three things make the obvious `await deps.bridge.request(...)` wrong here: + * + * 1. `sw_SendGCodes` does not return until Klipper has finished the move, but the + * bridge gives up after 15s (TIMEOUT_MS in sswcp.js). A toolchange that triggers an + * XY calibration runs far longer than that, so the request rejects while the printer + * is still working. Treating that rejection as failure told the user to retry + * something already in progress. + * 2. The G-code ack only says the command was queued. The machine's own state is the + * only thing that says it worked. + * 3. The machine says what it is doing: `machine_state_manager.action_code` reports + * "Extruder Docking Calibrating...", "Checking Extruder Pick..." and the rest. So + * the wait can show the real reason it is taking a while, and - more usefully - can + * keep waiting for as long as the machine claims to be busy. + * + * The shipped page does none of this. It raises an overlay with a flat 60s auto-dismiss + * and reports "Request timeout, please try again later" when its own request gives up, + * whether or not the printer is still moving. + */ + const TOOL_ACTION_HARD_CAP_MS = 10 * 60 * 1000; + + /** + * `done` is OPTIONAL, and homing is why. + * + * An action with a goal the machine reports - a toolchange, a park - ends when that + * field says so, and waiting past it would only be waiting. An action with no such + * field ends on the one thing every action has: the machine having been working and + * having stopped. That is the `sawBusy` branch below, and for those actions it is not + * a fallback but the whole signal. + * + * Passing a `done` that is not really one is worse than passing none, because it wins + * the race: it is tested first, so a field that is already true closes the dialog + * before the machine has moved. That is exactly what `home` did with `homed_axes`. + */ + function runToolAction({ title, script, waiting, done, gaveUp, settle }) { + const dlg = openBlockingDialog({ title, message: waiting }); + let refused = null; + // deliberately not awaited - see (1) + deps.bridge.request(CMD.SEND_GCODES, { script }).catch((e) => { + // A timeout is not a refusal - the machine is still moving. Which clock ran out + // is isTimeout's business; see the note on it in sswcp.js. + if (!isTimeout(e)) refused = e; + }); + + const started = Date.now(); + let quietDeadline = started + TOOL_CHANGE_TIMEOUT_MS; + let lastLabel = null; + + return (async () => { + let sawBusy = false; + let lastPoll = 0; + for (;;) { + // Every source that answers, not just machine_state_manager - which reads + // {main_state: 0, action_code: 0} straight through a manual toolchange on this + // firmware, so a wait that trusted it alone saw silence and gave up on work that + // was going fine. busyReason() adds the calibration step, the macro's own message + // and physical motion. + const act = state.activity(); + const reason = state.busyReason(); + + if (done && done()) { + if (settle) await settle(); + dlg.close(); render(); return; + } + if (refused) { dlg.fail(`The printer refused the command: ${refused.message}`); return; } + + // The action's own line is the more useful one when all the machine will say is + // that it is busy - see `vague` in busyReason(). + const label = machineActivity(act) || (reason.vague ? null : reason.label); + if (label) { + if (label !== lastLabel) { dlg.update(label); lastLabel = label; } + } else if (lastLabel) { + dlg.update(waiting); + lastLabel = null; + } + // keep waiting for as long as anything says it is doing something + if (isBusy(act) || reason.busy) { + sawBusy = true; + quietDeadline = Date.now() + TOOL_CHANGE_TIMEOUT_MS; + } else if (sawBusy) { + // It was working and has stopped. `sawBusy` is what keeps this from firing in + // the seconds before the machine picks the command up: idle_timeout was still + // "Ready" for the first 1.6s of the measured G28, and closing there would be + // the same bug from the other end. + if (settle) await settle(); + dlg.close(); render(); return; + } + + // re-read the unsubscribed objects roughly once a second + if (Date.now() - lastPoll > 1000) { lastPoll = Date.now(); await session.refreshWaitState(); } + + if (Date.now() > quietDeadline) { dlg.fail(gaveUp); return; } + if (Date.now() - started > TOOL_ACTION_HARD_CAP_MS) { + dlg.fail(`${gaveUp} Giving up after ten minutes.`); + return; + } + await new Promise((r) => setTimeout(r, 400)); + } + })(); + } + + return { + setSpeed: (v) => + send(CMD.CONTROL_PRINT_SPEED, { percentage: clampTo(LIMITS.printSpeed, v) }, 'set speed'), + + setBedTemp: (v) => { + const t = clampTo(LIMITS.bedTemp, v); + return setpoint(CMD.CONTROL_BED_TEMP, { temp: t }, 'set bed temp', + t > 0 ? `Heated bed \u2192 ${t} \u00B0C` : 'Heated bed off'); + }, + + setExtruderTemp: (index, v) => { + const t = clampTo(LIMITS.nozzleTemp, v); + return setpoint(CMD.CONTROL_EXTRUDER_TEMP, { temp: t, index, map: index }, + 'set nozzle temp', + t > 0 ? `Toolhead ${index + 1} \u2192 ${t} \u00B0C` + : `Toolhead ${index + 1} off`); + }, + + setMainFan: (v) => + send(CMD.CONTROL_MAIN_FAN, { speed: clampTo(LIMITS.fanSpeed, v) }, 'set main fan'), + + setCavityFan: (v) => + send(CMD.CONTROL_GENERIC_FAN, + { name: NAMED.cavityFan, speed: clampTo(LIMITS.fanSpeed, v) }, 'set assist fan'), + + // Held until the printer echoes it, like a setpoint: measured at 236ms, 2029ms and + // 620ms across three runs, and the switch reverted under the mirror in two of them. + + setLed: (on) => + pending.track('led', !!on, + send(CMD.CONTROL_LED, { name: NAMED.cavityLed, white: on ? 1 : 0 }, 'set led')), + + // mode is an integer on the wire; the page used to send 'inner'/'exhaust' strings. + + setPurifierMode: (mode) => + send(CMD.CONTROL_PURIFIER, { mode: Number(mode) }, 'set purifier mode'), + + /** Aim jog and extrude at a head. Selection only - it does not change the tool. */ + + selectTool: (i) => { store.activeTool = Number(i) || 0; render(); }, + + // Motion has no dedicated bridge command - the shipped page sends G-code, so + // this does too. G28 homes; G91/G0/G90 makes one relative step. + /** + * Home, then re-read what got homed. + * + * `homed_axes` lives only on the `toolhead` object, which the shipped page does not + * subscribe - so nothing on the stream reports that homing finished. G28 also runs far + * longer than the bridge's 15s timeout, so this waits on the machine the same way a + * toolchange does, then refreshes the one field that cannot arrive on its own. + */ + + /** + * Homing has no goal field, and `homed_axes` is not one. + * + * It looked like one, and this wait used it twice - first alone, then guarded by a + * stillness check - and closed early both times. A real G28 was watched end to end + * on 811002511261022618B3 (2026-09-01, 42 s, sampled every 200 ms) and settles it: + * + * 0.0s homed_axes "xyz" idle_timeout "Ready" vel 0 <- G28 sent + * 1.6s homed_axes "xyz" idle_timeout "Printing" vel 0 + * 5.5s homed_axes "xyz" vel 40 + * 27.6s homed_axes "xyz" vel 0 z 309 + * 41.7s homed_axes "xyz" idle_timeout "Ready" vel 0 <- done + * + * `homed_axes` NEVER MOVED. It read "xyz" for the whole procedure, because the + * machine was already homed and this firmware's G28 does not clear it - so the + * predicate was true on the first poll and the dialog shut over a machine that had + * not started. (The toolchange trace in state.js's busyReason does show it clearing; + * that is `G28 X Y` under a homing_override, and it is a different path.) + * + * `live_velocity` cannot patch it either: it reads exactly 0 for two seconds at a + * time between the homing moves, eight times across those 42 seconds. Any stillness + * window short enough to be responsive fits inside one of those gaps. + * + * `idle_timeout` bracketed the operation exactly - Ready, Printing for forty + * seconds, Ready - and `busyReason()` already reads it. So the answer is to pass no + * done() at all and let the wait end where the evidence is: the machine having been + * working and having stopped. + */ + home: () => runToolAction({ + title: 'Homing', + script: 'G28', + waiting: 'Homing all axes\u2026', + // One more read on the way out: the wait polls `toolhead` about once a second, and + // what the jog guard needs is `homed_axes` as it stands when the machine stopped. + settle: () => session.refreshToolhead(), + gaveUp: 'Homing did not finish in time.', + }), + + /** + * Change the live toolhead, or park it. See runToolAction for why this does not + * simply await the command. + */ + + pickTool: (i) => { + const idx = Number(i) || 0; + return runToolAction({ + title: `Switching to toolhead ${idx + 1}`, + // `T A0` is what the printer's own UI sends, recovered from the shipped bundle. + // The A0 is not decoration - the firmware's SM_PRINT_CHECK_SWITCH_EXTRUDER passes + // it too. Neither this nor PARK_EXTRUDER appears in printer.gcode.help, because + // both register without help strings, as T0..T3 do. + script: `T${idx} A0`, + waiting: `Waiting for toolhead ${idx + 1}\u2026`, + done: () => state.toolhead().activeIndex === idx, + gaveUp: `Toolhead ${idx + 1} did not report active. The printer may still be ` + + 'working, or the change may have failed.', + }); + }, + + parkTool: (i) => { + const live = state.toolhead().activeIndex; + const idx = i == null ? live : Number(i); + if (live == null || idx !== live) { + setStatus(live == null ? 'No toolhead is engaged' + : `Toolhead ${live + 1} is the one engaged`, 'warn'); + return Promise.resolve(); + } + return runToolAction({ + title: `Parking toolhead ${idx + 1}`, + script: `PARK_EXTRUDER${idx === 0 ? '' : idx}`, + waiting: 'Waiting for the printer to park\u2026', + done: () => state.toolhead().activeIndex == null, + gaveUp: 'The printer did not report a parked toolhead.', + }); + }, + + // chrome, from the registry's header declarations + + jog: (axis, deltaMm, toolIndex) => { + const d = Number(deltaMm); + if (!Number.isFinite(d) || d === 0) return; + if (axis === 'E') { + // Extrusion is per-toolhead, so select the tool first. + const t = Number(toolIndex) || 0; + return send(CMD.SEND_GCODES, + { script: `T${t}\nG91\nG0 E${d} F300\nG90` }, 'extrude'); + } + // Klipper refuses a move on an unhomed axis and the failure is silent from here, + // so say so rather than letting the press look like a dead button. + if (state.toolhead().allHomed === false) { + setStatus('Home the axes before jogging', 'warn'); + return; + } + const feed = axis === 'Z' ? 600 : 3000; + return send(CMD.SEND_GCODES, + { script: `G91\nG0 ${axis}${d} F${feed}\nG90` }, `jog ${axis}`); + }, + + printPrefs: () => { + const tc = state.taskConfig(); + const boxes = []; + openDialog({ + title: 'Print Preferences', + build: (b) => PRINT_PREFERENCES.forEach(({ key, label }) => { + boxes.push([key, toggleField(b, { label, checked: !!tc[key] })]); + }), + confirmLabel: 'Apply', + onConfirm: () => { + const patch = {}; + boxes.forEach(([k, input]) => { patch[k] = input.checked; }); + send(CMD.UPDATE_MACHINE_FILAMENT_INFO, patch, 'set print preferences'); + }, + }); + }, + }; +} diff --git a/resources/web/device_page/js/views/device-control/control/control-panel.js b/resources/web/device_page/js/views/device-control/control/control-panel.js new file mode 100644 index 00000000000..21e7ba6f716 --- /dev/null +++ b/resources/web/device_page/js/views/device-control/control/control-panel.js @@ -0,0 +1,49 @@ +/* + * The Control panel: readings, toolhead picker, motion. + * + * Two renderers share one body because they share one enabled/disabled state - the + * shipped page fades the whole control surface while the machine is unreachable rather + * than hiding it, and `.control-grid[data-enabled]` is what does that. + */ +'use strict'; + +import { el } from '../../../core/dom.js'; +import { renderControlMain, renderStatusCard } from './control-view.js'; + +export default { + id: 'control', + title: 'Control', + view: 'control', + column: 'side', + bodyClass: 'control-body', + + header: [ + { kind: 'gap' }, + { kind: 'icon', id: 'refresh', cls: 'tab wide is-active', icon: 'refresh', + title: 'Refresh', on: (ctx) => ctx.handlers.refreshAll() }, + { kind: 'spacer' }, + { kind: 'pill', id: 'print-prefs', cls: 'pref-pill', label: 'Print Preferences', + chev: 'printPreferenceArrow', on: (ctx) => ctx.handlers.printPrefs() }, + ], + + reads: ['toolheads', 'bed', 'led', 'fans', 'purifier', 'speed', 'toolhead', 'reachable'], + + mount(root) { + const grid = el('div', 'control-grid'); + grid.id = 'control-grid'; + grid.dataset.enabled = '0'; + const card = el('div', 'status-card'); card.id = 'status-card'; + const main = el('div', 'control-main'); main.id = 'control-main'; + grid.appendChild(card); + grid.appendChild(main); + root.appendChild(grid); + }, + + update(root, ctx) { + // The shipped page renders the whole control surface and fades it while the machine + // is unreachable, rather than hiding it. Match that. + root.firstChild.dataset.enabled = ctx.store.reachable ? '1' : '0'; + renderStatusCard(root.querySelector('#status-card'), ctx); + renderControlMain(root.querySelector('#control-main'), ctx); + }, +}; diff --git a/resources/web/device_page/js/views/device-control/control/control-view.js b/resources/web/device_page/js/views/device-control/control/control-view.js new file mode 100644 index 00000000000..f31df5e3497 --- /dev/null +++ b/resources/web/device_page/js/views/device-control/control/control-view.js @@ -0,0 +1,730 @@ +/* + * The Control panel's DOM: readings, quick settings, toolhead picker and motion. + * + * The biggest of the six by a distance, and where the measurements live - the 126px + * reading column, the three-digit reserve, the jog wheel's bands. See + * docs/u1-webui/02-device-page/05-visual-reference.md + */ +'use strict'; + +import { $, el, icon } from '../../../core/dom.js'; +import { LIMITS, PURIFIER_MODES } from '../../../../../shared/js/protocol.js'; +import { openPopover, closePopover } from '../../../core/overlay.js'; +import { placeholder } from '../../../widgets/art.js'; +import { rebuildOn, text, data } from '../../../core/render.js'; + +// Print speed moves in whole 50% steps: 50 / 100 / 150 across LIMITS.printSpeed. +const SPEED_STEP = 50; +// Granularity and labelling are different questions. The fans are continuous - step 1, +// so any value is reachable - but labelling every step printed 101 ticks across the +// panel. These are the marks that get drawn; `step` is only what the knob snaps to. + +// Granularity and labelling are different questions. The fans are continuous - step 1, +// so any value is reachable - but labelling every step printed 101 ticks across the +// panel. These are the marks that get drawn; `step` is only what the knob snaps to. +const FAN_TICKS = [0, 25, 50, 75, 100]; +/** Speed is labelled at exactly the values the machine accepts - there are only three. */ + +/** Speed is labelled at exactly the values the machine accepts - there are only three. */ +function speedTicks() { + const out = []; + for (let v = LIMITS.printSpeed.min; v <= LIMITS.printSpeed.max; v += SPEED_STEP) out.push(v); + return out; +} + +/* + * Heat states, read off the two numbers the machine already sends. + * + * `power` would say directly whether a heater is being driven, but only the extruders + * report it - `heater_bed` is subscribed for `temperature` and `target` alone - so the + * comparison below is the one question both can answer. + */ + +/* + * Heat states, read off the two numbers the machine already sends. + * + * `power` would say directly whether a heater is being driven, but only the extruders + * report it - `heater_bed` is subscribed for `temperature` and `target` alone - so the + * comparison below is the one question both can answer. + */ +const TEMP_TOL = 2; // within this of the target counts as arrived + +const TEMP_WARM = 40; // above this the hardware is still hot to the touch + +function heatState(cur, target) { + if (!Number.isFinite(cur)) return ''; + if (target > 0 && cur < target - TEMP_TOL) return 'heating'; + if (target > 0 && Math.abs(cur - target) <= TEMP_TOL) return 'ready'; + // Cooling is only worth saying while the part is still hot enough to matter. + if (cur > target + TEMP_TOL && cur > TEMP_WARM) return 'cooling'; + return ''; +} + +/** How far along the ramp from where it started to where it is going. */ + +/** How far along the ramp from where it started to where it is going. */ +function rampProgress(start, cur, target) { + if (!Number.isFinite(start) || !Number.isFinite(cur)) return 0; + const span = target - start; + if (Math.abs(span) < 1) return 1; + return Math.min(1, Math.max(0, (cur - start) / span)); +} + +/** + * A row is an icon and a reading. No label: the shipped icons carry the identity - + * iconExtruder1..4 are numbered - and the label was costing the width the numbers need. + * + * The target edits in place. It is a real so it brings the I-beam, + * keyboard, selection and min/max validation with it, styled to look like text until the + * row is hovered. Committing on Enter or blur rather than on every keystroke, because + * each commit is a G-code round trip to the machine. + * + * A target of zero is a heater that is off, and it shows as an empty field over a dash + * rather than as `0`. The zero was never information - every idle row carried one - and + * it had to be deleted before a number could be typed. Focus selects whatever is there + * for the same reason: this field is replaced far more often than it is edited. + */ + +/** + * A row is an icon and a reading. No label: the shipped icons carry the identity - + * iconExtruder1..4 are numbered - and the label was costing the width the numbers need. + * + * The target edits in place. It is a real so it brings the I-beam, + * keyboard, selection and min/max validation with it, styled to look like text until the + * row is hovered. Committing on Enter or blur rather than on every keystroke, because + * each commit is a G-code round trip to the machine. + * + * A target of zero is a heater that is off, and it shows as an empty field over a dash + * rather than as `0`. The zero was never information - every idle row carried one - and + * it had to be deleted before a number could be typed. Focus selects whatever is there + * for the same reason: this field is replaced far more often than it is edited. + */ +function tempRow(key, iconName, limit, title, apply, pending) { + const row = el('div', 'status-row'); + row.dataset.k = key; + row.dataset.name = title; + row.title = title; + row.appendChild(icon(iconName)); + + row.appendChild(el('span', 'cur', '_')); + row.appendChild(el('span', 'sl', '/')); + + const tgt = document.createElement('input'); + tgt.className = 'tgt'; + tgt.type = 'number'; + tgt.min = String(limit.min); + tgt.max = String(limit.max); + tgt.placeholder = '—'; + tgt.setAttribute('aria-label', title); + // The machine's current target lives on the element, not in a closure: the row is + // never rebuilt, so a captured value would go stale the moment the printer changed it. + tgt.dataset.target = '0'; + const revert = () => showTarget(tgt, tgt.dataset.target); + const commit = () => { + // An empty field is not an instruction to switch the heater off. It is a cleared + // field on the way to a number, or a keystroke the browser refused. Off is asked + // for by typing a zero, which is explicit and cannot happen by walking away. + const raw = tgt.value.trim(); + if (raw === '') return revert(); + const v = Number(raw); + if (!Number.isFinite(v) || v < limit.min || v > limit.max) return revert(); + if (v === Number(tgt.dataset.target)) return; + // Hold the asked-for value on screen until the machine echoes it back. Writing the + // machine's target straight back in is what made a set temperature vanish: the next + // state push lands about a second later, before the printer has reported the change, + // so the field went back to what it said before - usually 0. + // + // `track` wires the command's own failure in too, which is the one thing the row + // cannot see for itself: a command that never left has nothing coming to confirm it. + pending.track(key, v, apply(v)); + // Reflected here and not left to the next repaint: the pulse is feedback for the + // keystroke that caused it, and a frame away is the wrong distance from a keypress. + setPend(row, 'sent'); + row.dataset.pendVal = String(v); + }; + tgt.onkeydown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); tgt.blur(); } + if (e.key === 'Escape') { revert(); tgt.blur(); } + }; + tgt.onblur = commit; + // Select on focus, with the guard that pattern needs: on a click, focus fires before + // mouseup, and mouseup would otherwise collapse the selection focus had just made. + let claiming = false; + tgt.onmousedown = () => { claiming = document.activeElement !== tgt; }; + tgt.onmouseup = (e) => { if (claiming) { e.preventDefault(); claiming = false; } }; + tgt.onfocus = () => tgt.select(); + row.appendChild(tgt); + + row.appendChild(el('span', 'unit', '°C')); + // The heat bar carries no reading of its own - only how far along the ramp the + // machine is. It is positioned out of flow because the row has no spare width: 126px + // is the measured column, and a 2px bar under the numbers costs none of it. + row.appendChild(el('i', 'heat')); + return row; +} + +/** Zero is off, and off shows as the placeholder dash rather than as a digit. */ + +/** Zero is off, and off shows as the placeholder dash rather than as a digit. */ +function showTarget(tgt, t) { + const n = Number(t); + tgt.value = Number.isFinite(n) && n > 0 ? String(n) : ''; +} + +/** + * Push new readings into a row without touching its DOM. + * + * The status card used to be rebuilt on every state push - roughly once a second - which + * destroyed whatever input the user was typing into. That is what made entering a + * temperature feel like a race: you had a sub-second window before the field was + * replaced under you. Values are written in place instead, the target field is left + * alone entirely while it has focus, and a target that has been sent but not yet echoed + * back is left alone as well. + */ + +/** + * Push new readings into a row without touching its DOM. + * + * The status card used to be rebuilt on every state push - roughly once a second - which + * destroyed whatever input the user was typing into. That is what made entering a + * temperature feel like a race: you had a sub-second window before the field was + * replaced under you. Values are written in place instead, the target field is left + * alone entirely while it has focus, and a target that has been sent but not yet echoed + * back is left alone as well. + */ +function updateTempRow(row, cur, target, pending) { + const t = Math.round(Number(target) || 0); + const c = Number(cur); + const tgt = row.querySelector('.tgt'); + row.querySelector('.cur').textContent = fmtTemp(cur); + + // A new target restarts the ramp the bar is measured against. Whoever set it - this + // page, the printer's own screen, or the G-code of a running print - the row reports + // the machine, so the machine's target is what it tracks. + if (String(t) !== tgt.dataset.target || row.dataset.start == null) { + tgt.dataset.target = String(t); + row.dataset.start = String(Number.isFinite(c) ? c : t); + } + + // Confirm, expire, or go on holding - the same three ends every control has, decided + // in pending.js rather than in four datasets here. + const p = pending.resolve(row.dataset.k, t); + setPend(row, p.state); + if (p.asked !== undefined) row.dataset.pendVal = String(p.asked); + else delete row.dataset.pendVal; + + // A focused field is the user's, and a sent value is theirs until the machine answers. + if (document.activeElement !== tgt && p.state !== 'sent') showTarget(tgt, t); + + const st = heatState(c, t); + // A ramp restarts when the direction does, not only when the target does. Measured + // on a real nozzle: asked for 40, it overshot to 48, and the bar - still measuring + // the climb from 26 - sat at 100% while the temperature was falling. + if (st !== row.dataset.heat && (st === 'heating' || st === 'cooling')) { + row.dataset.start = String(Number.isFinite(c) ? c : t); + } + row.dataset.heat = st; + const moving = st === 'heating' || st === 'cooling'; + row.querySelector('.heat').style.width = + moving ? `${(rampProgress(Number(row.dataset.start), c, t) * 100).toFixed(1)}%` : '0'; + row.title = rowTitle(row, st, t); +} + +/** The row says what it is doing in the tooltip, which is where there is room for words. */ + +/** The row says what it is doing in the tooltip, which is where there is room for words. */ +function rowTitle(row, st, target) { + const name = row.dataset.name || ''; + if (row.dataset.pend === '1') { + return `${name} — ${row.dataset.pendVal} °C sent, waiting for the printer`; + } + if (row.dataset.pend === 'lost') { + return `${name} — the printer did not take ${row.dataset.pendVal} °C`; + } + if (st === 'heating') return `${name} — heating to ${target} °C`; + if (st === 'cooling') { + return `${name} — ${target > 0 ? `cooling to ${target} °C` : 'cooling down'}`; + } + if (st === 'ready') return `${name} — at ${target} °C`; + return target > 0 ? `${name} — set to ${target} °C` : `${name} — off`; +} + +function fmtTemp(v) { + const n = Number(v); + return Number.isFinite(n) ? String(Math.round(n)) : '_'; +} + +/** One quick-setting tile: icon over a value, opening its own panel underneath. */ + +/** One quick-setting tile: icon over a value, opening its own panel underneath. */ +function tile(key, iconName, title, onOpen, control) { + const b = el('button', 'qtile'); + b.dataset.k = key; + b.title = title; + b.appendChild(icon(iconName)); + b.appendChild(control || el('b', null, '_')); + if (onOpen) b.onclick = () => onOpen(b); + return b; +} + +/** + * Readings and quick settings. + * + * Takes the context rather than seven unpacked values: everything here came from + * `state` anyway, and the popovers have to read it *live* - they are built once when + * the tile is wired and opened much later, so a value captured at build time would be + * whatever the machine said at boot. That used to be paid for with `root.__state`, a + * snapshot written onto the DOM on every frame for the closures to find. The context + * is the same idea with one copy instead of one per frame. + */ + +/** + * Readings and quick settings. + * + * Takes the context rather than seven unpacked values: everything here came from + * `state` anyway, and the popovers have to read it *live* - they are built once when + * the tile is wired and opened much later, so a value captured at build time would be + * whatever the machine said at boot. That used to be paid for with `root.__state`, a + * snapshot written onto the DOM on every frame for the closures to find. The context + * is the same idea with one copy instead of one per frame. + */ +export function renderStatusCard(root, ctx) { + const st = ctx.state; + const toolheads = st.toolheads(); + + // Rebuild only when the shape changes; otherwise write the new numbers into the DOM + // that is already there. Beyond keeping focus, this also keeps a tile alive while its + // popover is anchored to it - replacing the anchor would orphan the panel. + rebuildOn(root, `${toolheads.length}`, () => buildStatusCard(root, toolheads, ctx)); + + const rows = root.querySelectorAll('.status-row'); + toolheads.forEach((t, i) => { + if (rows[i]) updateTempRow(rows[i], t.temperature, t.target, ctx.pending); + }); + const bedRow = root.querySelector('.status-row[data-k="bed"]'); + const bed = st.bed(); + if (bedRow) updateTempRow(bedRow, bed.temperature, bed.target, ctx.pending); + + const q = (k) => root.querySelector(`.qtile[data-k="${k}"]`); + const setVal = (k, v) => { const n = q(k); if (n) n.querySelector('b').textContent = v; }; + const speed = st.speed(); + setVal('speed', `${speed.factorPct == null ? 100 : speed.factorPct}%`); + setVal('fan', `${st.fans().main}%`); + + const purifier = st.purifier(); + const purAbsent = !purifier.present || !purifier.powerDetected; + const pur = q('pur'); + if (pur) { + pur.querySelector('b').textContent = purAbsent ? '\u2014' : (purifier.modeName || '_'); + if (purAbsent) pur.dataset.absent = '1'; else delete pur.dataset.absent; + pur.title = purAbsent ? 'Air purifier - not connected' : 'Air purifier'; + } + + // The light shows what was asked for until the printer echoes it. Measured over three + // runs against the machine, the echo took 236ms, 2029ms and 620ms - and with the + // mirror driving the switch directly it reverted to its old position in two of the + // three, which is indistinguishable from a click that did nothing. + const ledTile = q('led'); + if (ledTile) { + const led = ctx.pending.resolve('led', st.led().on); + ledTile.querySelector('.switch') + .setAttribute('aria-checked', led.value ? 'true' : 'false'); + setPend(ledTile, led.state); + ledTile.title = led.state === 'sent' + ? 'Chamber light \u2014 sent, waiting for the printer' + : led.state === 'lost' + ? `Chamber light \u2014 the printer did not take ${led.asked ? 'on' : 'off'}` + : 'Chamber light'; + } +} + +/** + * Reflect a pending state onto an element for the stylesheet. + * + * The module's vocabulary is 'sent'/'lost'; the DOM's is `data-pend="1"|"lost"`, which + * the stylesheet and three test suites already key on. This is the one place the two + * meet, rather than the attribute spelling leaking into the state machine. + */ + +/** + * Reflect a pending state onto an element for the stylesheet. + * + * The module's vocabulary is 'sent'/'lost'; the DOM's is `data-pend="1"|"lost"`, which + * the stylesheet and three test suites already key on. This is the one place the two + * meet, rather than the attribute spelling leaking into the state machine. + */ +function setPend(node, state) { + if (!state) { delete node.dataset.pend; return; } + node.dataset.pend = state === 'sent' ? '1' : 'lost'; +} + +function buildStatusCard(root, toolheads, ctx) { + const handlers = ctx.handlers; + const st = ctx.state; + + toolheads.forEach((t, i) => { + root.appendChild(tempRow(`e${i}`, `iconExtruder${i + 1}`, LIMITS.nozzleTemp, + `Toolhead ${i + 1} temperature`, + (v) => handlers.setExtruderTemp(i, v), ctx.pending)); + }); + root.appendChild(tempRow('bed', 'iconHotBedTemperature', LIMITS.bedTemp, + 'Heated bed temperature', + (v) => handlers.setBedTemp(v), ctx.pending)); + + const tiles = el('div', 'qtiles'); + + tiles.appendChild(tile('speed', 'iconSpeed', 'Print speed', (anchor) => { + const speed = st.speed(); + openPopover(anchor, { + title: 'Print speed', width: 300, + build: (b) => b.appendChild(sliderRow('iconSpeed', 'Print Speed', + speed.factorPct == null ? 100 : speed.factorPct, + LIMITS.printSpeed.min, LIMITS.printSpeed.max, SPEED_STEP, + (v) => handlers.setSpeed(v), speedTicks())), + }); + })); + + tiles.appendChild(tile('fan', 'iconMainCooling', 'Fan control', (anchor) => { + const fans = st.fans(); + openPopover(anchor, { + title: 'Fan control', width: 330, + build: (b) => { + b.appendChild(sliderRow('iconMainCooling', 'Main Cooling Fan Speed', + fans.main, 0, 100, 1, (v) => handlers.setMainFan(v), FAN_TICKS)); + b.appendChild(sliderRow('iconAuxiliaryCooling', 'Assist Cooling Fan Speed', + fans.cavity, 0, 100, 1, (v) => handlers.setCavityFan(v), FAN_TICKS)); + }, + }); + })); + + tiles.appendChild(tile('pur', 'iconPurifier', 'Air purifier', (anchor) => { + const purifier = st.purifier(); + const purAbsent = !purifier.present || !purifier.powerDetected; + openPopover(anchor, { + title: 'Air purifier', width: 290, + build: (b) => { + // A control for hardware that is not attached is a claim the machine would + // refuse. The modes stay visible so the panel keeps its shape when one is + // plugged in, but they are plainly out of reach and the panel says why. + if (purAbsent) { + const e = el('div', 'pop-empty'); + e.appendChild(icon('iconPurifier')); + e.appendChild(el('span', null, 'No air purifier connected')); + b.appendChild(e); + } + const seg = el('div', 'mode-seg'); + if (purAbsent) seg.setAttribute('aria-disabled', 'true'); + Object.entries(PURIFIER_MODES).forEach(([v, label]) => { + const m = el('button', 'mode', label); + if (String(purifier.mode) === String(v)) m.classList.add('is-active'); + if (purAbsent) m.disabled = true; + else m.onclick = () => { handlers.setPurifierMode(Number(v)); closePopover(); }; + seg.appendChild(m); + }); + b.appendChild(seg); + }, + }); + })); + + // A binary needs no panel - the switch is the whole control. The click asks for the + // opposite of what is *shown*, which while a request is outstanding is the request + // rather than the machine: clicking twice in a second must undo the first click, not + // re-send it because the printer has not caught up yet. + const sw = el('button', 'switch'); + sw.setAttribute('role', 'switch'); + sw.onclick = (e) => { + e.stopPropagation(); + const shown = ctx.pending.resolve('led', st.led().on).value; + handlers.setLed(!shown); + }; + tiles.appendChild(tile('led', 'iconLed', 'Chamber light', null, sw)); + + root.appendChild(tiles); +} + +/** + * The shipped fan control's own shape: a titled slider with ticks and the value on the + * right. `step` is what keeps a knob from landing where the machine cannot go. + */ + +/** + * The shipped fan control's own shape: a titled slider with ticks and the value on the + * right. `step` is what keeps a knob from landing where the machine cannot go. + */ +function sliderRow(iconName, label, value, min, max, step, apply, ticks) { + const wrap = el('div', 'sctl'); + + const head = el('div', 'sctl-head'); + head.appendChild(icon(iconName)); + head.appendChild(el('span', 'sctl-title', label)); + const num = el('b', 'sctl-num', `${Math.round(value)}%`); + head.appendChild(num); + wrap.appendChild(head); + + // Granularity and labelling are different questions. A fan is continuous - step 1, so + // any value is reachable - but labelling every step printed 101 of them across the + // panel. `marks` is what gets drawn; `step` is only what the knob snaps to. + const marks = ticks && ticks.length ? ticks.slice() : []; + if (!marks.length) for (let v = min; v <= max; v += step) marks.push(v); + + const tickEls = el('div', 'sl-ticks'); + marks.forEach(() => tickEls.appendChild(el('i'))); + + const slot = el('div', 'sl-wrap'); + slot.appendChild(tickEls); + const range = document.createElement('input'); + range.type = 'range'; + range.min = String(min); range.max = String(max); range.step = String(step); + range.value = String(value); + range.setAttribute('aria-label', label); + // repaint while dragging, commit once on release: each commit is a round trip + range.oninput = () => { num.textContent = `${range.value}%`; }; + range.onchange = () => apply(Number(range.value)); + slot.appendChild(range); + wrap.appendChild(slot); + + const labels = el('div', 'sl-labels'); + marks.forEach((v) => labels.appendChild(el('span', null, `${v}%`))); + wrap.appendChild(labels); + return wrap; +} + +/* ---- control: motion ------------------------------------------------ */ + +// The jog wheel's three bands, outermost first. The ring IS the step size, so there is +// no separate selector and no hidden state: the target you click is the distance. + +// The jog wheel's three bands, outermost first. The ring IS the step size, so there is +// no separate selector and no hidden state: the target you click is the distance. +const JOG_STEPS = [10, 1, 0.1]; + +const BED_STEPS = [10, 1, 0.1]; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function svgEl(tag, attrs) { + const n = document.createElementNS(SVG_NS, tag); + Object.entries(attrs || {}).forEach(([k, v]) => n.setAttribute(k, String(v))); + return n; +} + +/** + * The XY pad: four quadrants by three concentric bands. + * + * Geometry is computed rather than drawn so the bands keep their taper at any size. + * Sectors are real annular paths, so each has its own hit area - a CSS approximation + * with rectangles would put the corners in the wrong quadrant. + */ + +/** + * The XY pad: four quadrants by three concentric bands. + * + * Geometry is computed rather than drawn so the bands keep their taper at any size. + * Sectors are real annular paths, so each has its own hit area - a CSS approximation + * with rectangles would put the corners in the wrong quadrant. + */ +function jogWheel(handlers, head, activeTool) { + const R_OUT = 128, SZ = (R_OUT + 8) * 2, C = SZ / 2; + const home = Math.round(R_OUT * 0.246); + const span = R_OUT - home; + const w = [span * 8 / 21, span * 7 / 21, span * 6 / 21]; + const radii = [R_OUT, R_OUT - w[0], R_OUT - w[0] - w[1], R_OUT - w[0] - w[1] - w[2]]; + + const pt = (r, deg) => { + const a = (deg * Math.PI) / 180; + return [C + r * Math.cos(a), C + r * Math.sin(a)]; + }; + const sector = (a1, a2, r1, r2) => { + const [x1, y1] = pt(r2, a1), [x2, y2] = pt(r2, a2); + const [x3, y3] = pt(r1, a2), [x4, y4] = pt(r1, a1); + return `M${x1.toFixed(1)} ${y1.toFixed(1)}A${r2.toFixed(1)} ${r2.toFixed(1)} 0 0 1 ` + + `${x2.toFixed(1)} ${y2.toFixed(1)}L${x3.toFixed(1)} ${y3.toFixed(1)}` + + `A${r1.toFixed(1)} ${r1.toFixed(1)} 0 0 0 ${x4.toFixed(1)} ${y4.toFixed(1)}Z`; + }; + + const GAP = 3; + const DIRS = [ + { a1: -135, a2: -45, axis: 'Y', sign: +1, label: 'Y' }, + { a1: -45, a2: 45, axis: 'X', sign: +1, label: 'X' }, + { a1: 45, a2: 135, axis: 'Y', sign: -1, label: '\u2212Y' }, + { a1: 135, a2: 225, axis: 'X', sign: -1, label: '\u2212X' }, + ]; + + const svg = svgEl('svg', { class: 'wheel', viewBox: `0 0 ${SZ} ${SZ}`, + role: 'group', 'aria-label': 'XY jog' }); + const homed = head.allHomed; + + DIRS.forEach((d) => { + JOG_STEPS.forEach((step, band) => { + const path = svgEl('path', { + class: `sect ring${band}`, + d: sector(d.a1 + GAP, d.a2 - GAP, radii[band + 1], radii[band]), + }); + const t = svgEl('title', {}); + t.textContent = homed === false + ? `Home the axes before jogging (${d.label} ${step} mm)` + : `Jog ${d.label} ${step} mm`; + path.appendChild(t); + if (homed === false) path.setAttribute('data-blocked', '1'); + else path.addEventListener('click', () => handlers.jog(d.axis, d.sign * step, activeTool)); + svg.appendChild(path); + }); + }); + + // the axis letter belongs inside the band it labels, not floating outside the wheel + DIRS.forEach((d) => { + const [x, y] = pt((radii[0] + radii[1]) / 2, (d.a1 + d.a2) / 2); + const t = svgEl('text', { class: 'axis', x: x.toFixed(0), y: y.toFixed(0) }); + t.textContent = d.label; + svg.appendChild(t); + }); + [-45, 135].forEach((ang) => { + JOG_STEPS.forEach((step, band) => { + const [x, y] = pt((radii[band] + radii[band + 1]) / 2, ang); + const t = svgEl('text', { class: 'step', x: x.toFixed(0), y: y.toFixed(0), + transform: `rotate(-45 ${x.toFixed(0)} ${y.toFixed(0)})` }); + t.textContent = String(step); + svg.appendChild(t); + }); + }); + + const hc = svgEl('circle', { class: 'home', cx: C, cy: C, r: home }); + const ht = svgEl('title', {}); + ht.textContent = homed === false ? 'Home all axes (G28) - required before jogging' + : 'Home all axes (G28)'; + hc.appendChild(ht); + hc.addEventListener('click', () => handlers.home()); + if (homed === false) hc.setAttribute('data-invite', '1'); + svg.appendChild(hc); + + const hs = home * 0.46; + svg.appendChild(svgEl('path', { + class: 'homeicon', + d: `M${C} ${C - hs * 0.82}l-${hs} ${hs * 0.82}v${hs * 1.14}h${hs * 0.64}` + + `v-${hs * 0.71}h${hs * 0.71}v${hs * 0.71}h${hs * 0.64}v-${hs * 1.14}z`, + })); + return svg; +} + +/** + * Toolhead picker and motion. + * + * `activeTool` - which head jog and extrude are aimed at - is the user's, not the + * machine's, so it lives in the page's state and arrives on the context. It used to be + * a module-level `let` in this file, which meant the renderer held state the rest of + * the page could not see and re-called itself argument-less to keep it; the `head` + * snapshot existed only to survive that self-call, and is now read live. + */ + +/** + * Toolhead picker and motion. + * + * `activeTool` - which head jog and extrude are aimed at - is the user's, not the + * machine's, so it lives in the page's state and arrives on the context. It used to be + * a module-level `let` in this file, which meant the renderer held state the rest of + * the page could not see and re-called itself argument-less to keep it; the `head` + * snapshot existed only to survive that self-call, and is now read live. + */ +export function renderControlMain(root, ctx) { + const handlers = ctx.handlers; + const toolheads = ctx.state.toolheads(); + const head = ctx.state.toolhead(); + const activeTool = ctx.store.activeTool; + const machineTool = head.activeIndex != null ? head.activeIndex : null; + + // Everything below is decided by these four, and nothing else in this panel moves - + // so this is the whole of what a repaint has to react to. It used to rebuild on every + // frame, which meant the jog wheel's twenty-four SVG sectors were thrown away and + // recreated about once a second, under whatever the pointer was over. + const sig = `${toolheads.length}:${activeTool}:${machineTool}:${head.allHomed}`; + if (!rebuildOn(root, sig, () => build(root, ctx, toolheads, head, + activeTool, machineTool, handlers))) return; +} + +function build(root, ctx, toolheads, head, activeTool, machineTool, handlers) { + + /* --- toolhead picker, left of the wheel --- */ + const pick = el('div', 'pick-col'); + const boxes = el('div', 'pick-row'); + const count = Math.max(toolheads.length, 4); + for (let i = 0; i < count; i++) { + const b = el('button', 'pick-box'); + if (i === activeTool) b.classList.add('is-active'); + if (i === machineTool) b.dataset.live = '1'; + b.title = i === machineTool + ? `Toolhead ${i + 1} - target for jog and extrude, and live on the machine` + : `Target jog and extrude at toolhead ${i + 1} (does not change the tool)`; + b.appendChild(icon(`iconExtruder${i + 1}`)); + // selection only: changing the live tool is a separate, deliberate action + b.onclick = () => handlers.selectTool(i); + boxes.appendChild(b); + } + pick.appendChild(boxes); + + // One button, and what it does depends on the toolhead selected above it - exactly + // what the shipped page does, where each toolhead carries a button reading "Park + // Extruder" when that head is ACTIVATE and "Pick Extruder" when it is not. + // + // Two buttons forced an asymmetry that had to be explained: Pick followed the + // selection while Park followed the machine, so neither could name its target without + // a tooltip. With one button the target is always the head you have selected. + const acts = el('div', 'pick-acts'); + const isLive = activeTool === machineTool; + const act = el('button', 'btn primary', isLive ? 'Park extruder' : 'Pick extruder'); + act.title = isLive + ? `Park toolhead ${activeTool + 1}, leaving none engaged` + : `Make toolhead ${activeTool + 1} live - blocks while the gantry moves`; + act.onclick = () => (isLive ? handlers.parkTool(activeTool) + : handlers.pickTool(activeTool)); + acts.appendChild(act); + pick.appendChild(acts); + + const ej = el('div', 'ejog'); + const up = el('button', 'ebtn', '\u25B2'); + up.title = `Extrude on toolhead ${activeTool + 1}`; + up.onclick = () => handlers.jog('E', +1, activeTool); + const cap = el('span', 'ecap', 'Extrude'); + const dn = el('button', 'ebtn', '\u25BC'); + dn.title = `Retract on toolhead ${activeTool + 1}`; + dn.onclick = () => handlers.jog('E', -1, activeTool); + ej.appendChild(up); ej.appendChild(cap); ej.appendChild(dn); + pick.appendChild(ej); + root.appendChild(pick); + + /* --- the wheel, and Z under it --- */ + const motion = el('div', 'motion-col'); + motion.appendChild(jogWheel(handlers, head, activeTool)); + + // Z moves the bed on this machine, so it is a row that says "Bed" rather than a + // third axis on a wheel that would imply the toolhead travels. + const bedRow = el('div', 'bed-row'); + // Klipper refuses a move on an unhomed axis and says nothing this page can see, so a + // button that looks live and does nothing is worse than one plainly disabled. + const blocked = head.allHomed === false; + + // On this machine the bed IS the Z axis, and Z measures the nozzle-to-bed gap - so + // raising the BED is a NEGATIVE Z move. The printer's own config settles it rather + // than convention: stepper_z homes positive to position_endstop 275, and PRINT_END + // drives to Z200 to get clearance, both of which only make sense if larger Z means a + // wider gap. Sending Z+ for an up arrow moved the bed away from the nozzle. + // + // dir: +1 raises the bed toward the nozzle, -1 lowers it away. + const bedBtn = (v, dir) => { + const b = el('button', 'bed-btn', `${dir > 0 ? '\u2191' : '\u2193'} ${v}`); + const dz = -dir * v; + b.disabled = blocked; + b.title = blocked + ? 'Home the axes first - the printer refuses a move until Z is homed' + : `Move the bed ${dir > 0 ? 'up, toward the nozzle' : 'down, away from the nozzle'}` + + ` by ${v} mm (Z${dz > 0 ? '+' : ''}${dz})`; + if (!blocked) b.onclick = () => handlers.jog('Z', dz, activeTool); + return b; + }; + BED_STEPS.forEach((v) => bedRow.appendChild(bedBtn(v, +1))); + bedRow.appendChild(el('span', 'bed-lab', 'Bed')); + [...BED_STEPS].reverse().forEach((v) => bedRow.appendChild(bedBtn(v, -1))); + motion.appendChild(bedRow); + root.appendChild(motion); +} + +/* ---- printing task -------------------------------------------------- */ diff --git a/resources/web/device_page/js/views/device-control/filament/filament-commands.js b/resources/web/device_page/js/views/device-control/filament/filament-commands.js new file mode 100644 index 00000000000..4364d988d7d --- /dev/null +++ b/resources/web/device_page/js/views/device-control/filament/filament-commands.js @@ -0,0 +1,650 @@ +/* + * filament-commands.js - what the Filament panel can ask the machine to do. + * + * Two families, and they reach the printer by two different roads: + * + * the head's own filament `print_task_config`, over sw_UpdateMachineFilamentInfo - + * the same object the print-processing popup edits when + * filaments are assigned to a job + * everything about an ACE a plain G-code macro over sw_SendGCodes. Every control on + * the panel is one, read out of `printer.gcode.help` on the + * machine, so none of it needed a new bridge command + * + * Two rules apply to every macro below without exception, and both were learned the + * expensive way: + * + * Nothing is awaited. An instant `ok` is indistinguishable from success - + * ACE_BG_UNLOAD's own help says ~3 min - so what was asked for is held by + * core/pending.js and confirmed against machine state, never against the ack. + * + * `ace` is not on the subscription, so the mirror does not move on its own. Anything + * that changes the ACE re-reads it: confirmAce() below is the whole of that, and + * without it a pending value would sit until it timed out and reported itself lost. + * + * check_coverage.py reads the CMD references out of this file to answer "can a user + * reach this command", and attributes them to the filament panel because this is the + * module that panel is handed. That makes the attribution a fact about the imports + * rather than a promise in a declaration. + */ +'use strict'; + +import { CMD, TASK_CONFIG, cssColor } from '../../../../../shared/js/protocol.js'; +import { isTimeout } from '../../../../../shared/js/sswcp.js'; +import { machineActivity, isBusy } from '../../../../../shared/js/activity.js'; +// Everything about the ACE comes from one module - see shared/js/multiACE.js for why. +import { ACE, FEEDER, DRY_MINUTES_PER_HOUR, aceUnitId, aceLine, aceOverridesUrl, + parseAceOverrides, aceGlyph, channelStep, channelWord, + gcodeStoreUrl, lastPrinterError, aceModeChange, aceModeRefusal } + from '../../../../../shared/js/multiACE.js'; +import { openDialog, openBlockingDialog, numberField, toggleField } + from '../../../core/overlay.js'; +import { el } from '../../../core/dom.js'; + + +/** + * When to look again after asking the ACE for something. + * + * A source switch lands in well under a second; a load is minutes. These are re-reads, + * not a poll: they stop at the last one whether or not anything was confirmed, and the + * heartbeat keeps the panel fresh after that. + */ +const CONFIRM_AT = [400, 1200, 3000, 6000, 12000]; + +export function create(deps) { + // `bridge` is deliberately NOT destructured: it does not exist yet when these are + // built - boot() decides between the real host and the simulator - so it is reached + // through deps each time rather than captured as null once. + const { state, store, pending, session, send, setStatus, render } = deps; + + const line = aceLine; + + /** Look at the ACE again, a few times, because nothing pushes it. */ + function confirmAce() { + CONFIRM_AT.forEach((ms) => setTimeout(() => { session.refreshAce().then(render); }, ms)); + } + + /** + * Send one macro and hold what it asked for until the machine agrees. + * + * `key`/`value` are optional: a setting the ACE object does not report back - a flush + * length, a Spoolman URL - has nothing to confirm against, and saying so by leaving + * them out is better than inventing a mirror for it. + */ + function macro(script, label, key, value) { + const sent = send(CMD.SEND_GCODES, { script }, label); + if (key !== undefined) pending.track(key, value, sent); + else sent.then((ok) => { if (ok) setStatus(label); }); + confirmAce(); + return sent; + } + + /* ---- what is IN each bay ----------------------------------------- */ + + /** + * Read multiACE's own record of what is in each bay. + * + * The `ace` Klipper object does not carry it. Every raw slot on the measured machine + * reads `{material:"", brand:"", rfid:0}` — these spools have no tags — while + * multiACE's web UI names all four, because it keeps them in an override store and + * merges them into `/multiace/api/state`. Orca's own AceMmuProvider polls that + * endpoint from C++ and therefore sees filament this panel drew as `?`. + * + * `/multiace/api/state` is not reachable from a browser: nginx serves `/multiace/` + * with no CORS header. The STORE is: it is a file under Moonraker's `config` root, and + * Moonraker on :7125 reflects the Origin — the same server this page already fetches + * camera frames and job thumbnails from. + * + * Fails quietly on purpose. No file, no multiACE, no route to the printer, or a + * Moonraker with auth turned on all mean the same thing to the panel: nothing to merge, + * and a bay nobody has named goes on being drawn as a bay nobody has named. + */ + async function syncBays() { + // With no printer there is nothing to answer an HTTP GET, so the simulator does - + // the same seam the camera's frames use. + if (deps.mock && deps.mock.aceOverrides) { + store.aceBays = deps.mock.aceOverrides(); + render(); + return store.aceBays; + } + const url = aceOverridesUrl(store.device); + if (!url) return null; + try { + const r = await fetch(url, { cache: 'no-store' }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + store.aceBays = parseAceOverrides(await r.json()); + } catch (e) { + store.aceBays = null; + } + render(); + return store.aceBays; + } + + /* ---- what feeds a head ------------------------------------------- */ + + /** + * Three macros, one control. + * + * ACE_SET_HEAD_ACE is the one that has to clear the others: the machine resolves a + * head as manual, then feeder, then ACE, so binding a head to a unit while + * `head_feeder` is still true changes nothing anyone can see. + */ + function setSource(i, source) { + const t = `Toolhead ${i + 1}`; + if (source === 'manual') { + return macro(line(ACE.SET_HEAD_MANUAL, { HEAD: i, ENABLE: 1 }), + `${t}: hand-fed`, `ace-src-${i}`, 'manual'); + } + if (source === 'feeder') { + return macro(`${line(ACE.SET_HEAD_MANUAL, { HEAD: i, ENABLE: 0 })}\n` + + `${line(ACE.SET_HEAD_FEEDER, { HEAD: i, ENABLE: 1 })}`, + `${t}: stock feeder`, `ace-src-${i}`, 'feeder'); + } + const unit = Number(String(source).slice(4)); + return macro(`${line(ACE.SET_HEAD_MANUAL, { HEAD: i, ENABLE: 0 })}\n` + + `${line(ACE.SET_HEAD_FEEDER, { HEAD: i, ENABLE: 0 })}\n` + + `${line(ACE.SET_HEAD_ACE, { HEAD: i, ACE: unit })}`, + `${t}: ACE ${aceUnitId(unit)}`, `ace-src-${i}`, source); + } + + /** + * The mode, and the three different things sending it can do. + * + * This is the one control on the panel whose SUCCESS arrives as a FAILURE, so it is the + * one that cannot go through `macro()`. + * + * multi <-> head live, and ordinary. Held pending against `ace.mode`, which moves + * within a read - measured 2026-09-01 on a machine with filament in + * three heads, because that transition has no unload guard. + * normal <-> either swaps three Klipper extras on disk and then RAISES on purpose: + * `gcmd.error('... Please reboot the printer to activate!')`. The + * request is therefore NOT sent through send(), which would report + * the raise as a failed command. And it is not tracked pending + * against `ace.mode` either: that field will not move until the + * machine is restarted, so a pending value would sit out its clock + * and report itself lost on a switch that worked. What reports it + * instead is the saved `ace__mode` disagreeing with `ace.mode`, + * which the pill draws. + * refused with filament in any head the macro's guard prints two `//` notes + * and returns, so the RPC answers `ok` for a switch that did not + * happen. The reason exists only on the console. + * + * `HEAD=` is passed for head mode, which the panel never used to send: it writes + * `head_feeder[h] = (h != n)` for all four, and without it the mode keeps whatever + * wiring was there. + */ + async function setAceMode(mode, head) { + const from = state.ace().mode; + const args = { MODE: mode }; + if (mode === 'head' && head != null) args.HEAD = head; + const script = line(ACE.SET_MODE, args); + const label = `ACE mode: ${mode}`; + if (aceModeChange(from, mode).live) { + macro(script, label, 'ace-mode', mode); + return true; + } + + let raised = null; + try { + await deps.bridge.request(CMD.SEND_GCODES, { script }); + } catch (e) { + raised = (e && e.message) ? String(e.message) : 'refused'; + } + confirmAce(); + + // An `ok` here is not a yes: the guard prints and returns. Only the console knows. + const said = raised ? null : aceModeRefusal(await gcodeStore(20)); + if (said) { + openDialog({ + title: 'The printer refused', + build: (bd) => { + const box = el('div', 'verb-cmd'); + said.forEach((s) => box.appendChild(el('div', null, s))); + bd.appendChild(box); + }, + confirmLabel: 'Close', + cancel: false, + onConfirm: () => true, + }); + return false; + } + // A raise that says `reboot` is the success. Anything else really is a failure. + if (raised && !/reboot/i.test(raised)) { + setStatus(`${label} failed: ${raised}`, 'err'); + return false; + } + setStatus(raised || `Switched to ${mode} mode. Restart the printer to activate.`); + await session.refreshAce(); + render(); + return true; + } + + /* ---- loading, unloading, swapping -------------------------------- */ + /* + * There were three functions here - loadBay, loadHead, unloadHead - one per control that + * could send one. They are gone, and runVerb() below is what replaced them: the verb + * list decides WHICH macro applies in the state the head is in, so a caller that picks + * the function has already made that decision somewhere else. Two places deciding the + * same thing is how a Load ends up offered on a head that is already loaded. + * + * The conformance suite is what said so, and it said it the moment the last caller went: + * "nothing calls filament.loadBay, loadHead, unloadHead - either wire a control to it or + * delete it." + */ + + const unloadAllHeads = () => + macro(ACE.UNLOAD_ALL, 'Unloading every toolhead'); + + /** + * Send one verb from `aceVerbs()`, whichever surface it was chosen on. + * + * The list already carries the line - the sheet and the menu both show it before it is + * sent - so this sends THAT rather than rebuilding it from the verb's name. A control + * that displays one command and sends another is the bug this shape makes impossible. + * + * The pending key is the head's own load state, because what confirms any of these is + * `head_source[n]` changing - and none of them is awaited: ACE_BG_UNLOAD's own help + * says ~3 min, and an instant `ok` is indistinguishable from success. + */ + /* + * Every macro a verb is allowed to send, named here rather than trusted from the verb. + * + * `aceVerbs()` builds the line and this sends it, which is what stops a control showing + * one command and sending another - but it also means the macro name appears nowhere in + * this module, and check_coverage.py reads exactly that to answer "can a user reach + * this command". It said UNACCOUNTED for both background verbs and it was right: the + * panel could send them and nothing here claimed them. + * + * So the claim is made by being true. A verb whose macro is not in this set is not sent, + * which is a real gate on a surface where one of the macros purges ~60 mm onto the bed. + */ + const VERB_MACROS = new Set([ + // ACE_SWAP_HEAD is NOT here and Swap is still offered: a swap someone asks for is an + // ACE_UNLOAD_HEAD then an ACE_LOAD_HEAD, both of which are. The reason is in + // aceVerbs() - that macro is the print's, and it opens with a Z hop. + ACE.LOAD_HEAD, ACE.UNLOAD_HEAD, + ACE.BG_SWAP, ACE.BG_UNLOAD, ACE.BG_SET_HEAD, + // The U1's own, for a head with no ACE behind it to send an ACE macro to. + FEEDER.FEED, + ]); + + /* + * A verb that moves filament takes the machine over, so the panel says so and waits. + * + * Three things this has to get right, and the Control panel's runToolAction learned all + * three the expensive way: + * + * 1. `sw_SendGCodes` does not return until Klipper has finished, and the bridge gives + * up after 15s. A load HOMES first - measured 14.7s to `xy` and 31s from cold - so + * the request rejects while the printer is working, and the panel reported + * `Toolhead 1: load failed: sw_SendGCodes timed out after 15000ms` for a load that + * was running. A timeout is not a refusal. The request is not awaited. + * 2. The ack only says the command was queued; machine state is what says it worked. + * 3. The machine says what it is DOING, and here it says it twice: `channel_state` + * names the step (`unload_heating` -> "Heat nozzle") and `action_code` names the + * operation ("Auto Loading"). The wait shows the finer of the two. + * + * It BLOCKS, and that is the point rather than a side effect: a non-background swap is + * about three minutes during which the machine can do nothing else, so a second verb + * started underneath it is not a queue, it is a collision. A background verb does not + * block - not blocking is its whole purpose. + */ + const FILAMENT_HARD_CAP_MS = 10 * 60 * 1000; + const QUIET_MS = 90 * 1000; + /* + * How long the machine gets to START, which is a different question from how long it + * gets to keep going, and a much shorter one. + * + * A load HOMES first and that is the slow part - 31s from cold - but homing is reported: + * `action_code` goes to 832 within a second or two, so `sawBusy` is set long before the + * homing ends. Nothing busy and nothing on the channel after this means nothing started, + * and the 90s quiet window was spending 90 seconds on a stuck dialog to say so. + */ + const NO_START_MS = 25 * 1000; + + /* + * What the printer said, when it said no. + * + * `sw_SendGCodes` answered `ok` for a swap that never ran: multiACE printed + * `!! Must home Z axis first` and set `last_swap_result.status` to `error`, and the RPC + * reply carried neither. Moonraker's console history has the line, on the same host and + * port this page already reads the override store from. + * + * That particular sentence is history - the panel does not send the macro that raised + * it any more (see the Swap verb in aceVerbs) - but the shape of the failure is not: a + * macro that declines is an `ok` here, and the console is the only place the reason + * exists. + */ + async function gcodeStore(n) { + // With no printer there is nothing to answer an HTTP GET, so the simulator does - the + // same seam the override store and the camera's frames use. + if (deps.mock && deps.mock.gcodeStore) return deps.mock.gcodeStore(n); + try { + const url = gcodeStoreUrl(store.device, n); + if (!url) return null; + const r = await fetch(url, { cache: 'no-store' }); + return r.ok ? await r.json() : null; + } catch (e) { return null; } // an unreachable Moonraker is not the fault here + } + + async function printerSaid() { return lastPrinterError(await gcodeStore(30)); } + + function runFilamentAction({ title, script, head, waiting, kind }) { + const dlg = openBlockingDialog({ title, message: waiting }); + let refused = null; + // The reply is not read: an `ok` is not a yes on this machine, machine state is what + // says it worked, and the trace pane already carries the packet for anyone debugging. + deps.bridge.request(CMD.SEND_GCODES, { script }) + .catch((e) => { if (!isTimeout(e)) refused = e; }); + + const started = Date.now(); + let quiet = started + QUIET_MS; + let last = null; + let sawBusy = false; + // multiACE stamps every attempt into `last_swap_result`. Holding the one that was + // there BEFORE is what makes a new one mean this attempt rather than a previous one. + const wasSwap = JSON.stringify((state.ace().lastSwap) || null); + + const giveUp = async (fallback) => { + const said = await printerSaid(); + dlg.fail(said ? `The printer stopped: ${said}` : fallback); + render(); + }; + + return (async () => { + for (;;) { + if (refused) { dlg.fail(`The printer refused the command: ${refused.message}`); return; } + + const feed = (state.filaments()[head] || {}).feed; + const step = channelStep(feed && feed.channelState, kind); + if (step && step.failed) { + // A failure is a firmware state and is reported as one - translating it would + // be this panel's opinion of what happened. + dlg.fail(`${channelWord(step.state) || 'The printer stopped'}.`); + render(); + return; + } + if (sawBusy && step && step.done) { dlg.close(); render(); return; } + + // multiACE's own verdict, which arrives in a second rather than in twenty-five. + // + // Only `ACE_SWAP_HEAD` writes `last_swap_result`, and the panel stopped sending + // it - so what this now catches is the PRINT's own swap failing underneath a verb + // someone started, which dooms that verb too. Kept for that, not for ours. + const ls = state.ace().lastSwap; + if (ls && JSON.stringify(ls) !== wasSwap && ls.status && ls.status !== 'ok') { + await giveUp(`The ${kind} failed.`); + return; + } + + const act = state.activity(); + // channel_state first: it names the step inside the operation, where action_code + // names only the operation. And where neither has a name for what it is doing, + // the machine's OWN WORD rather than a sentence about waiting: a dialog reading + // `wait_insert` says which state nothing is moving in, and one reading "Asking the + // printer..." for ninety seconds says only that the panel has stopped talking. + const raw = feed && feed.channelState; + const label = (step && step.label) ? `${step.label} (${step.at + 1}/${step.total})` + : machineActivity(act) || channelWord(raw); + if (label && label !== last) { dlg.update(label); last = label; } + + if ((step && step.at != null) || isBusy(act)) { + sawBusy = true; + quiet = Date.now() + QUIET_MS; + } + + if (!sawBusy && Date.now() - started > NO_START_MS) { + // An `ok` is not a yes on this machine, so nothing having started is a real + // outcome - and the reason for it is on the printer's console, not in the reply. + const where = channelWord(raw); + await giveUp(where ? `Nothing started. The toolhead is ${where.toLowerCase()}.` + : 'Nothing started.'); + return; + } + if (Date.now() > quiet) { + dlg.fail('The printer stopped reporting. It may have finished.'); + render(); + return; + } + if (Date.now() - started > FILAMENT_HARD_CAP_MS) { + dlg.fail('Giving up after ten minutes. The printer may still be working.'); + return; + } + await new Promise((r) => setTimeout(r, 400)); + // `ace` is not on the stream, so it has to be asked for - and this is the one + // moment it changes most. + await session.refreshAce(); + } + })(); + } + + function runVerb(v) { + // EVERY macro the verb would send, not just the one it is named for. A swap sends + // two, and checking one of them is not a gate. + if (!(v.macros || [v.macro]).every((m) => VERB_MACROS.has(m))) { + setStatus(`${v.name} is not available`, 'err'); + return Promise.resolve(false); + } + // `HEAD=` is multiACE's word for it and `EXTRUDER=` is the U1's, and the same + // toolhead is meant by both. Reading only the first left a feeder verb addressing + // nothing: an untitled dialog with no channel to follow, which is what "Load" with + // no toolhead and a permanent "Asking the printer..." looked like. + const head = v.args && (v.args.HEAD != null ? v.args.HEAD : v.args.EXTRUDER); + const slot = v.args && v.args.SLOT; + const unit = v.args && v.args.ACE; + // Named end to end, because this is the title of a dialog someone will be looking at + // for three minutes: which bay, and which head it is going to. + const bay = (slot != null && unit != null) ? `${aceUnitId(unit)}${slot + 1}` : null; + const label = head == null ? v.name + : bay ? `${v.name} ${bay} \u2192 Toolhead ${head + 1}` + : `${v.name} \u2014 Toolhead ${head + 1}`; + const value = (slot != null && unit != null) ? `${aceUnitId(unit)}${slot + 1}` + : /Unload/.test(v.name) ? '' : 'loading'; + // A background verb runs while the printer gets on with something else, so it must + // not block; everything else takes the machine over and says so. + if (v.bg) return macro(v.cmd, label, `ace-load-${head}`, value); + + const kind = /^Swap/.test(v.name) ? 'swap' : /^Unload/.test(v.name) ? 'unload' : 'load'; + pending.track(`ace-load-${head}`, value, null); + confirmAce(); + return runFilamentAction({ + title: label, + script: v.cmd, + head, + kind, + waiting: 'Asking the printer\u2026', + }); + } + + /** + * Declare a head background-capable. + * + * Its own help says what the claim MEANS: the dock below that head is open, and the + * cold-pull purges ~60 mm through it. So this is asked for on its own rather than + * folded into the verb that needs it - the panel offers the macro, and sending it is a + * separate decision from running a swap with it. + */ + const declareBgHead = (i) => + macro(aceLine(ACE.BG_SET_HEAD, { HEAD: i, ENABLE: 1 }), + `Toolhead ${i + 1}: background swap enabled`); + + /* ---- the dryer ---------------------------------------------------- */ + + /** + * The panel offers hours and the macro takes MINUTES. + * + * Measured: `DURATION=3` came back as `duration: 180` seconds and `DURATION=240` as + * `14400`. Sending the panel's `4` unconverted asked the machine to dry for four + * minutes - and it answered `ok`, which is why this was worth an experiment rather + * than a reading of the help text, which says only `[DURATION=]`. + */ + const startDrying = (unit, temp, hours) => + macro(line(ACE.DRY, { ACE: unit, TEMP: temp, + DURATION: Math.round(hours * DRY_MINUTES_PER_HOUR) }), + `ACE ${aceUnitId(unit)}: drying at ${temp} °C for ${hours} h`, + `ace-dry-${unit}`, true); + + // ACE_STOP_DRYING takes the unit. ACED__DRY_STOP stops "the current ACE", which on a + // machine with two units is whichever is active rather than the one that was pressed. + const stopDrying = (unit) => + macro(line(ACE.DRY_STOP, { ACE: unit }), + `ACE ${aceUnitId(unit)}: dryer stopped`, `ace-dry-${unit}`, false); + + /** + * Humidity-controlled drying, per unit. + * + * `THRESHOLD=` was a guess and the machine took it, answered `ok`, and changed + * nothing - the reading in `auto_dry` was untouched. The arguments are `ENABLE=0|1` + * and `RH_START=<%>`, settled by sending each and reading the object back. + */ + const setAutoDry = (unit, threshold) => + macro(line(ACE.SET_AUTO_DRY, threshold + ? { ACE: unit, ENABLE: 1, RH_START: threshold } + : { ACE: unit, ENABLE: 0 }), + threshold ? `ACE ${aceUnitId(unit)}: dry automatically above ${threshold} %` + : `ACE ${aceUnitId(unit)}: automatic drying off`); + + /* ---- the settings a person sets once ------------------------------ */ + /* + * These live behind the panel header's overflow rather than on the face of the panel, + * where they would bury the four things that change daily. + * + * Three of the four ARE reported back - `confirm_commands`, `spoolman_url`, + * `spoolman_auto` and `purge_matrix` are all in the `ace` object - so each dialog opens + * on the machine's own value. They were written as write-only on the assumption that + * none of them was readable, which was wrong, and a dialog that sets a value it could + * have shown is one that asks you to remember what you chose. + * + * The flush LENGTH is the one that genuinely is not there: `purge_matrix` says whether + * the per-pair stamps from the slicer's flush matrix are honoured, and the length + * itself lives in the config. That field says so rather than pretending. + */ + + const note = (b, text) => { + const p = el('p', 'ms-note', text); + b.appendChild(p); + }; + + function setPurge() { + const now = state.ace().settings; + let mm; let matrix; + openDialog({ + title: 'Flush length', + build: (b) => { + note(b, 'Flush for the next swap or load. 0 uses the stock 80 mm. ' + + 'Not reported back by the machine.'); + mm = numberField(b, { label: 'Length', value: 0, min: 0, max: 400, unit: 'mm' }); + matrix = toggleField(b, { label: 'Honour the slicer’s flush matrix', + checked: now.purgeMatrix }); + }, + confirmLabel: 'Set', + onConfirm: () => macro(line(ACE.SET_PURGE, { LENGTH: Number(mm.value), + MATRIX: matrix.checked ? 1 : 0 }), + `Flush length: ${Number(mm.value) || 'stock default'}`), + }); + } + + function setConfirmCommands() { + const now = state.ace().settings; + let on; + openDialog({ + title: 'Confirmations', + build: (b) => { + note(b, 'The printer asks on its own screen before a load or unload.'); + on = toggleField(b, { label: 'Confirm before load and unload', + checked: now.confirmCommands }); + }, + confirmLabel: 'Set', + onConfirm: () => macro(line(ACE.SET_CONFIRM, { ENABLE: on.checked ? 1 : 0 }), + on.checked ? 'Confirmations on' : 'Confirmations off'), + }); + } + + function setSpoolman() { + const now = state.ace().settings; + let url; let auto; + openDialog({ + title: 'Spoolman', + build: (b) => { + note(b, 'Where the ACE looks up spool weights. ' + + (now.bound ? `${now.bound} bay${now.bound > 1 ? 's' : ''} bound.` + : 'No bay is bound.')); + const row = el('label', 'field'); + row.appendChild(el('span', 'field-label', 'URL')); + const wrap = el('div', 'field-row'); + url = document.createElement('input'); + url.type = 'url'; + url.value = now.spoolmanUrl; + url.placeholder = 'http://spoolman.local:7912'; + wrap.appendChild(url); + row.appendChild(wrap); + b.appendChild(row); + auto = toggleField(b, { label: 'Sync automatically', checked: now.spoolmanAuto }); + }, + confirmLabel: 'Set', + onConfirm: () => macro(line(ACE.SET_SPOOLMAN, + { URL: url.value.trim(), AUTO: auto.checked ? 1 : 0 }), + 'Spoolman updated'), + }); + } + + const clearHeads = () => macro(ACE.CLEAR_HEADS, 'Head→bay bookkeeping cleared'); + + /* ---- the head's own filament, unchanged --------------------------- */ + + return { + setFilament: (index, type, color, vendor) => { + // print_task_config carries these as parallel per-slot arrays. Colour goes back in + // the form the printer sends: RRGGBBAA, no '#'. Writing CSS here would put a value + // on the machine that nothing else on it can read. + const tc = state.taskConfig(); + const types = (tc[TASK_CONFIG.TYPE] || []).slice(); + const vendors = (tc[TASK_CONFIG.VENDOR] || []).slice(); + const rgba = (tc[TASK_CONFIG.COLOR_RGBA] || []).slice(); + const argb = (tc[TASK_CONFIG.COLOR] || []).slice(); + + const hex = (cssColor(color) || '#CCCCCC').slice(1).toUpperCase(); + types[index] = type; + if (vendor !== undefined) vendors[index] = vendor; + rgba[index] = `${hex}FF`; + argb[index] = (0xFF000000 | parseInt(hex, 16)) >>> 0; + + const patch = { [TASK_CONFIG.TYPE]: types, + [TASK_CONFIG.COLOR]: argb, + [TASK_CONFIG.COLOR_RGBA]: rgba }; + if (vendor !== undefined) patch[TASK_CONFIG.VENDOR] = vendors; + send(CMD.UPDATE_MACHINE_FILAMENT_INFO, patch, 'set filament'); + }, + + syncBays: () => syncBays(), + setSource: (i, source) => setSource(i, source), + setAceMode: (m, head) => setAceMode(m, head), + unloadAllHeads: () => unloadAllHeads(), + runVerb: (v) => runVerb(v), + declareBgHead: (i) => declareBgHead(i), + startDrying: (u, t, h) => startDrying(u, t, h), + stopDrying: (u) => stopDrying(u), + setAutoDry: (u, t) => setAutoDry(u, t), + setPurge: () => setPurge(), + setConfirmCommands: () => setConfirmCommands(), + setSpoolman: () => setSpoolman(), + clearHeads: () => clearHeads(), + + filamentHelp: () => openDialog({ + title: 'Filament sources', + build: (b) => { + // UI copy stays to the point: what a control is, not what multiACE is. The + // reasoning lives in docs/u1-webui/02-device-page/. + const p = el('p', 'ms-note'); + p.textContent = 'One card per toolhead. Its header chooses the source: stock ' + + 'feeder, an ACE unit, or hand-fed.'; + p.insertBefore(aceGlyph(), p.firstChild); + b.appendChild(p); + b.appendChild(el('p', 'ms-note', + 'A grey bay with no name is occupied and unidentified. No bay has a level.')); + }, + confirmLabel: 'Close', + cancel: false, + onConfirm: () => true, + }), + }; +} diff --git a/resources/web/device_page/js/views/device-control/filament/filament-panel.js b/resources/web/device_page/js/views/device-control/filament/filament-panel.js new file mode 100644 index 00000000000..822bbad2f79 --- /dev/null +++ b/resources/web/device_page/js/views/device-control/filament/filament-panel.js @@ -0,0 +1,61 @@ +/* + * The Filament panel: one card per toolhead, and what feeds it. + * + * Which shape the body takes is decided by the machine - see filament-view.js. A U1 + * without the multiACE plugin reports no `ace` object and gets the four slots the page + * always drew; one with it gets four toolhead cards in two columns. + * + * The header carries the two things that are about the MACHINE rather than about any one + * toolhead: the ACE mode, which decides whether a head may be wired to a unit at all, + * and the settings a person sets once. Everything that changes daily is on the cards. + */ +'use strict'; + +import { renderFilament, openAceSettings, openAceModeMenu, aceStatus, aceModeLabel } + from './filament-view.js'; + +export default { + id: 'filament', + title: 'Filament', + view: 'control', + column: 'side', + grow: true, + bodyId: 'filament', + bodyClass: 'filament-body', + + header: [ + { kind: 'gap' }, + { kind: 'icon', id: 'filament-refresh', cls: 'tab wide is-active', icon: 'refresh', + title: 'Refresh', on: (ctx) => ctx.handlers.refreshAll() }, + { kind: 'sep' }, + // `label` is a function, so the pill reports the mode instead of drawing the one it + // was built with. Disabled rather than hidden when there is no ACE: a control that + // vanishes leaves no way to find out why it is not there. + { kind: 'pill', id: 'filament-mode', cls: 'pref-pill', chev: 'keyboardArrowDropDown', + label: aceModeLabel, + enabled: (ctx) => ctx.state.ace().present, + title: (ctx) => (ctx.state.ace().present + ? 'How the ACE feeds the toolheads' : 'No ACE on this printer'), + on: (ctx) => openAceModeMenu(document.getElementById('filament-mode'), ctx) }, + { kind: 'spacer' }, + { kind: 'status', id: 'filament-ace', text: aceStatus, + // Which multiACE, and nothing more: a version is a fact, an explanation is not. + title: (ctx) => { + const a = ctx.state.ace(); + return a.present ? `multiACE${a.apiVersion ? ` api_version ${a.apiVersion}` : ''}` + : 'multiACE not installed'; + } }, + { kind: 'gap' }, + { kind: 'icon', id: 'filament-settings', cls: 'icon-only', icon: 'settings', + title: 'Unload all, flush length, confirmations, Spoolman', + on: (ctx) => openAceSettings(document.getElementById('filament-settings'), ctx) }, + { kind: 'icon', id: 'filament-help', cls: 'icon-only', icon: 'help', + title: 'About filament sources', on: (ctx) => ctx.handlers.filamentHelp() }, + ], + + reads: ['filaments', 'ace'], + + update(root, ctx) { + renderFilament(root, ctx); + }, +}; diff --git a/resources/web/device_page/js/views/device-control/filament/filament-view.js b/resources/web/device_page/js/views/device-control/filament/filament-view.js new file mode 100644 index 00000000000..cf5ef0d5910 --- /dev/null +++ b/resources/web/device_page/js/views/device-control/filament/filament-view.js @@ -0,0 +1,1791 @@ +/* + * filament-view.js - the Filament panel's DOM. + * + * The panel has two shapes, and which one it draws is decided by the machine rather + * than by a setting: + * + * no `ace` object the four slots the page always drew, on the bundle's own extruder + * artwork. A U1 without the multiACE plugin has no ACE to describe + * and no macro to send, so it gets the panel it had. + * an `ace` object four toolhead cards in two columns, each with its own header + * choosing what feeds that head, the source drawn under it, and a + * tube from the bay to the head's inlet. + * + * The card is settled in docs/u1-webui/02-device-page/10-multiace-filament.md, decided + * across five interactive studies. What is worth carrying here rather than looking up: + * + * The toolhead carries no filament identity. No colour, no material name, no pencil on + * the artwork - filament belongs to the SOURCE, and the head's copy of it was the one + * that could go stale. What the head has of its own is a sensor marker, which the + * printer reports properly in four positions along the path plus a fault. + * + * A bay is drawn as a head: `.slot`'s own 36px disc over its 58x19 name pill. It is + * the same statement about the same substance at the other end of one tube. + * + * The cabinet is two halves - #EEEEEE over #CECECE, Orca's own AMS neutrals - with the + * seam running THROUGH the roll as a hard stop. Splitting the bay across the halves is + * what made the shipped bay affordable: with both in the upper half the box was 71px + * and a bay had to shrink to 26/52. + * + * Everything sits on one centred axis, so the tube that matters is vertical. That is + * asserted arithmetically in run_webkit.py, not looked at: an inherited + * `align-items: flex-start` once left every card silently left-aligned and no + * screenshot said so. + * + * The body must fit 456px at 1920x1080 - measured, not estimated - and `.panel-body` is + * `overflow: hidden`, so a body past it is clipped in silence. The layout below measures + * 455. + */ +'use strict'; + +import { el, icon } from '../../../core/dom.js'; +import { cssColor, isDarkColor } from '../../../../../shared/js/protocol.js'; +// Everything about the ACE comes from one module - see shared/js/multiACE.js for why. +import { ACE_MODES, ACE_MODE_LABELS, DRY_TEMPS, DRY_HOURS, DRY_LIMITS, + DRY_MINUTES_PER_HOUR, AUTO_DRY_THRESHOLDS, NOT_DECLARED, + aceBadge, aceGlyphSquare, aceBayAddr, humidityLevel, mergeAceBays, + aceVerbs, channelStep, channelWord, headOccupied, + aceSourceOptions, aceModeChange, acePendingMode } + from '../../../../../shared/js/multiACE.js'; +import { keyedList, rebuildOn } from '../../../core/render.js'; +import { openDialog, closeDialog, openMenu } from '../../../core/overlay.js'; + +/** The toolhead artwork, at half. 64x140 becomes 32x70, which is what fits twice over. */ +const HEAD_SCALE = 0.5; + + + +/** + * What the head is holding, in words. + * + * It used to carry the field name alongside - `channel_action_state: load_finish` - on + * the reasoning that naming the source was honest. It is honest and it is ours: a field + * name says where the page READ something, which is not what a hover is for. Which field + * answers this, and the measurement that picked it, is multiACE.js's headOccupied(). + */ +const SENSOR = { at: 'Filament loaded', none: 'No filament loaded', err: 'Feed error' }; + +/** + * Where a bay's name came from - and every one of them is a READING here. + * + * The word still says which source it is, because multiACE's precedence + * (`rfid → override → derived`) is the thing worth knowing about a bay. The GLYPH does + * not vary with it any more: a pencil is an offer to edit, and naming a bay from this + * panel is not built - reading is (`syncBays()` merges the override store), writing is + * tier 2b and wants `ACE_SPOOL_ASSIGN`. Three of these wore the pencil and nothing + * behind it, which is a promise on the face of the panel that the panel cannot keep. + * + * The bay's own web UI is where a name is typed today, and this page does not pretend + * otherwise. When 2b lands, `override`, `derived` and `unknown` are the three that get + * the pencil back; `rfid` never does - a tag is the hardware's own record. + */ +const PROV = { + rfid: { glyph: 'eye', word: 'spool tag · read only' }, + override: { glyph: 'eye', word: 'named in multiACE' }, + derived: { glyph: 'eye', word: 'from the loaded filament' }, + typed: { glyph: 'eye', word: 'typed in' }, + unknown: { glyph: 'eye', word: 'not named' }, +}; + +/** + * The same question for a HEAD, where the machine answers it itself. + * + * A bay's provenance is multiACE's (above). A head's is the printer's, and it publishes + * the verdict: `print_task_config.filament_edit`. This asks that and nothing else. + * + * It used to ask `f.tag` - is there an RFID tag on the spool - and that is a DIFFERENT + * question. Measured on 811002511261022618B3: head 2 carries a tag, reads + * `filament_official: false, filament_edit: true`, and its record has been overridden. + * The panel drew it read-only and then opened an editor on it anyway, which is how the + * mark and the click came to disagree in both directions at once. + */ +function headProv(f) { + return f.editable + ? { glyph: 'pencil', word: 'can be edited' } + : { glyph: 'eye', word: f.tag ? 'spool tag · read only' : 'read only' }; +} + +/* ---------------------------------------------------------------- * + * the panel body + * ---------------------------------------------------------------- */ + +/** + * Which shape the body takes, and the machine decides all of it. + * + * Four, not two, because a mode is not a display preference - it is how many heads the ACE + * drives, and it changes what the same four bays physically are: + * + * slots no `ace` object. A stock U1 has no ACE to describe. + * hub head mode. One head is on the cabinet and any of its four bays may feed it, so + * the cabinet belongs to that card and its unit row sits in that card's header. + * lanes multi mode. Bay i is plumbed to head i, so the cabinet belongs to the PANEL: + * its row is drawn once above the grid and each card holds only its own lane. + * nolane normal mode. Nothing is ACE-fed. The unit row goes below the grid, because an + * idle cabinet still reports humidity and can still dry - which is most of what + * an idle cabinet is for - and a control that vanishes leaves no way to find out + * why it is not there. + */ +function shapeOf(ace) { + if (!ace.present) return 'slots'; + if (ace.mode === 'multi') return 'lanes'; + if (ace.mode === 'normal') return 'nolane'; + return 'hub'; +} + +export function renderFilament(root, ctx) { + const ace = ctx.state.ace(); + const slots = ctx.state.filaments(); + const shape = shapeOf(ace); + // One guard, and it is the one render.js provides: the four shapes share no children, + // so switching between them has to clear rather than reconcile. + rebuildOn(root, shape, (r) => { + if (shape === 'slots') { r.appendChild(el('div', 'slot-row')); return; } + if (shape === 'lanes') r.appendChild(el('div', 'ace-band')); + r.appendChild(el('div', 'ace-grid')); + if (shape === 'nolane') r.appendChild(el('div', 'ace-band')); + }); + if (shape === 'slots') { renderSlots(root.firstElementChild, slots, ctx.handlers); return; } + const band = root.querySelector('.ace-band'); + if (band) renderBand(band, ace, ctx); + renderCards(root.querySelector('.ace-grid'), ace, slots, ctx); +} + +/** + * The unit's own row, where the unit is the PANEL's rather than one card's. + * + * Same row as a head-mode card's second line - badge, name, humidity, the Dry chip - drawn + * once because in these two modes every card is on the same cabinet or none is. Nothing + * here is about a toolhead, which is why it can leave the cards. + */ +function renderBand(band, ace, ctx) { + keyedList(band, ace.units, { + key: (u) => u.index, + sig: (u) => [u.id, u.model, u.humidity, u.temperature, u.connected, + u.dryer.running, u.dryer.doneMin, u.dryer.totalMin, + mergeAceBays(u, ctx.store.aceBays) + .map((b) => `${b.occupied ? 1 : 0}${b.color || ''}`).join(''), + ].join('|'), + create: (u) => { + const s = unitStrip(ctx, u, [], mergeAceBays(u, ctx.store.aceBays)); + s.classList.add('is-band'); + return s; + }, + }); +} + +/* ---------------------------------------------------------------- * + * no ACE: the four slots, exactly as they were + * ---------------------------------------------------------------- */ + +/** + * Four slots, drawn on the bundle's own extruder artwork. + * + * Values come from `print_task_config` - the same object the print-processing + * popup edits. See docs/u1-webui/00-shared/01-shared-models.md + */ +function renderSlots(root, slots, handlers) { + keyedList(root, slots, { + key: (f, i) => i, + // What the card DRAWS, and only that. `f.tag` is the RFID record, an object, and + // it went into the signature whole - where it stringified to "[object Object]" for + // every tagged spool alike, so swapping one tagged spool for another would not have + // rebuilt the card. All the card shows of it is that there is one. + sig: (f) => [f.loaded ? 1 : 0, f.type, f.subType, f.vendor, f.color, + f.tag ? 1 : 0].join(':'), + create: (f, i) => { + const css = cssColor(f.color); + const slot = el('button', 'slot'); + slot.title = f.loaded + ? `Slot ${i + 1}: ${[f.vendor, f.type, f.subType].filter(Boolean).join(' ')}` + : `Slot ${i + 1}: empty`; + + const dot = el('div', 'dot', String(i + 1)); + if (f.loaded) { + dot.dataset.loaded = '1'; + dot.style.background = css || '#C4C4C4'; + // keep the number legible on dark filament + dot.style.color = isDarkColor(f.color) ? '#fff' : '#333'; + } + slot.appendChild(dot); + slot.appendChild(el('div', 'bar', f.loaded ? f.type : '/')); + // The mark has to agree with what the click does, and what the click may do is the + // MACHINE's answer - see headProv(). The RFID chip is a separate fact and keeps its + // own badge: a tag can be present on a spool whose record is editable anyway. + if (f.tag) slot.appendChild(el('span', 'slot-tag', 'RFID')); + const mark = f.editable ? icon('iconFilamentEdit', 'pencil') : glyph('eye'); + mark.classList.add('pencil'); + slot.appendChild(mark); + slot.onclick = () => editSlot(i, f, handlers); + return slot; + }, + }); +} + +/* ---------------------------------------------------------------- * + * an ACE: four toolhead cards + * ---------------------------------------------------------------- */ + +/** + * What feeds head `i`, with anything asked for and not yet confirmed showing instead. + * + * The vocabulary is the selector's, and the selector's is the mode's - see + * aceSourceOptions(). Outside head mode there is no choosing WHICH unit, so an ACE-fed + * head is on `lane` rather than on `ace:`: the lane is plumbing, and naming a unit + * there would offer a choice the machine does not have. + */ +function askedSource(ace, pending, i) { + const h = ace.heads[i]; + const mirror = h.source !== 'ace' ? h.source + : ace.mode === 'head' ? `ace:${h.unitIndex}` : 'lane'; + return pending.resolve(`ace-src-${i}`, mirror); +} + +/** Which heads a unit feeds. One unit may feed several; the reverse is not reported. */ +const usersOf = (ace, u) => + ace.heads.filter((h) => h.source === 'ace' && h.unitIndex === u).map((h) => h.index); + +function renderCards(grid, ace, slots, ctx) { + keyedList(grid, ace.heads, { + key: (h) => h.index, + sig: (h) => cardSig(ace, slots, ctx.pending, h.index, ctx.store.aceBays), + create: (h) => card(ace, slots, ctx, h.index), + }); + drawTubes(grid); +} + +/** + * Everything the card draws, and only that. + * + * It has to include the resolved source rather than the machine's, or a switch flipped + * by the user would not repaint until the printer agreed - which for a load is minutes. + */ +function cardSig(ace, slots, pending, i, overrides) { + const src = askedSource(ace, pending, i); + const h = ace.heads[i]; + const u = h.source === 'ace' ? ace.units[h.unitIndex] : null; + const sp = h.loaded || slots[i]; + return [ + ace.mode, ace.unitCount, src.value, src.state, + usersOf(ace, h.unitIndex).join('-'), + h.bay, sensorOf(slots[i], headLoaded(ace, slots, i)), + // The field occupancy is actually decided by. It is NOT `channelState` below: that + // one settles back to `wait_insert` after an unload, so a signature holding only it + // never changed when the head emptied - which is the repaint half of the same bug. + (slots[i] && slots[i].feed && slots[i].feed.actionState) || '', + // What the machine is doing to this head. It is in the signature because the card + // DRAWS it - the step bar beside the toolhead - and a card whose signature omits + // something it draws simply never repaints for it. The sensor mark above buckets + // four positions into one word, so it cannot stand in for this: a whole swap runs + // with `filament_at_extruder` unchanged. + (slots[i] && slots[i].feed && slots[i].feed.channelState) || '', + ace.bgHeads.join(','), + // Which bay is plumbed here, and which head each of this card's bays is recorded as + // feeding. In lane mode the second one is drawn ON the card - a bay of this head's + // lane that another head is fed from wears the note saying so - and nothing else in + // this signature moves when a different head's `head_source` does. + h.lane == null ? '-' : `L${h.lane}`, + ace.heads.map((o) => (o.source === 'ace' && o.bay === h.lane ? o.unitIndex : '')).join(''), + sp ? [sp.material || sp.type, sp.subType, sp.vendor, sp.color].join(',') : '-', + /* + * The two marks on the roll, which are not derivable from anything above it. + * + * A card whose signature omits something it DRAWS simply never repaints for it, and + * this omitted both: a tag arriving (the reader is asynchronous - it lands seconds + * after the filament does) and the machine flipping a slot between its own record + * and an overridden one. Neither moves material, sub-type, vendor or colour, so the + * line above cannot stand in for them, and the card kept whatever pair it was built + * with. Caught by a check that applied a tag to a drawn panel and found the mark + * missing while the menu - rebuilt on every click - had it. + */ + slots[i] ? `${slots[i].tag ? 1 : 0}${slots[i].editable ? 1 : 0}` : '-', + // In lane mode the card draws slot `lane` of EVERY unit, so every unit's bays are in + // the signature; in head mode it draws one cabinet and only that one is. + (h.lane == null ? (u ? [u] : []) : ace.units) + .map((x) => [x.id, x.model, x.humidity, x.temperature, x.dryer.running, + x.dryer.doneMin, x.dryer.totalMin, + mergeAceBays(x, overrides) + .map((b) => [b.occupied ? 1 : 0, b.material, b.color, b.source].join('')) + .join('/'), + ].join(':')).join('#') || '-', + pending.resolve(`ace-load-${i}`, h.bay == null ? '' : aceBayAddr(h.unitIndex, h.bay)).state, + ].join('|'); +} + +/** + * Whether a head is holding filament. One question, one answer, and it lives in + * multiACE.js beside the state table it is read from - see headOccupied() there for the + * measurement that settled which field answers it. + * + * It has been wrong twice. `filaments()[i].loaded` is `print_task_config`, the slicer's + * ASSIGNMENT to that slot, which a physical unload does not clear - so an emptied head + * went on offering `Unload`. Reading `filament_at_extruder` instead fixed the simulator + * and not the printer: that field was still true on both heads that had been emptied. + * + * Three places decided this independently before, and one of them had forgotten the + * feeder case entirely. Deciding it once is most of the fix - and it is why the second + * wrong answer was one line to correct. + */ +function headLoaded(ace, slots, i) { + return headOccupied(slots[i] && slots[i].feed, ace.heads[i], + slots[i] && slots[i].loaded); +} + +/** + * The mark on the artwork, which says the same thing the verbs do. + * + * It used to read `filament_at_extruder` straight, so it drew "filament at the extruder" + * on a head the panel was simultaneously offering `Load`. A card must not contradict + * itself: occupancy is decided once, above, and this only picks the word for it. + */ +function sensorOf(f, occupied) { + const feed = f && f.feed; + if (feed && feed.error) return 'err'; + return occupied ? 'at' : 'none'; +} + +function card(ace, slots, ctx, i) { + const h = ace.heads[i]; + const src = askedSource(ace, ctx.pending, i); + // `lane` is multi's answer and `ace:` is head mode's, and they are different shapes: + // one card holds one cabinet's four bays, the other holds every cabinet's one bay. + const lane = src.value === 'lane'; + const onAce = lane || String(src.value).startsWith('ace:'); + const unit = lane ? ace.units[h.unitIndex] + : onAce ? ace.units[Number(String(src.value).slice(4))] : null; + // Sharing is a head-mode reading risk: one cabinet drawn on several cards, which is + // exactly what lane mode does on purpose and says with the band instead. + const shared = (unit && !lane) ? usersOf(ace, unit.index).filter((x) => x !== i) : []; + + const bays = unit ? mergeAceBays(unit, ctx.store.aceBays) : null; + + const c = el('div', 'ace-card'); + if (onAce) c.classList.add('is-ace'); + if (shared.length) c.classList.add('is-shared'); + c.appendChild(cardHead(ace, ctx, i, src, unit, shared, bays)); + + const body = el('div', 'ace-body'); + const spool = onAce ? h.loaded : slots[i]; + // Whether anything is THERE is a different question from whether it has a name, and the + // feeder box needs both - see the note in feeder(). Asked once, from the field that + // answers it, rather than inferred from the identity that may have just been wiped. + const occupied = headLoaded(ace, slots, i); + body.appendChild(lane ? laneBox(ctx, ace, i, h) + : unit ? cabinet(ctx, i, unit, bays, h.bay) + : feeder(ctx, i, src.value, spool, occupied)); + // Where the filament actually came from, when that is not this head's own lane. + // + // The measured machine is plumbed for head mode and was switched to multi, so + // `head_source[3]` still read `{ace_index: 0, slot: 1}` - bay A2 feeding Toolhead 4, + // which is Toolhead 2's lane. multi's own rule says that cannot happen; the machine's + // record says it did. The mode is a claim about tubes and nothing verifies it, so the + // panel draws the record and names it. + if (lane && h.bay != null && h.bay !== i) body.appendChild(fromChip(ace, h)); + body.appendChild(el('div', 'ace-lane')); + body.appendChild(head(slots[i], i, unit, ctx, occupied)); + + // The tube layer paints BEHIND the cabinet and is appended last, so it also paints + // over the toolhead's inlet - which is where it is meant to land. + const wire = svg('svg', { class: 'ace-wire', 'aria-hidden': 'true' }); + const core = spool ? cssColor(spool.color) : null; + if (core) wire.dataset.core = core; + if (src.value === 'manual') wire.dataset.none = '1'; + body.appendChild(wire); + c.appendChild(body); + return c; +} + +/** + * Two lines: the toolhead and what feeds it, then the unit itself. + * + * The second line exists because the first has no room for the three things that are + * not optional information - which ACE it is, how wet it is, and whether it is drying - + * and a card with no ACE names its module there instead of holding an empty spacer, + * which alignment required anyway. + */ +function cardHead(ace, ctx, i, src, unit, shared, bays) { + const hd = el('div', 'ace-head'); + const r1 = el('div', 'ace-hrow'); + r1.appendChild(el('span', 'ace-name', `Toolhead ${i + 1}`)); + r1.appendChild(el('span', 'spacer')); + r1.appendChild(sourceSelect(ace, ctx, i, src)); + const more = el('button', 'icon-only ace-more', '⋯'); + more.type = 'button'; + more.title = unit ? 'Load, unload, swap, edit' : 'Load, unload, edit'; + more.onclick = (e) => { e.stopPropagation(); openHeadMenu(more, ace, ctx, i, unit); }; + r1.appendChild(more); + hd.appendChild(r1); + /* + * The second line names WHICH unit feeds this head, and outside head mode there is no + * such thing to name: in multi every card is on the same cabinet, so its row is the + * panel's and is drawn once in the band; in normal nothing is ACE-fed at all. + * + * It is also height the budget has not got. Normal measured 497 against 456 with four + * copies of a line reading `Feeder · channel 1`, and `.panel-body` is `overflow: hidden`, + * so that would have been clipped rather than seen. + */ + if (ace.mode === 'head') { + hd.appendChild(unit ? unitStrip(ctx, unit, shared, bays) : feederStrip(i, src.value)); + } + return hd; +} + +/** + * A list, not an icon switch. + * + * The first study gave each head a feeder/ACE/manual icon triple: 102px wide, and unable + * to name WHICH ACE. With four units the question has six answers, so it is a list. + */ +function sourceSelect(ace, ctx, i, src) { + const sel = el('select', 'ace-src'); + // The list is the MODE's - see aceSourceOptions(). It used to be one list disabled + // wholesale outside head mode, which took hand-feeding away in the two modes where + // `ACE_SET_HEAD_MANUAL` still works, and offered a choice of unit in the one mode where + // the lane is plumbing rather than a choice. + aceSourceOptions(ace, i).forEach(({ value, label }) => { + const o = el('option', null, label); + o.value = value; + if (value === src.value) o.selected = true; + sel.appendChild(o); + }); + sel.title = src.state === 'sent' ? 'Waiting for the machine' + : ace.mode === 'multi' ? `Bay ${i + 1} of every ACE feeds this toolhead` + : 'What feeds this toolhead'; + if (src.state) sel.dataset.pend = src.state; + sel.setAttribute('aria-label', `Source for Toolhead ${i + 1}`); + sel.onchange = () => ctx.handlers.setSource(i, sel.value); + return sel; +} + +function unitStrip(ctx, u, shared, bays) { + const r = el('div', 'ace-strip'); + r.appendChild(aceBadge(bays || u.bays)); + const n = el('span', 'ace-unit'); + n.appendChild(el('b', null, `ACE ${u.id}`)); + n.appendChild(document.createTextNode(` · ${u.model}`)); + n.title = [u.model, u.firmware].filter(Boolean).join(' ') + + (u.connected ? '' : ' · not answering') + + (shared.length ? ` · also feeds ${shared.map((x) => `Toolhead ${x + 1}`).join(', ')}` + : ''); + r.appendChild(n); + r.appendChild(el('span', 'spacer')); + r.appendChild(humidityPill(u, shared)); + r.appendChild(dryChip(ctx, u)); + return r; +} + +/** + * `AMSHumidity`'s own shape: two readings and one divider. + * + * Untinted on purpose. The tinted variant said the same thing as the number and made a + * reading that is usually fine look like a warning. + */ +function humidityPill(u, shared) { + const p = el('span', 'ace-hum'); + p.title = `${num(u.humidity, '%')} RH · ${num(u.temperature, '°C')} inside ACE ${u.id}` + + (shared.length ? ` · shared with ${shared.length} other head${shared.length > 1 ? 's' : ''}` + : ''); + if (shared.length) { + const m = el('span', 'ace-share', '⇄'); + p.appendChild(m); + } + p.appendChild(el('span', null, num(u.humidity, '%'))); + p.appendChild(el('span', 'ace-sep')); + p.appendChild(el('span', null, num(u.temperature, '°C'))); + return p; +} + +/** + * The chip offers *Dry* while it is idle and reports elapsed of total while it runs - + * where you are AND how long it was set for, in 8px more than "2 h 41 m left". It is the + * only thing that opens the dialog. + */ +function dryChip(ctx, u) { + const d = u.dryer; + const asked = ctx.pending.resolve(`ace-dry-${u.index}`, d.running); + const running = !!asked.value; + const b = el('button', 'ace-dry' + (running ? ' is-running' : '')); + b.type = 'button'; + b.appendChild(el('span', null, running ? dryLabel(d) : 'Dry')); + b.title = running + ? `Drying${d.target ? ` to ${d.target} °C` : ''}` + + (d.remainingMin != null ? ` · ${hm(d.remainingMin)} left` : '') + : `Dry ACE ${u.id}` + + (u.autoDry.enabled ? ` · automatic above ${u.autoDry.rhStart} %` : ''); + b.onclick = (e) => { e.stopPropagation(); openDryer(ctx, u); }; + return b; +} + +const dryLabel = (d) => (d.doneMin != null && d.totalMin != null) + ? `${hm(d.doneMin)} / ${hm(d.totalMin)}` + : (d.remainingMin != null ? `${hm(d.remainingMin)} left` : 'Drying'); + +function feederStrip(i, source) { + const r = el('div', 'ace-strip'); + r.appendChild(moduleBadge()); + const n = el('span', 'ace-unit'); + const manual = source === 'manual'; + n.appendChild(el('b', null, manual ? 'Hand-fed' : 'Feeder')); + n.appendChild(document.createTextNode(manual ? ' · bypass' : ` · channel ${(i % 2) + 1}`)); + n.title = manual ? 'Hand-fed' : 'Automatic Filament Feeder Module'; + r.appendChild(n); + r.appendChild(el('span', 'spacer')); + return r; +} + +/* ---- the box, its bays, and the head ------------------------------- */ + +/** + * The cabinet: two halves, and each half has a job. + * + * Spools above, materials named below, with the seam through the middle of every roll as + * a hard colour stop. The box hugs its four spools rather than filling the card - at four + * bays, full width was mostly empty grey, and the base drawn wider than the hood is what + * makes it read as furniture. + */ +function cabinet(ctx, i, u, unitBays, fedBay) { + const box = el('div', 'ace-box'); + const cab = el('div', 'ace-cab'); + const top = el('div', 'ace-cab-top'); + const bays = el('div', 'ace-bays'); + unitBays.forEach((b) => bays.appendChild(bay(ctx, i, u, b, b.index === fedBay))); + top.appendChild(bays); + cab.appendChild(top); + box.appendChild(cab); + return box; +} + +/** + * Multi mode's box: this head's LANE, which is slot `i` of every cabinet there is. + * + * The same cabinet drawing as head mode, one bay wide, once per unit - because that is + * what the hardware is. With a splitter on the head, slot i of each unit reaches it and + * the machine picks between them with `ACE_SWITCH`; with one unit there is one bay and no + * choosing at all. + * + * A bay is drawn ONCE, on the card of the head it is plumbed to. Drawing it again on the + * card of a head that happens to be fed from it would put one spool on the panel twice, + * which is the reading risk head mode's shared cabinet already answers with a dashed edge. + */ +function laneBox(ctx, ace, i, h) { + const box = el('div', 'ace-box'); + const row = el('div', 'ace-lanes'); + ace.units.forEach((u) => { + const b = mergeAceBays(u, ctx.store.aceBays)[i]; + if (!b) return; + const fed = h.unitIndex === u.index && h.bay === i; + const cab = el('div', 'ace-cab'); + const top = el('div', 'ace-cab-top'); + const bays = el('div', 'ace-bays'); + const node = bay(ctx, i, u, b, fed); + // A bay of THIS head's lane that another head is recorded as being fed from. Only + // reachable on a machine whose tubes do not match its mode, which is the machine the + // panel was measured on - so it is drawn rather than assumed away. + const other = ace.heads.find((o) => o.index !== i && o.source === 'ace' + && o.unitIndex === u.index && o.bay === i); + if (other) { + node.classList.add('is-lent'); + node.appendChild(el('span', 'ace-lent', `→ Toolhead ${other.index + 1}`)); + node.title = `${b.addr}: feeding Toolhead ${other.index + 1}`; + node.setAttribute('aria-label', node.title); + } + bays.appendChild(node); + top.appendChild(bays); + cab.appendChild(top); + row.appendChild(cab); + }); + box.appendChild(row); + return box; +} + +/** What is in the head, when it did not come from this head's own lane. */ +function fromChip(ace, h) { + const u = ace.units[h.unitIndex]; + const b = u && (u.bays || [])[h.bay]; + const c = el('span', 'ace-from'); + const sw = el('span', 'ace-from-dot'); + const col = h.loaded ? cssColor(h.loaded.color) : (b && b.known ? cssColor(b.color) : null); + if (col) sw.style.background = col; + c.appendChild(sw); + const mat = (h.loaded && (h.loaded.material || h.loaded.type)) || (b && b.material) || ''; + c.appendChild(document.createTextNode( + `${mat ? `${mat} · ` : ''}from ${aceBayAddr(h.unitIndex, h.bay)}`)); + c.title = `Loaded from ${aceBayAddr(h.unitIndex, h.bay)}, which is Toolhead ${h.bay + 1}'s lane`; + return c; +} + +/** + * One bay: `.slot`'s own 36px disc over its 58x19 name pill, 6px apart. + * + * Nothing is drawn round it at rest. Anything permanent there either hides the seam that + * runs through the roll or fights it, so the shape appears only under the pointer. + */ +function bay(ctx, i, u, b, fed) { + const node = el('button', 'ace-bay'); + node.type = 'button'; + if (fed) node.classList.add('is-fed'); + if (b.occupied && !b.known) node.classList.add('is-unknown'); + node.title = b.occupied + ? (b.known + ? `${b.addr}: ${[b.material, b.subType].filter(Boolean).join(' ')}` + + (b.vendor ? ` · ${b.vendor}` : '') + + (b.sku ? ` · ${b.sku}` : '') + + ` · ${(PROV[b.source] || PROV.unknown).word}` + : `${b.addr}: occupied, not named`) + : `${b.addr}: empty`; + node.setAttribute('aria-label', node.title); + + const col = b.occupied ? (b.known ? cssColor(b.color) : null) : null; + const disc = el('span', 'ace-disc', b.addr); + if (col) { + disc.dataset.loaded = '1'; + disc.style.background = col; + disc.style.color = isDarkColor(col) ? '#fff' : '#333'; + } else if (b.occupied) { + // Occupied and unnamed is a solid neutral, never the checkerboard - that is this + // page's word for EMPTY, and drawing it here would be a lie in the other direction. + disc.dataset.loaded = '1'; + disc.style.background = '#B7BDC6'; + } + node.appendChild(disc); + node.appendChild(el('span', 'ace-chip', b.occupied ? (b.material || '?') : '/')); + if (b.occupied) node.appendChild(provMark(b)); + // multiACE's own answer for a bay. No bay on the measured machine has a tag - every + // raw slot reads `rfid: 0` - so this is drawn from the model rather than from a + // sighting, and it is the same mark a feeder spool gets for the same reason. + if (b.occupied && b.source === 'rfid') { + node.appendChild(tagMark([b.material, b.subType].filter(Boolean).join(' ') || b.addr)); + } + node.onclick = () => baySheet(ctx, i, u, b); + return node; +} + +/** + * "This spool identified itself over the air", which is a DIFFERENT fact from whether + * its record may be edited - and the one the panel stopped drawing. + * + * The four-slot form has always carried it as a green `RFID` word under the chip, and + * that shape has the room for it. The ACE card does not: a bay is 62 px wide and the + * chip is already an ellipsis away from cutting off a material name. So it is the same + * fact at the same weight as the provenance mark, mirrored to the other side of the + * roll, absolute like that one so it costs no layout. + * + * Why it needs its own mark at all: the eye used to be driven by the tag, so a tagged + * head got one for free. It is driven by the machine's `filament_edit` now - which is + * right, and measured - and on a head whose tagged record has been overridden that + * leaves a pencil and nothing at all saying a tag is there. Reported from the panel: + * Toolhead 2 carries an NTAG reading Forshape PLA Silk and showed no sign of it. + */ +function tagMark(what) { + const w = el('span', 'ace-tag'); + w.appendChild(glyph('tag')); + w.title = `${what} · carries an RFID tag`; + return w; +} + +function provMark(b) { + const p = PROV[b.source] || PROV.typed; + const w = el('span', 'ace-prov'); + w.appendChild(glyph(p.glyph)); + w.title = `${[b.material, b.vendor].filter(Boolean).join(' · ') || b.addr || ''} · ${p.word}`; + return w; +} + +/** + * The stock feeder, in the module's own black and white. + * + * The same drawing as the cabinet - same seam rule, same proportions, same bay - in + * #FFFFFF over #1F1F1F instead of #EEEEEE over #CECECE, because Snapmaker's Automatic + * Filament Feeder Module is a different device and one glance should say so. A frame may + * change horizontal padding and nothing else, or a feeder's spool stops sitting at an + * ACE bay's exact height. + */ +function feeder(ctx, i, source, spool, occupied) { + const box = el('div', 'ace-box'); + const f = el('div', 'ace-feed'); + const top = el('div', 'ace-feed-top'); + const row = el('div', 'ace-feed-row'); + const b = el('button', 'ace-bay'); + b.type = 'button'; + const what = source === 'manual' ? 'Hand-fed' : 'Stock feeder'; + const col = spool ? cssColor(spool.color) : null; + const material = spool ? (spool.material || spool.type) : null; + /* + * Three states, exactly as a BAY has had all along: named, occupied-and-unnamed, empty. + * This box had two - it painted whatever colour it was handed, or the checkerboard - + * and the middle one is not hypothetical here. + * + * multiACE clears a feeder head's identity when the machine enters head mode + * (`_clear_filament_display`: empty type, empty vendor, `FILAMENT_COLOR_RGBA=00000000`) + * while the filament stays physically in the head. That painted a black spool, because + * the transparent colour read as black - see cssColor, fixed there. With the colour + * correctly ABSENT it would then have drawn the checkerboard, which is this page's word + * for EMPTY and a lie in the other direction. + * + * So: occupied and unnamed is the neutral `#B7BDC6` and a `?` - the same drawing and + * the same two values a bay in that state has always used. + */ + const unnamed = !!occupied && !col && !material; + b.title = material ? `${what}: ${[material, spool.vendor].filter(Boolean).join(' · ')}` + : unnamed ? `${what}: occupied, not named` + : `${what}: nothing detected`; + b.setAttribute('aria-label', b.title); + if (unnamed) b.classList.add('is-unknown'); + const disc = el('span', 'ace-disc'); + if (col) { + disc.dataset.loaded = '1'; + disc.style.background = col; + disc.style.color = isDarkColor(col) ? '#fff' : '#333'; + } else if (unnamed) { + // Never the checkerboard: that is EMPTY, and something is in the head. + disc.dataset.loaded = '1'; + disc.style.background = '#B7BDC6'; + } + b.appendChild(disc); + b.appendChild(el('span', 'ace-chip', material || (unnamed ? '?' : '/'))); + // The spool at the head gets the same mark a bay does, and for the same reason: a tag + // can be read and not written. `.slot` already drew the pencil half of this. + if (material) { + const p = headProv(spool); + const w = el('span', 'ace-prov'); + w.appendChild(glyph(p.glyph)); + w.title = `${[material, spool.vendor].filter(Boolean).join(' · ')} · ${p.word}`; + b.appendChild(w); + // The other side of the roll, and the other question: a tag on the spool is the + // spool's fact, and it is drawn whether or not the record may be edited. + if (spool.tag) { + b.appendChild(tagMark([spool.tag.vendor, spool.tag.type, spool.tag.subType] + .filter(Boolean).join(' ') || 'This spool')); + } + } + b.onclick = () => editSlot(i, spool || {}, ctx.handlers); + row.appendChild(b); + top.appendChild(row); + f.appendChild(top); + box.appendChild(f); + return box; +} + +/** + * The head: artwork, and one marker. + * + * The dot is centred on the artwork's BODY - extruderBackground.svg draws that body + * y=17.4..127.6 of its 64x140, so the middle is (32, 72.5) at whatever scale. + */ +function head(f, i, unit, ctx, occupied) { + const wrap = el('div', 'ace-toolwrap'); + const st = sensorOf(f, occupied); + const t = el('div', 'ace-tool is-target'); + t.style.setProperty('--s', HEAD_SCALE); + // Every one of these macros addresses `HEAD=n`, so the head is what they are ABOUT - + // and it is the one target on the card that is the same size at every panel width, + // where a bay is 62 px and shrinking. It wears the bay's own traced edge under the + // pointer, because two things you can click on one card should not answer differently. + t.title = `Toolhead ${i + 1} — ${SENSOR[st]}`; + t.setAttribute('aria-label', t.title); + t.setAttribute('role', 'button'); + t.tabIndex = 0; + t.onclick = () => headSheet(ctx, i, unit); + t.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); t.click(); } }; + const m = el('span', `ace-sensor is-${st}`); + m.title = SENSOR[st]; + t.appendChild(m); + const line = flightLine(f, i, unit); + if (line) wrap.appendChild(line); + wrap.appendChild(t); + return wrap; +} + +/** + * What the machine is doing to this head, in the machine's own words. + * + * `channel_state` is the U1's, not multiACE's - `filament_feed left|right` -> the head's + * own record, already on the subscription and already parsed by `feedChannels()`. So this + * costs no request; it was simply never drawn. + * + * It lives in the GUTTER beside the artwork, and that is the whole reason it can exist: + * a row under the box measures 479 against a 456 budget and `.panel-body` is + * `overflow: hidden`, so it would have been clipped rather than seen. The head is 32 px + * wide in a 371 px cell, which leaves two empty columns and one of them is free. + * + * The bar is DETERMINATE, and an earlier draft argued it could not be. That was right + * about `swap_phase`, which is a word, and wrong about the machine: `channel_state` names + * which of a KNOWN list the printer is in, so step 4 of 6 is a real quantity. What is + * still not derivable is a fraction WITHIN a step, and none is drawn. + */ +function flightLine(f, i, unit) { + const state = f && f.feed && f.feed.channelState; + // A swap on an ACE-fed head is one bar with two halves; a feeder head runs one + // direction at a time and gets that direction's own list. + const step = channelStep(state, unit ? 'swap' : null); + if (!step) return null; + + const row = el('div', 'ace-flight' + (step.failed ? ' is-fail' : '')); + if (step.failed) { + // A failure is a firmware STATE, not a sentence. Translating it would be this page's + // opinion of what happened, and it stays until something else happens. + row.appendChild(el('span', 'ace-flight-at', channelWord(step.state) || step.state)); + return row; + } + if (step.done || step.at == null) return null; + + const ticks = el('div', 'ace-steps'); + for (let k = 0; k < step.total; k += 1) { + const seg = el('i', k < step.at ? 'is-done' : k === step.at ? 'is-now' : null); + ticks.appendChild(seg); + } + row.appendChild(ticks); + // The heat step reads the nozzle rather than captioning it: "heating" and "here is how + // far" are different answers, and only the second says whether to wait. + const label = el('span', 'ace-flight-at', step.label); + row.appendChild(label); + row.title = `Step ${step.at + 1} of ${step.total}`; + row.dataset.heat = step.heat ? '1' : ''; + return row; +} + +/* ---- the tube ------------------------------------------------------ * + * Tubing, not a line: a #C9C9C9 casing with the filament's own 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. + * + * The manifold sits BELOW the cabinet and the layer paints behind it, so a drop from a + * bay disappears under the box and reappears beneath it - filament inside a machine is + * not something to draw over the machine. Which bay is feeding stays legible because the + * coloured core starts at that bay's x on the bar. + * + * Geometry is measured, so this runs after layout. It is memoised on the measurement + * itself rather than on state: the tube moves when the column width does, which no state + * signature can see, and rewriting four SVGs every frame is the churn render.js exists + * to avoid. + */ +function drawTubes(grid) { + grid.querySelectorAll('.ace-card').forEach((c) => { + const body = c.querySelector('.ace-body'); + const wire = c.querySelector('.ace-wire'); + const tool = c.querySelector('.ace-tool'); + const box = c.querySelector('.ace-box'); + if (!body || !wire || !tool || !box) return; + const R = body.getBoundingClientRect(); + if (!R.width) return; + + const bays = Array.from(c.querySelectorAll('.ace-bay')); + const fed = c.querySelector('.ace-bay.is-fed') || bays[0]; + const core = wire.dataset.core || ''; + const tr = tool.getBoundingClientRect(); + const inlet = { x: r1(tr.left + 64 * HEAD_SCALE / 2 - R.left), y: r1(tr.top - R.top) }; + const manY = r1(box.getBoundingClientRect().bottom - R.top + 7); + const xs = bays.map((b) => { + const q = b.getBoundingClientRect(); + return r1(q.left + q.width / 2 - R.left); + }); + const from = fed ? r1(fed.getBoundingClientRect().bottom - R.top) : 0; + const geom = [wire.dataset.none || '', R.width, R.height, inlet.x, inlet.y, + manY, from, core, xs.join(',')].join('|'); + if (wire.dataset.geom === geom) return; + wire.dataset.geom = geom; + wire.textContent = ''; + // Hand-fed is the one source with no tube: the spool is at the head and nothing + // routes it. Drawing one would be inventing hardware. + if (wire.dataset.none || !fed) return; + wire.setAttribute('viewBox', `0 0 ${R.width} ${R.height}`); + + const tube = (d, w) => { + wire.appendChild(svg('path', { d, fill: 'none', stroke: '#C9C9C9', 'stroke-width': w, + 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })); + wire.appendChild(svg('path', { d, fill: 'none', stroke: '#EFF1F3', 'stroke-width': w - 2, + 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })); + }; + const lit = (d) => wire.appendChild(svg('path', { d, fill: 'none', stroke: core, + 'stroke-width': 2.6, + 'stroke-linecap': 'round' })); + const mid = r1((Math.min(...xs) + Math.max(...xs)) / 2); + if (bays.length > 1) { + tube(`M${Math.min(...xs)} ${manY} H${Math.max(...xs)}`, 6); + xs.forEach((x) => tube(`M${x} ${from} V${manY}`, 5)); + if (core) { + const fx = xs[bays.indexOf(fed)]; + lit(`M${fx} ${from} V${manY}`); + lit(`M${fx} ${manY} H${mid}`); + } + } + const leg = `M${mid} ${bays.length > 1 ? manY : from} V${inlet.y}`; + tube(leg, 6); + if (core) lit(leg); + wire.appendChild(svg('circle', { cx: inlet.x, cy: inlet.y, r: 3.2, + fill: core || '#C9C9C9' })); + }); +} + +/* ---------------------------------------------------------------- * + * the two menus, and their two scopes + * ---------------------------------------------------------------- */ + +/** + * A toolhead's own menu: load, unload, swap, and the filament's own record. + * + * Swap is absent on a stock feeder because there are no other bays to swap to, and + * Unload is greyed on an empty head rather than offered. + */ +function openHeadMenu(anchor, ace, ctx, i, unit) { + const h = ace.heads[i]; + // `filaments()` returns a record per slot whether or not anything is in it, so the + // truthiness of that object says nothing - `loaded` is the field that does. Reading the + // object left Unload offered on an empty head and every Load labelled Reload. + const slots = ctx.state.filaments(); + const f = slots[i]; + const loaded = headLoaded(ace, slots, i); + const spool = loaded ? (unit ? h.loaded || f : f) : null; + /* + * Off the HEAD's record, not off `spool`. + * + * `spool` may be the bay for an ACE-fed head, and the row opens the head's Materials + * Setting - `filaments()[i]` - so asking the bay whether the head may be edited would + * be two different slots answering as one. + */ + const editable = !!f.editable; + + // The same list the bay sheet reads, filtered the other way: the verbs that take no + // SLOT= have nothing to choose, so they belong here and the slotted ones do not. + const items = aceVerbs(ace, i, unit ? h.bay : null, loaded) + .filter((v) => !v.slotted) + .map((v) => ({ + // The name in the label and the reason in the hover. Spelling the reason in the + // label took the menu to 532 px against a 391 px card; the macro chip that used to + // carry it - a muted row reading `ACE_BG_SET_HEAD` - said which refusal this was in + // G-code, which is a thing the reader has to already know to read. + label: v.name, + icon: /nload/.test(v.name) ? 'iconFilamentCheck' : 'refresh', + title: v.off ? `${v.name} — ${v.off}` : v.name, + muted: !!v.off, + onClick: v.off === NOT_DECLARED ? () => ctx.handlers.declareBgHead(i) + : v.off ? undefined : () => ctx.handlers.runVerb(v), + })); + + if (unit) { + // Where the slotted verbs went. Not a control: a sentence saying where to click, + // because the bay IS the argument and pointing at it is the whole design. + items.push({ label: 'Swap to another bay…', glyph: aceGlyphSquare(), + title: 'Click the toolhead, then the bay', + muted: true }); + } + items.push(null); + items.push({ + // View or edit, as the MACHINE has it - and now the sheet obeys the same answer, so + // the label, the icon and what opens are one decision. This asked whether the spool + // had a tag, which is a different question, and then opened an editor either way. + label: editable ? 'Edit this filament…' : 'View this filament', + glyph: glyph(editable ? 'pencil' : 'eye'), + title: editable ? null : 'Read only — this is the spool’s own record', + onClick: () => editSlot(i, ctx.state.filaments()[i], ctx.handlers), + }); + openMenu(anchor, items, { head: `Toolhead ${i + 1}` }); +} + +/** + * The panel's own menu: the settings a person sets once. + * + * On the face of the panel these would bury the four things that change daily. Two `...` + * a few pixels apart mean two different scopes, so each menu names its own on the way + * open - the cheapest honest fix, and it costs a line that was going to be there anyway. + */ +export function openAceSettings(anchor, ctx) { + if (!ctx.state.ace().present) { + openMenu(anchor, [{ label: 'No ACE on this printer', + glyph: aceGlyphSquare(), muted: true }], + { head: 'This printer' }); + return; + } + openMenu(anchor, [ + { label: 'Unload every toolhead', glyph: aceGlyphSquare(), + onClick: () => ctx.handlers.unloadAllHeads() }, + null, + { label: 'Flush length…', icon: 'settings', + onClick: () => ctx.handlers.setPurge() }, + { label: 'Confirm before load and unload', icon: 'settings', + onClick: () => ctx.handlers.setConfirmCommands() }, + { label: 'Spoolman…', icon: 'settings', + onClick: () => ctx.handlers.setSpoolman() }, + null, + { label: 'Clear the head→bay bookkeeping', icon: 'delete', + onClick: () => ctx.handlers.clearHeads() }, + ], { head: 'This printer' }); +} + +/** + * The three modes, as a list rather than as a control that cycles. + * + * Cycling would send SET_ACE_MODE twice to get from normal to head, and a mode change is + * not a thing to do twice by accident. + * + * Head asks WHICH head on the way, always - including from head mode, because that is how + * the coupled head gets moved. `SET_ACE_MODE MODE=head HEAD=n` writes + * `head_feeder[h] = (h != n)` for all four, and without the argument the mode keeps + * whatever wiring was there. + */ +export function openAceModeMenu(anchor, ctx) { + const ace = ctx.state.ace(); + const now = ctx.pending.resolve('ace-mode', ace.mode).value; + openMenu(anchor, ACE_MODES.map((m) => ({ + label: ACE_MODE_LABELS[m] + (m === now ? ' ✓' : ''), + glyph: aceGlyphSquare(), + // Head is never muted: from head mode it is the way to move the coupled head. + muted: m === now && m !== 'head', + title: aceModeChange(now, m).needsRestart + ? 'Needs every toolhead unloaded, then a restart' : null, + onClick: (m === now && m !== 'head') ? undefined : () => pickMode(ctx, ace, now, m), + })), { head: 'ACE mode' }); +} + +/** + * Which head is on the cabinet, and then the switch. + * + * A restart-owing switch gets a confirmation of its own rather than going straight out: + * it needs every head unloaded and a power cycle, and it is the one control on this panel + * whose success arrives as an error. + */ +function pickMode(ctx, ace, from, to) { + const change = aceModeChange(from, to); + if (to !== 'head') { + if (change.live) { ctx.handlers.setAceMode(to); return; } + openDialog({ + title: `Switch to ${ACE_MODE_LABELS[to]} mode`, + build: (bd) => { + bd.appendChild(el('p', 'ms-note', + 'Every toolhead has to be unloaded first, and the printer restarted afterwards.')); + bd.appendChild(el('div', 'verb-cmd', `SET_ACE_MODE MODE=${to}`)); + }, + confirmLabel: 'Switch', + onConfirm: () => { ctx.handlers.setAceMode(to); return true; }, + }); + return; + } + let head = ace.heads.findIndex((h) => h.source === 'ace'); + if (head < 0) head = ace.heads.length - 1; + openDialog({ + title: 'Head mode', + build: (bd) => { + bd.appendChild(el('p', 'ms-note', + 'One toolhead is on the cabinet; the other three keep their stock feeders.')); + const cmd = el('div', 'verb-cmd', `SET_ACE_MODE MODE=head HEAD=${head}`); + const rows = []; + ace.heads.forEach((h, k) => { + const r = el('button', 'hrow'); + r.type = 'button'; + r.setAttribute('aria-pressed', k === head ? 'true' : 'false'); + r.appendChild(el('span', null, `Toolhead ${k + 1}`)); + if (h.source === 'ace') r.appendChild(el('span', 'hsub', 'on the cabinet now')); + r.onclick = () => { + head = k; + rows.forEach((x, n) => x.setAttribute('aria-pressed', n === k ? 'true' : 'false')); + cmd.textContent = `SET_ACE_MODE MODE=head HEAD=${head}`; + }; + rows.push(r); + bd.appendChild(r); + }); + if (change.needsRestart) { + bd.appendChild(el('p', 'ms-note', + 'Every toolhead has to be unloaded first, and the printer restarted afterwards.')); + } + bd.appendChild(cmd); + }, + confirmLabel: 'Switch', + onConfirm: () => { ctx.handlers.setAceMode('head', head); return true; }, + }); +} + +/** What the header reports about the machine, beside the panel's own title. */ +export function aceStatus(ctx) { + const ace = ctx.state.ace(); + if (!ace.present) return 'no ACE'; + const bits = [ace.unitCount === 0 ? 'no ACE unit' + : ace.unitCount === 1 ? '1 ACE unit' + : `${ace.unitCount} ACE units · ${ace.unitCount * 4} bays`]; + if (ace.mode && ace.mode !== 'head') bits.push(`${ace.mode} mode`); + if (ace.swapping) bits.push('swapping'); + return bits.join(' · '); +} + +/** + * The pill, and the one state it has that is not simply the mode. + * + * A normal-mode switch writes the saved `ace__mode` and then raises, so `ace.mode` goes on + * reporting the old mode until the machine is restarted. The pill says both, because + * "Normal" alone would claim something that is not true yet and "Head" alone would hide a + * switch that worked. + */ +export function aceModeLabel(ctx) { + const ace = ctx.state.ace(); + const owed = acePendingMode(ace); + if (owed) { + return `ACE mode · ${ACE_MODE_LABELS[ace.mode] || '—'} → ` + + `${ACE_MODE_LABELS[owed] || owed} · restart to finish`; + } + const m = ctx.pending.resolve('ace-mode', ace.mode).value; + return `ACE mode · ${ACE_MODE_LABELS[m] || '—'}`; +} + +/* ---------------------------------------------------------------- * + * loading a bay + * ---------------------------------------------------------------- */ + +/** + * Clicking a bay loads it, behind a confirmation. + * + * A swap is a physical operation of about three minutes that purges filament, and the + * bays sit under the pointer while someone is reading the card. The dialog names the + * macro and its arguments, so what is about to happen is legible before it happens. + */ +/** + * A bay's own sheet: what can be done with this filament, and what would be sent. + * + * This is the confirmation the panel already opened, grown into the thing the study + * settled on. Where a verb LIVES was the open question and the answer split along the + * macros: `ACE_SWAP_HEAD` and `ACE_BG_SWAP` take a `SLOT=`, so the bay is the argument + * and clicking the argument is a shorter sentence than picking a verb and being asked + * for it afterwards. The head-level verbs stay in the card's menu, where they were. + * + * A swap is minutes of physical work that purges filament, and the bays sit under the + * pointer while someone reads the card - so nothing here fires on the click that opened + * it. + */ +/* + * A bay's own sheet: what is in it, and the one verb a bay is the whole argument for. + * + * A SWAP is not offered here. It reads as an operation on the filament - "swap this + * spool" - when what it does is move a TOOLHEAD from one bay to another, and the toolhead + * is the thing it addresses: both macros it sends take `HEAD=n`. The toolhead's own sheet + * brings every bay to it and labels each with what it would do, which is the same choice + * made where the target is named. Loading an empty head from a bay stays: the bay is the + * argument and there is no other end to it. + */ +function baySheet(ctx, i, u, b) { + const ace = ctx.state.ace(); + const loaded = headLoaded(ace, ctx.state.filaments(), i); + // Which unit, because outside head mode a card draws one bay of each and the bay alone + // no longer says which cabinet it is in. + const verbs = aceVerbs(ace, i, b.index, loaded, u.index) + .filter((v) => v.slotted && !/^(Swap|Background swap)/.test(v.name)); + + openDialog({ + title: b.addr, + build: (bd) => { + bd.appendChild(bayIdentity(b)); + if (!verbs.length) { + // Not an error and not an empty dialog: say which state this is, because + // "nothing to do here" is the answer. A head already holding filament is a swap, + // and a swap is chosen at the toolhead. + const h = ace.heads[i] || {}; + bd.appendChild(el('p', 'ms-note', (b.index === h.bay && u.index === h.unitIndex) + ? `Already feeding Toolhead ${i + 1}.` + : `Toolhead ${i + 1} is loaded.`)); + return; + } + bd.appendChild(verbList(ctx, verbs, i)); + }, + confirmLabel: 'Close', + cancel: false, + onConfirm: () => true, + }); +} + +/** + * The toolhead's own sheet: everything that can be done to THIS head. + * + * The head is what every one of these macros addresses, so it is a defensible thing to + * click - and it is the only target that does not shrink with the panel. What it has to + * solve that a bay's sheet does not is that two of the verbs take a `SLOT=` and a head is + * not a slot. So the bays come to the sheet, one button each, and each says what clicking + * it would do IN THIS STATE: load into an empty head, swap into a loaded one, and nothing + * at all for the bay that is already feeding. + */ +function headSheet(ctx, i, unit) { + const ace = ctx.state.ace(); + const h = ace.heads[i]; + const slots = ctx.state.filaments(); + const f = slots[i]; + const loaded = headLoaded(ace, slots, i); + const spool = loaded ? (unit ? h.loaded || f : f) : null; + /* + * Which bays come to the sheet, and it is the same question the card's box answers. + * + * Head mode: this head's unit, all four - a hub, and the bay is the choice. Outside it: + * this head's LANE, one bay per unit - the bay index is fixed by the plumbing and what + * is left to choose is which cabinet. With one unit that is not a choice either, and the + * row is one button, which is honest rather than empty. + */ + const bays = !unit ? [] + : h.lane == null + ? mergeAceBays(unit, ctx.store.aceBays).map((b) => ({ b, unit })) + : ace.units.map((u) => ({ b: mergeAceBays(u, ctx.store.aceBays)[h.lane], unit: u })) + .filter((x) => x.b); + + openDialog({ + title: `Toolhead ${i + 1}`, + build: (bd) => { + bd.appendChild(headIdentity(spool, unit, h)); + + if (unit) { + bd.appendChild(el('p', 'ms-note', h.bay == null ? 'Load from:' : 'Swap to:')); + const row = el('div', 'pickrow'); + bays.forEach(({ b, unit: bu }) => { + // Each bay's own answer, from the same list the bay sheet reads. A bay that is + // already feeding is not a verb, so its button says so and does nothing. + const v = aceVerbs(ace, i, b.index, loaded, bu.index) + .filter((x) => x.slotted && !x.bg)[0]; + const btn = el('button', 'pickbay'); + btn.type = 'button'; + const d = el('span', 'pickdisc', b.addr); + const col = b.occupied ? (b.known ? cssColor(b.color) : '#B7BDC6') : null; + if (col) { d.style.background = col; d.style.color = isDarkColor(col) ? '#fff' : '#333'; } + btn.appendChild(d); + btn.appendChild(el('span', 'picklab', + !b.occupied ? 'empty' : v ? v.name : 'feeding')); + btn.disabled = !v || !!v.off; + btn.title = v ? (v.off ? `${v.name} — ${v.off}` : `${v.name} from ${b.addr}`) + : `${b.addr} is already feeding this toolhead`; + // Close FIRST: a non-background verb opens a blocking dialog of its own, and + // closing after would shut the one just opened. + if (v && !v.off) btn.onclick = () => { closeDialog(); ctx.handlers.runVerb(v); }; + row.appendChild(btn); + }); + bd.appendChild(row); + } + + // and the verbs that address the head itself, which take no slot + const rest = aceVerbs(ace, i, unit ? h.bay : null, loaded).filter((v) => !v.slotted); + if (rest.length) bd.appendChild(verbList(ctx, rest, i)); + }, + confirmLabel: 'Close', + cancel: false, + onConfirm: () => true, + }); +} + +/** What the head is holding, and where it comes from. */ +function headIdentity(spool, unit, h) { + const row = el('div', 'verb-id'); + const disc = el('span', 'verb-disc', + unit && h.bay != null ? aceBayAddr(h.unitIndex, h.bay) : ''); + const col = spool ? cssColor(spool.color || spool.colour) : null; + if (col) { disc.style.background = col; disc.style.color = isDarkColor(col) ? '#fff' : '#333'; } + row.appendChild(disc); + const text = el('div', 'verb-idtext'); + const mat = spool && (spool.material || spool.type); + text.appendChild(el('div', null, mat + ? [mat, spool.subType].filter(Boolean).join(' ') : 'Nothing loaded')); + text.appendChild(el('span', null, spool + ? [spool.vendor, unit ? `ACE ${unit.id}` : 'stock feeder'].filter(Boolean).join(' · ') + : (unit ? `ACE ${unit.id}` : 'stock feeder'))); + row.appendChild(text); + return row; +} + +/** What is in the bay, drawn the way the bay draws it. */ +function bayIdentity(b) { + const row = el('div', 'verb-id'); + const disc = el('span', 'verb-disc', b.addr); + const col = b.occupied ? (b.known ? cssColor(b.color) : '#B7BDC6') : null; + if (col) { disc.style.background = col; disc.style.color = isDarkColor(col) ? '#fff' : '#333'; } + row.appendChild(disc); + const text = el('div', 'verb-idtext'); + text.appendChild(el('div', null, b.occupied + ? [b.material, b.subType].filter(Boolean).join(' ') || 'Occupied, not named' + : 'Empty')); + text.appendChild(el('span', null, [b.vendor, b.sku].filter(Boolean).join(' · ') + || (b.occupied ? (PROV[b.source] || PROV.unknown).word : 'Nothing to load.'))); + row.appendChild(text); + return row; +} + +/** + * One row per verb: what it is, and the line it would send. + * + * The macro is not an explanation - it is the thing that will go out - which is the one + * piece of prose this panel's copy rule allows in a dialog. An unavailable verb names the + * macro that would MAKE it available instead, and offers to send it: that is how a head + * is declared background-capable on a machine, so it is how it is declared here. + */ +/* + * A toolhead's verbs. Each row is a name and, when it is refused, the reason. + * + * It used to carry the macro line under the name and in the hover. A macro name does say + * what will be sent - but on this surface it was the only thing under every row, so a + * dialog for moving filament read as a G-code console. The trace pane is where the wire + * belongs, and it already has it. + */ +function verbList(ctx, verbs, i) { + const wrap = el('div', 'verbs'); + verbs.forEach((v) => { + const row = el('button', 'verb' + (v.off ? ' is-off' : '')); + row.type = 'button'; + const main = el('div', 'verb-main'); + main.appendChild(el('div', 'verb-name', v.name)); + if (v.off) main.appendChild(el('div', 'verb-cmd', v.off)); + row.appendChild(main); + row.title = v.off || v.name; + row.disabled = !!v.off; + // Close FIRST - see the note in the bay picker above. + if (!v.off) row.onclick = () => { closeDialog(); ctx.handlers.runVerb(v); }; + if (v.off === NOT_DECLARED) { + // ACE_BG_SET_HEAD's own help says what declaring a head MEANS physically: its dock + // is open below, and the cold-pull purges through it. So this is not a convenience + // - it is the one control on this panel that can cost filament and a bed, and it + // asks separately rather than being folded into the verb. + const en = el('button', 'verb-gate', 'Enable for this toolhead'); + en.type = 'button'; + en.onclick = (e) => { e.stopPropagation(); ctx.handlers.declareBgHead(i); closeDialog(); }; + main.appendChild(en); + } + wrap.appendChild(row); + }); + return wrap; +} + +/* ---------------------------------------------------------------- * + * the dryer + * ---------------------------------------------------------------- */ + +/** + * Bambu's *AMS Dryness Control*, rebuilt in this page's own dialog chrome. + * + * Taken apart, that dialog answers four questions - how wet is it, is it drying, how + * hard, for how long - and it answers the first AS A QUANTITY, with a droplet filled to + * the reading, which is most of why it works. + * + * Two things it has not got and the U1 needs: `ACE_SET_AUTO_DRY`, which starts the dryer + * by itself above a humidity; and each of `ACE_DRY`'s two arguments offered as four + * presets PLUS one empty field, in the same row, because the macro takes a number and + * not a menu - a spool whose tag says 65 °C for 8 h should not have to be rounded to the + * nearest chip. A preset OR a typed value, never both: the moment the field holds a + * number the preset stops being highlighted, because the preset is no longer what would + * be sent. + * + * The running dialog is the SAME dialog. An amber droplet was tried and taken out - it + * made the reading you came for change meaning at the moment you came for it. + */ +function openDryer(ctx, u) { + // Auto-dry opens on the machine's own setting rather than on Off: it is reported, and + // a control that shows a value the machine does not hold is a control that lies. + const set = { temp: DRY_TEMPS[1], hours: DRY_HOURS[1], tempCustom: '', hoursCustom: '', + auto: u.autoDry.enabled ? u.autoDry.rhStart : null }; + const eff = (k) => (set[`${k}Custom`] !== '' ? Number(set[`${k}Custom`]) : set[k]); + const running = ctx.pending.resolve(`ace-dry-${u.index}`, u.dryer.running).value; + let cmdLine; + + /* + * What pressing Confirm will DO, restated as the dialog changes. + * + * It used to print the macro line, which was the honest thing while the numbers and the + * wire disagreed - the duration is offered in HOURS and `ACE_DRY` takes MINUTES, and a + * dialog offering 4 would have dried for four minutes. That is a reason to get the + * conversion right, which it now is, and not a reason to make the reader check the + * arithmetic in G-code. + */ + const summary = () => (running ? 'Stops drying now.' + : `Dries at ${eff('temp')} °C for ${hm(Math.round(eff('hours') * DRY_MINUTES_PER_HOUR))}` + + (set.auto ? `, and again automatically above ${set.auto} % RH.` + : ', and not automatically.')); + + openDialog({ + title: 'Filament drying', + wide: true, + build: (b) => { + b.classList.add('dryer'); + const sub = el('p', 'dry-sub', + `ACE ${u.id} · ${[u.model, u.firmware].filter(Boolean).join(' ')}`); + b.appendChild(sub); + + const cols = el('div', 'dry-cols'); + const c1 = el('div', 'dry-c1'); + const drop = el('div', 'dry-drop'); + drop.appendChild(droplet(u.humidity)); + const lvl = humidityLevel(u.humidity); + drop.title = `${num(u.humidity, '%')} RH`; + c1.appendChild(drop); + // One word, whatever the state. "Drying · 1 h 19 m / 4 h" wraps in this column and + // grows the block under the droplet, which reads as the droplet having moved. + c1.appendChild(el('div', 'dry-state', running ? 'Drying' : 'Idle')); + const reads = el('div', 'dry-reads'); + reads.title = `Inside ACE ${u.id}, not the room`; + [['Humidity', num(u.humidity, '%')], + ['Temperature', num(u.temperature, '°C')]].forEach(([k, v]) => { + const r = el('div', 'dry-read'); + r.appendChild(el('span', 'dry-k', k)); + r.appendChild(el('span', 'dry-v', v)); + reads.appendChild(r); + }); + c1.appendChild(reads); + cols.appendChild(c1); + + const c2 = el('div', 'dry-c2'); + const fields = []; + fields.push(valueField('Temperature', DRY_TEMPS, 'temp', ' °C', DRY_LIMITS.temp)); + fields.push(valueField('Duration', DRY_HOURS, 'hours', ' h', DRY_LIMITS.hours)); + fields.forEach((f) => c2.appendChild(f.node)); + + const auto = el('div', 'dry-field'); + auto.appendChild(el('span', 'dry-k', 'Automatically, above')); + const seg = el('span', 'dry-seg'); + [null].concat(AUTO_DRY_THRESHOLDS).forEach((v) => { + const o = el('span', 'dry-opt', v == null ? 'Off' : `${v} %`); + if (v === set.auto) o.classList.add('is-on'); + o.onclick = () => { + set.auto = v; + Array.from(seg.children).forEach((x) => x.classList.remove('is-on')); + o.classList.add('is-on'); + paint(); + }; + seg.appendChild(o); + }); + auto.appendChild(seg); + c2.appendChild(auto); + cols.appendChild(c2); + b.appendChild(cols); + + if (running && u.dryer.doneMin != null && u.dryer.totalMin != null) { + b.appendChild(el('p', 'dry-note', + `${hm(u.dryer.doneMin)} of ${hm(u.dryer.totalMin)}` + + (u.dryer.target ? `, at ${u.dryer.target} °C.` : '.'))); + } + cmdLine = el('div', 'dry-cmd'); + b.appendChild(cmdLine); + paint(); + + function paint() { + fields.forEach((f) => f.paint()); + cmdLine.textContent = summary(); + } + + /** Four presets and one empty field, in one row, because it is one choice. */ + function valueField(label, presets, key, suffix, [min, max]) { + const f = el('div', 'dry-field'); + f.appendChild(el('span', 'dry-k', label)); + const g = el('span', 'dry-seg'); + const opts = presets.map((v) => { + const o = el('span', 'dry-opt', v + suffix); + o.onclick = () => { set[key] = v; set[`${key}Custom`] = ''; inp.value = ''; paint(); }; + g.appendChild(o); + return o; + }); + const inp = document.createElement('input'); + inp.className = 'dry-custom'; + inp.type = 'number'; + inp.min = String(min); + inp.max = String(max); + inp.placeholder = 'Custom'; + inp.setAttribute('aria-label', `${label}, custom value, ${min} to ${max}`); + inp.title = `${min}–${max}${suffix}`; + // Repainted without rebuilding, so the field does not lose focus mid-keystroke - + // the same reason core/render.js exists on the rest of the page. + inp.oninput = () => { set[`${key}Custom`] = inp.value.trim(); paint(); }; + inp.onchange = () => { + if (inp.value.trim() !== '') { + // Past what the macro takes is clamped on commit, not refused. + inp.value = String(Math.min(max, Math.max(min, Number(inp.value)))); + set[`${key}Custom`] = inp.value; + } + paint(); + }; + g.appendChild(inp); + f.appendChild(g); + return { + node: f, + paint: () => { + const custom = set[`${key}Custom`] !== ''; + opts.forEach((o, k) => o.classList.toggle('is-on', !custom && presets[k] === set[key])); + inp.classList.toggle('is-on', custom); + }, + }; + } + }, + confirmLabel: running ? 'Stop' : 'Start', + onConfirm: () => { + if (running) { ctx.handlers.stopDrying(u.index); return true; } + ctx.handlers.startDrying(u.index, eff('temp'), eff('hours')); + // Only when it differs from what the machine already holds - the dialog opens on + // its value, so sending it back on every Start would be noise. + const was = u.autoDry.enabled ? u.autoDry.rhStart : null; + if (set.auto !== was) ctx.handlers.setAutoDry(u.index, set.auto); + return true; + }, + }); +} + +/** + * A droplet filled to the reading. + * + * Orca buckets the same number into `hum_level1..5` to pick a glyph; this draws it + * continuously and says the bucket in the title. The fill is a rectangle clipped to the + * outline, and the clip id is unique per instance: a clipPath id resolves DOCUMENT-wide, + * and two droplets sharing one had the second point into the first - which clips + * nothing, so the fill drew as the bare rectangle it is. + */ +let dropSeq = 0; +function droplet(humidity) { + const s = svg('svg', { width: 88, height: 104, viewBox: '0 0 88 104', + 'aria-hidden': 'true' }); + const d = 'M44 6 C44 6 12 44 12 66 a32 32 0 0 0 64 0 C76 44 44 6 44 6 Z'; + const cid = `dry-clip-${++dropSeq}`; + const defs = svg('defs', {}); + const cp = svg('clipPath', { id: cid }); + cp.appendChild(svg('path', { d })); + defs.appendChild(cp); + s.appendChild(defs); + s.appendChild(svg('path', { d, fill: '#F5F6FA', stroke: '#C9CDD2', 'stroke-width': 1.5 })); + const h = Math.max(0, Math.min(100, Number(humidity) || 0)); + const y = 104 - (h / 100) * 72; + s.appendChild(svg('rect', { x: 0, y, width: 88, height: 104 - y, fill: '#7FB6E8', + 'clip-path': `url(#${cid})` })); + s.appendChild(svg('path', { d, fill: 'none', stroke: '#C9CDD2', 'stroke-width': 1.5 })); + return s; +} + +/* ---------------------------------------------------------------- * + * the two badges + * ---------------------------------------------------------------- */ + +/** + * The feeder module's badge: the frame at badge size, white over black, on a rounded + * SQUARE. The ACE's is 44x26 because a cabinet is wide; the module is not a cabinet, and + * a wide badge for it only ever read as a squashed ACE. + */ +function moduleBadge() { + const s = svg('svg', { width: 17, height: 17, viewBox: '0 0 24 24', + 'aria-hidden': 'true', class: 'ace-badge ace-modbadge' }); + s.appendChild(svg('rect', { x: 1, y: 1, width: 22, height: 22, rx: 5, fill: '#FFFFFF' })); + s.appendChild(svg('path', { + d: 'M1 13 h22 v5 a5 5 0 0 1 -5 5 h-12 a5 5 0 0 1 -5 -5 Z', fill: '#1F1F1F' })); + s.appendChild(svg('rect', { x: 1, y: 1, width: 22, height: 22, rx: 5, fill: 'none', + stroke: '#D9DDE3', 'stroke-width': 1 })); + return s; +} + +/** The eye, the pencil and the tag, inline so they take the mark's own colour on hover. */ +function glyph(kind) { + const s = svg('svg', { width: 13, height: 13, viewBox: '0 0 20 20', + 'aria-hidden': 'true', class: 'ace-glyph' }); + const g = svg('g', { fill: 'none', stroke: 'currentColor', 'stroke-width': 1.3, + 'stroke-linejoin': 'round' }); + if (kind === 'tag') { + /* + * The contactless arcs, which are what an RFID read looks like everywhere else. + * + * Not a luggage-tag outline: at 13 px a tag shape is a rounded rectangle with a dot + * in it and reads as nothing. Three arcs off a point read as "this was read over the + * air" at any size, which is the fact being drawn. + */ + g.setAttribute('stroke-linecap', 'round'); + g.appendChild(svg('circle', { cx: 5.6, cy: 10, r: 1.15, fill: 'currentColor', + stroke: 'none' })); + [4.2, 7.6, 11].forEach((r) => g.appendChild(svg('path', { + d: `M${5.6 + r * 0.5} ${10 - r * 0.866} A${r} ${r} 0 0 1 ${5.6 + r * 0.5} ${10 + r * 0.866}` }))); + s.appendChild(g); + return s; + } + if (kind === 'eye') { + g.appendChild(svg('path', { d: 'M1.8 10S4.7 5.2 10 5.2 18.2 10 18.2 10 15.3 14.8 10 14.8 1.8 10 1.8 10Z' })); + g.appendChild(svg('circle', { cx: 10, cy: 10, r: 2.4 })); + } else { + g.appendChild(svg('path', { d: 'M13.4 3.6l3 3L7.6 15.4l-3.9.9.9-3.9z' })); + } + s.appendChild(g); + return s; +} + +/* ---------------------------------------------------------------- * + * the filament slot editor + * ---------------------------------------------------------------- */ + +/** + * Filament slot editor. + * + * Laid out after Bambu Studio's "Materials Setting": what the filament IS at the top, + * then what the machine knows about how to run it. The lower half is read-only on + * purpose - nozzle limits come off the spool's RFID tag and pressure advance is + * Klipper's own calibration, so presenting them as editable would be a lie. Slots + * without a tag simply omit that block rather than showing a grid of zeros. + */ +function editSlot(index, f, handlers) { + let type, vendor, color; + const tag = f.tag; + /* + * Whether this sheet is a form or a reading, and the machine decides. + * + * It was always a form. A tagged head wore the eye, its menu row said *View this + * filament* and *Read only - the spool carries its own record*, the sheet headed a + * block *From the spool tag* - and then offered three editable fields and a Confirm + * that wrote over them. Every word promised one thing and the buttons did the other. + * + * `print_task_config.filament_edit` is the printer's own permission bit; see + * headProv(). Read-only here means no inputs at all, not disabled ones: there is + * nothing to type, so a field to type in is the wrong drawing. + */ + const editable = !!f.editable; + + const row = (parent, label, value, hint) => { + const r = el('div', 'ms-row'); + r.appendChild(el('span', 'ms-key', label)); + const v = el('span', 'ms-val', value); + if (hint) v.title = hint; + r.appendChild(v); + parent.appendChild(r); + return r; + }; + + openDialog({ + title: 'Materials Setting', + build: (b) => { + b.classList.add('materials'); + + // --- identity --- + if (!editable) { + // The same three values the form would offer, as readings. `f.loaded` rather + // than a tag: an empty slot has nothing to show and nothing to change either. + const ro = el('div', 'ms-block'); + if (f.loaded) { + row(ro, 'Filament', [f.type, f.subType].filter(Boolean).join(' ') || '—'); + row(ro, 'Vendor', f.vendor || '—'); + const c = row(ro, 'Color', cssColor(f.color) || '—'); + const sw = el('span', 'ms-swatch'); + sw.style.background = cssColor(f.color) || '#CCCCCC'; + c.lastChild.prepend(sw); + } else { + ro.appendChild(el('div', 'ms-note', 'Nothing is loaded at this toolhead.')); + } + b.appendChild(ro); + buildRest(b); + return; + } + + const id = el('div', 'ms-block'); + const tRow = el('label', 'field'); + tRow.appendChild(el('span', 'field-label', 'Filament')); + const tWrap = el('div', 'field-row'); + type = document.createElement('input'); + type.value = f.type || ''; + type.placeholder = 'PLA'; + type.setAttribute('list', 'ms-types'); + tWrap.appendChild(type); + const dl = document.createElement('datalist'); + dl.id = 'ms-types'; + ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'] + .forEach((v) => { const o = document.createElement('option'); o.value = v; dl.appendChild(o); }); + tWrap.appendChild(dl); + tRow.appendChild(tWrap); + id.appendChild(tRow); + + const vRow = el('label', 'field'); + vRow.appendChild(el('span', 'field-label', 'Vendor')); + const vWrap = el('div', 'field-row'); + vendor = document.createElement('input'); + vendor.value = f.vendor || ''; + vendor.placeholder = 'Generic'; + vWrap.appendChild(vendor); + vRow.appendChild(vWrap); + id.appendChild(vRow); + + const cRow = el('label', 'field'); + cRow.appendChild(el('span', 'field-label', 'Color')); + const cWrap = el('div', 'field-row ms-color'); + color = document.createElement('input'); + color.type = 'color'; + color.value = cssColor(f.color) || '#CCCCCC'; + const swatch = el('span', 'ms-swatch'); + swatch.style.background = color.value; + color.oninput = () => { swatch.style.background = color.value; }; + cWrap.appendChild(swatch); + cWrap.appendChild(color); + cRow.appendChild(cWrap); + id.appendChild(cRow); + b.appendChild(id); + + buildRest(b); + }, + // A sheet that only tells you something offers Close alone: Cancel and Confirm are + // both wrong words for a reading, and `cancel: false` is what the dryer's own + // read-only sheet uses. + cancel: editable, + confirmLabel: editable ? 'Confirm' : 'Close', + onConfirm: () => { + if (!editable) return true; + return handlers.setFilament(index, type.value.trim(), color.value, + vendor.value.trim()); + }, + }); + + // Everything below the identity, which is read-only in both shapes: the tag's own + // temperatures are the spool's, pressure advance is Klipper's calibration, and the + // feed path is a reading of where the filament is. + function buildRest(b) { + // --- what the spool says about itself --- + if (tag) { + b.appendChild(el('h4', 'ms-head', 'From the spool tag')); + const g = el('div', 'ms-block'); + if (tag.subType) row(g, 'Series', tag.subType); + row(g, 'Nozzle Temperature', + (tag.nozzleMin != null && tag.nozzleMax != null) + ? `${tag.nozzleMin} – ${tag.nozzleMax} °C` : '—', + 'min and max reported by the spool'); + if (tag.bedTemp) row(g, 'Bed Temperature', `${tag.bedTemp} °C`); + if (tag.dryingTemp) { + row(g, 'Drying', `${tag.dryingTemp} °C` + + (tag.dryingTime ? ` · ${tag.dryingTime} h` : '')); + } + b.appendChild(g); + } else if (f.loaded) { + b.appendChild(el('div', 'ms-note', + 'This spool carries no RFID tag, so temperatures come from the profile.')); + } + + // --- flow dynamics: Klipper's pressure advance is the K-factor analogue --- + b.appendChild(el('h4', 'ms-head', 'Flow dynamics')); + const fd = el('div', 'ms-block'); + row(fd, 'Pressure Advance', + f.pressureAdvance != null ? f.pressureAdvance.toFixed(4) : '—', + 'Klipper’s pressure_advance for this toolhead - the same role Bambu’s Factor K plays'); + row(fd, 'Smooth Time', + f.smoothTime != null ? `${f.smoothTime.toFixed(3)} s` : '—'); + b.appendChild(fd); + + // --- ACE feed path --- + if (f.feed) { + b.appendChild(el('h4', 'ms-head', 'Feed path')); + const fp = el('div', 'ms-block'); + row(fp, 'Channel', f.feed.channelState || '—'); + row(fp, 'Detected', f.feed.detected ? 'yes' : 'no'); + row(fp, 'At extruder', f.feed.atExtruder ? 'yes' : 'no'); + if (f.feed.error) { + const e = row(fp, 'Error', f.feed.error); + e.dataset.severity = 'error'; + } + b.appendChild(fp); + } + } +} + +/* ---- small things -------------------------------------------------- */ + +const NS = 'http://www.w3.org/2000/svg'; +function svg(tag, attrs) { + const n = document.createElementNS(NS, tag); + Object.entries(attrs || {}).forEach(([k, v]) => n.setAttribute(k, v)); + return n; +} + +const r1 = (v) => Math.round(v * 10) / 10; + +/** A reading the machine did not send is a dash, never a zero. */ +const num = (v, unit) => (v == null ? '—' : `${v} ${unit}`); + +/** + * `2 h 41 m`, `41 m` under the hour, and `4 h` on the hour. + * + * The last one is not tidiness: the chip shares a 391px cell with the unit's name, its + * model and a humidity pill, and `4 h 0 m` costs 26px that pushes the model into an + * ellipsis for a reading that says nothing. + */ +function hm(min) { + const m = Math.max(0, Math.round(Number(min) || 0)); + if (m < 60) return `${m} m`; + return m % 60 ? `${Math.floor(m / 60)} h ${m % 60} m` : `${Math.floor(m / 60)} h`; +} diff --git a/resources/web/device_page/js/views/device-control/task/task-commands.js b/resources/web/device_page/js/views/device-control/task/task-commands.js new file mode 100644 index 00000000000..36502f32dff --- /dev/null +++ b/resources/web/device_page/js/views/device-control/task/task-commands.js @@ -0,0 +1,77 @@ +/* + * commands/task.js - Commands the Printing Task card issues: pause, resume, cancel, and the + * thumbnail it shows. + * + * check_coverage.py reads the CMD references out of this file to answer "can a user + * reach this command", and attributes them to the task panel because this is the module + * that panel is handed. That makes the attribution a fact about the imports rather than + * a promise in a declaration - which is the difference that let a handler nothing called + * count as implemented for as long as it did. + */ +'use strict'; + +import { CMD } + from '../../../../../shared/js/protocol.js'; +import { openDialog } from '../../../core/overlay.js'; +import { pickThumb } from '../../../core/thumbs.js'; + +export function create(deps) { + // `bridge` is deliberately NOT destructured: it does not exist yet when these are + // built - boot() decides between the real host and the simulator - so it is reached + // through deps each time rather than captured as null once. + const { state, store, pending, session, cmd, + send, setpoint, setStatus, render } = deps; + + /** + * Fetch the printing file's thumbnail, once. + * + * Keyed on the filename, so a card repainting every second asks the printer nothing. + * `sw_FilesThumbnailsBase64` is the only command that returns bytes - the other one + * returns paths, which is what made thumbnails silently absent before. + */ + function refreshJobThumb(file) { + if (store.jobThumb.file === (file || null)) return; + store.jobThumb = { file: file || null, data: null }; + if (!file) return; + const asked = file; + deps.bridge.request(CMD.FILE_THUMBS_B64, { path: file }) + .then((r) => { + if (store.jobThumb.file !== asked) return; // the job moved on while we asked + store.jobThumb.data = pickThumb(r); + if (store.jobThumb.data) render(); + }) + .catch(() => { /* no thumbnail is a normal answer; the card shows its placeholder */ }); + } + + return { + pause: () => send(CMD.PRINT_PAUSE, {}, 'pause'), + + resume: () => send(CMD.PRINT_RESUME, {}, 'resume'), + + cancel: () => send(CMD.PRINT_CANCEL, {}, 'cancel'), + + confirmCancel: () => openDialog({ + title: 'Cancel this print?', + build: (b) => { + const p = document.createElement('p'); + p.style.cssText = 'margin:4px 0 6px;font-size:13px;line-height:1.55;color:#39434F'; + p.textContent = 'The job stops immediately and cannot be resumed.'; + b.appendChild(p); + }, + confirmLabel: 'Cancel print', + onConfirm: () => { cmd.cancel(); }, + }), + + /* ---- machine store.files ---- */ + + // Storage's Prints, since the print-files tab that used to be the target was the + // same list twice - see KINDS in storage-panel.js. Reprint is the verb there. + showFiles: () => { store.storageKind = 'prints'; cmd.showView('storage'); }, + + /* ---- print job ---- */ + + refreshJobThumb: (f) => refreshJobThumb(f), + + /** The print-task options the header pill edits, straight onto print_task_config. */ + }; +} diff --git a/resources/web/device_page/js/views/device-control/task/task-panel.js b/resources/web/device_page/js/views/device-control/task/task-panel.js new file mode 100644 index 00000000000..5cc23d6ba02 --- /dev/null +++ b/resources/web/device_page/js/views/device-control/task/task-panel.js @@ -0,0 +1,44 @@ +/* + * The Printing Task card. + * + * The thumbnail is fetched once per file rather than per repaint - this card repaints + * about once a second, and `sw_FilesThumbnailsBase64` is a round trip to the printer. + * + * The status word lives in the header rather than in the card. It was a badge on a row + * of its own, above a second row repeating the machine's name that the rail already + * shows - two rows of furniture around one word. A panel header is where a panel says + * what it is; saying what it is DOING in the same line costs nothing and gives the card + * back 24px plus its margin. + */ +'use strict'; + +import { renderTask, stateLabel } from './task-view.js'; + +export default { + id: 'task', + title: 'Printing Task', + view: 'control', + column: 'main', + bodyId: 'task', + bodyClass: 'task-body', + + header: [ + { kind: 'gap' }, + { kind: 'status', id: 'task-state', cls: 'job-badge', + text: (ctx) => stateLabel(ctx.state.job().state), + state: (ctx) => ctx.state.job().state, + // The badge already carries `stateLabel()`, which is the machine's word in words. + // The hover used to carry the raw one beside it - `print_stats.state: standby` - so + // that the rename to `idle` "hid nothing"; translating is not hiding, and the field + // name was the only thing the second copy added. + }, + { kind: 'spacer' }, + ], + + reads: ['job', 'device', 'store.jobThumb'], + + update(root, ctx) { + ctx.handlers.refreshJobThumb(ctx.state.job().filename); + renderTask(root, ctx.state.job(), ctx.handlers, ctx.store.device, ctx.store.jobThumb.data); + }, +}; diff --git a/resources/web/device_page/js/views/device-control/task/task-view.js b/resources/web/device_page/js/views/device-control/task/task-view.js new file mode 100644 index 00000000000..0955d336997 --- /dev/null +++ b/resources/web/device_page/js/views/device-control/task/task-view.js @@ -0,0 +1,181 @@ +/* + * The Printing Task card's DOM. + * + * A band, not a stack. The card was 306px tall in a panel 766px wide, and it spent that + * on a 152px thumbnail, a 40px grey percentage, a row carrying the machine's own name - + * which the rail already shows - and a row of its own for two buttons. Read as four + * blocks down the page; drawn to a shape that was right when every panel was one cell of + * an equal 2x2 grid. + * + * It is 127 now, and the three moves are the ones in Bambu's own progress card: + * + * the thumbnail ANCHORS the block instead of sitting above the bar + * the metadata joins the filename's line instead of taking one + * the buttons ride the BAR's line - a 6px bar and a 30px button cost 70 stacked + * and 30 side by side, which is the single biggest saving here + * + * And the status word moved to the panel header, where a panel says what it is and what + * it is doing on one line. See task-panel.js. + * + * One thing that did NOT change, because a conformance check holds it: this is ONE card + * at every state, zeroed rather than replaced. An idle machine and a printing one differ + * in the numbers, not in the furniture. + */ +'use strict'; + +import { $, el } from '../../../core/dom.js'; +import { PRINT_STATE } from '../../../../../shared/js/protocol.js'; +import { rebuildOn, text, attr } from '../../../core/render.js'; + +/** + * What the header badge says. + * + * Klipper calls a machine with no job `standby`; the shipped page shows `idle`. Exported + * because the badge is declared in task-panel.js now and the mapping belongs with the + * rest of the card's language rather than with its declaration. + */ +export function stateLabel(st) { + return st === PRINT_STATE.STANDBY ? 'idle' : (st || PRINT_STATE.UNKNOWN); +} + +export function renderTask(root, job, handlers, device, thumb) { + const active = job.state === PRINT_STATE.PRINTING || job.state === PRINT_STATE.PAUSED; + const paused = job.state === PRINT_STATE.PAUSED; + + // The card's *shape*: which controls exist and whether there is a thumbnail or a + // message at all. The numbers move every second and the shape does not, which is the + // whole point - this used to rebuild the card, the buttons and the image once a + // second, so a click could land on a node that was about to be replaced. + rebuildOn(root, `${active ? 1 : 0}:${paused ? 1 : 0}:${thumb ? 1 : 0}:${job.message ? 1 : 0}`, + () => { + const wrap = el('div', 'job'); + + const tw = el('div', 'job-thumb'); + if (thumb) { + tw.appendChild(el('img')); + } else { + // The bundle's own empty-box art, not the not-connected device illustration - + // which answers "there is no printer" rather than "there is no file". + const ph = el('img', 'job-thumb-ph'); + ph.src = 'icons/empty-box.png'; + ph.alt = ''; + tw.appendChild(ph); + } + wrap.appendChild(tw); + + const col = el('div', 'job-col'); + + // row 1: what is printing, and what it has cost so far + const r1 = el('div', 'job-row'); + r1.appendChild(el('div', 'job-name')); + const meta = el('div', 'job-meta'); + // Elapsed and filament used, because those are what `print_stats` actually + // reports. Bambu's card shows grams here; grams need a diameter and a density + // this page is not told, so metres of filament is the same fact without the guess. + meta.appendChild(el('span', 'job-elapsed')); + meta.appendChild(el('span', 'job-used')); + r1.appendChild(meta); + col.appendChild(r1); + + // row 2: how far, and how far to go + const r2 = el('div', 'job-row'); + r2.appendChild(el('div', 'job-pct')); + const nums = el('div', 'job-nums'); + // Layers read as a pair; a machine that reports neither shows the pair as zero, + // which is what the original does rather than hiding the row. + nums.appendChild(el('span', 'job-layer')); + nums.appendChild(el('span', 'job-time')); + r2.appendChild(nums); + col.appendChild(r2); + + // row 3: the bar, and the buttons that act on it + const r3 = el('div', 'job-row'); + const bar = el('div', 'job-bar'); + bar.appendChild(el('div')); + r3.appendChild(bar); + + const btns = el('div', 'job-actions'); + // Idle is not a dead end. The card's one useful action with nothing running is to + // go and find something to print, which is what Storage is for - the button used + // to be present, disabled, and titled "Nothing to start here". + btns.appendChild(roundBtn( + paused || !active ? 'play' : 'pause', + paused ? 'Resume the print' + : (active ? 'Pause the print' : 'Choose a file to print'), + () => (active ? (paused ? handlers.resume() : handlers.pause()) + : handlers.showFiles()))); + if (active) { + btns.appendChild(roundBtn('stop', 'Cancel the print', + () => handlers.confirmCancel())); + } + r3.appendChild(btns); + col.appendChild(r3); + + if (job.message) col.appendChild(el('div', 'job-msg')); + wrap.appendChild(col); + root.appendChild(wrap); + }); + + // ---- the numbers, which is all that moves ------------------------------ + if (thumb) { + attr(root.querySelector('.job-thumb img'), 'src', + thumb.startsWith('data:') ? thumb : `data:image/png;base64,${thumb}`); + } + + const pct = Math.round(job.progress * 100); + const nameEl = root.querySelector('.job-name'); + const name = job.filename ? (job.filename.split('/').pop() || job.filename) : '—'; + text(nameEl, name); + // The row ellipsises, so the whole name has to be reachable somehow. + nameEl.title = job.filename || ''; + text(root.querySelector('.job-elapsed'), hm(job.printDuration)); + text(root.querySelector('.job-used'), metres(job.filamentUsed)); + text(root.querySelector('.job-pct'), `${pct}%`); + text(root.querySelector('.job-layer'), + `Layer ${job.layer ?? 0}/${job.totalLayer ?? 0}`); + text(root.querySelector('.job-time'), `— ${hm(remaining(job))}`); + root.querySelector('.job-bar > div').style.width = `${pct}%`; + if (job.message) text(root.querySelector('.job-msg'), job.message); +} + +/** `0h 0m`, the way the shipped card writes it - clock() pads the minutes and this + * row does not. */ +function hm(sec) { + const t = Math.max(0, Math.round(Number(sec) || 0)); + return `${Math.floor(t / 3600)}h ${Math.floor((t % 3600) / 60)}m`; +} + +/** `print_stats.filament_used` is millimetres of filament. Metres, to one decimal. */ +function metres(mm) { + const m = Math.max(0, Number(mm) || 0) / 1000; + return `${m.toFixed(1)} m`; +} + +/** What is left, from what has run and how far it has got. Zero when unknowable. */ +function remaining(job) { + if (!(job.progress > 0) || !(job.printDuration > 0)) return 0; + return Math.max(0, job.printDuration * (1 - job.progress) / job.progress); +} + +/** One round control, the shape the original uses for print actions. */ +function roundBtn(kind, title, onClick) { + const b = el('button', 'job-btn'); + b.type = 'button'; + b.dataset.kind = kind; + b.title = title; + b.setAttribute('aria-label', title); + b.innerHTML = { + play: '', + pause: '', + stop: '', + }[kind]; + b.onclick = onClick; + return b; +} + +/* ---- print history --------------------------------------------------- */ + +/** Klipper's own job outcomes; anything else is shown verbatim. */ diff --git a/resources/web/device_page/js/views/fault/fault-commands.js b/resources/web/device_page/js/views/fault/fault-commands.js new file mode 100644 index 00000000000..45708397f1d --- /dev/null +++ b/resources/web/device_page/js/views/fault/fault-commands.js @@ -0,0 +1,36 @@ +/* + * commands/fault.js - The one command the fault banner issues: re-ask the machine what is wrong. + * + * check_coverage.py reads the CMD references out of this file to answer "can a user + * reach this command", and attributes them to the fault panel because this is the module + * that panel is handed. That makes the attribution a fact about the imports rather than + * a promise in a declaration - which is the difference that let a handler nothing called + * count as implemented for as long as it did. + */ +'use strict'; + +import { CMD } + from '../../../../shared/js/protocol.js'; + +export function create(deps) { + // `bridge` is deliberately NOT destructured: it does not exist yet when these are + // built - boot() decides between the real host and the simulator - so it is reached + // through deps each time rather than captured as null once. + const { state, store, pending, session, cmd, + send, setpoint, setStatus, render } = deps; + + + + return { + queryException: async () => { + try { + store.exception = await deps.bridge.request(CMD.EXCEPTION_QUERY, {}); + } catch (e) { + store.exception = null; + } + render(); + }, + + /** Everything the printer will tell us about itself, in one sheet. */ + }; +} diff --git a/resources/web/device_page/js/views/fault/fault-panel.js b/resources/web/device_page/js/views/fault/fault-panel.js new file mode 100644 index 00000000000..9a9bf42f623 --- /dev/null +++ b/resources/web/device_page/js/views/fault/fault-panel.js @@ -0,0 +1,25 @@ +/* + * The fault banner. + * + * Not a panel: it has no header and no body chrome, and it sits directly in `.content` + * above both destinations, because a fault is about the machine rather than about + * whichever view happens to be open. + */ +'use strict'; + +import { renderFault } from './fault-view.js'; + +export default { + id: 'fault', + bare: true, // no
/
; the body element is the whole thing + view: null, // above both destinations + bodyId: 'fault', + bodyClass: 'fault', + hiddenAtRest: true, + + reads: ['activity', 'store.exception'], + + update(root, ctx) { + renderFault(root, ctx.state.activity(), ctx.store.exception, ctx.handlers); + }, +}; diff --git a/resources/web/device_page/js/views/fault/fault-view.js b/resources/web/device_page/js/views/fault/fault-view.js new file mode 100644 index 00000000000..c131593ef70 --- /dev/null +++ b/resources/web/device_page/js/views/fault/fault-view.js @@ -0,0 +1,63 @@ +/* + * The fault banner's DOM. + */ +'use strict'; + +import { $, el, icon } from '../../core/dom.js'; +import { lookupFault } from '../../../../shared/js/errors.js'; +import { rebuildOn, data } from '../../core/render.js'; + +/** + * A fault comes from `server.exception.query`, and from nothing else. + * + * This used to fall back to `machine_state_manager.action_code`, on the stated belief + * that it "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 is "Auto Loading", 640 "Unloading", 832 "Homing Calibration...". So starting a load + * raised a red banner reading *Printer fault · code 0000000000000240 · not in the shipped + * catalogue*, which is `0x240` = 576, padded into a 16-digit fault code that could never + * match because it never was one. + * + * The two tables even overlap in value, which is why activity.js keeps them keyed apart + * and says so. Decoding an activity code against the fault catalogue is the same + * category error one level up. + */ +export function renderFault(root, activity, exception, handlers) { + const code = exception && (exception.code || exception.action_code); + const fault = lookupFault(code); + + // Clearing `built` matters: the guard used to be a `data-code` compared on the way in + // and never reset 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. A recurring fault is the ordinary case, not an exotic one. + if (!fault) { + root.hidden = true; + root.innerHTML = ''; + delete root.dataset.built; + return; + } + + root.hidden = false; + rebuildOn(root, String(fault.code), () => { + // class 0003 is advisory in the catalogue's own numbering; anything else stops work + root.dataset.severity = fault.errorClass === '0003' ? 'warn' : 'error'; + + root.appendChild(icon('exclamationMark')); + const body = el('div', 'fault-body'); + body.appendChild(el('div', 'fault-title', fault.title)); + body.appendChild(el('div', 'fault-desc', fault.description)); + + const bits = [`code ${fault.code}`]; + if (fault.subsystemName && fault.subsystemName !== 'unknown') bits.push(fault.subsystemName); + if (fault.toolhead) bits.push(`toolhead ${fault.toolhead}`); + // "the shipped catalogue" is our name for a file we extracted from the bundle. To a + // reader it is a code the printer reported and this page has no name for. + if (!fault.known) bits.push('unrecognised'); + body.appendChild(el('div', 'fault-code', bits.join(' \u00B7 '))); + root.appendChild(body); + + const again = el('button', 'btn', 'Re-check'); + again.onclick = () => handlers.queryException(); + root.appendChild(again); + }); +} diff --git a/resources/web/device_page/js/views/storage/storage/storage-commands.js b/resources/web/device_page/js/views/storage/storage/storage-commands.js new file mode 100644 index 00000000000..022c8fd0c2c --- /dev/null +++ b/resources/web/device_page/js/views/storage/storage/storage-commands.js @@ -0,0 +1,380 @@ +/* + * commands/storage.js - Commands Storage issues, across all three of its kinds. + * + * Recordings, finished prints and a file root are three different sources behind one + * grid, so this is where they are made to look alike. + * + * Thumbnails have two commands and only one of them returns bytes: + * server.files.thumbnails answers with PATHS, and the base64 lives behind + * server.files.thumbnails_base64. Asking the first and reading it as the second is why a + * real printer showed no thumbnail at all. + * + * check_coverage.py reads the CMD references out of this file to answer "can a user + * reach this command", and attributes them to the storage panel because this is the module + * that panel is handed. That makes the attribution a fact about the imports rather than + * a promise in a declaration - which is the difference that let a handler nothing called + * count as implemented for as long as it did. + */ +'use strict'; + +import { CMD, timelapseUrl } + from '../../../../../shared/js/protocol.js'; +import { openDialog } from '../../../core/overlay.js'; +import { pickThumb } from '../../../core/thumbs.js'; + +export function create(deps) { + // `bridge` is deliberately NOT destructured: it does not exist yet when these are + // built - boot() decides between the real host and the simulator - so it is reached + // through deps each time rather than captured as null once. + const { state, store, pending, session, cmd, + send, setpoint, setStatus, render } = deps; + + /** + * How many rows the first read asks for, and therefore how much of the grid is full + * when it arrives. + * + * Measured in a 1920x1080 window: the grid is 1584x939 and a card is 214x259, so 7 + * columns of 3 whole rows are on screen with a fourth part-shown - 21 cards before + * anything is scrolled. The first read asked for 20 prints and 24 recordings, so a + * full screen was never what came back; the answer to "what is on this printer" + * arrived a row short of the space it had. At 2560x1440 the same arithmetic gives 11 + * columns and 5 rows, which is where 60 comes from. + * + * It is one number for all three sources because they draw into the same grid. Only + * Prints can fetch a second page - the other two have no cursor to carry - so for + * them this is not a page size but the whole list, which is the other reason it is + * not 20. + */ + const PAGE = 60; + + /** The root each file-backed kind reads from. */ + const STORAGE_ROOT = { logs: 'logs' }; + + /** Watch a machine-file transfer until it stops moving. */ + async function pollTransfer() { + for (let i = 0; i < 60; i++) { + let st; + try { st = await deps.bridge.request(CMD.FILE_STATUS, {}); } catch { return; } + const pct = Number(st && (st.progress ?? st.percent)); + if (Number.isFinite(pct)) { + setStatus(`transfer ${Math.round(pct > 1 ? pct : pct * 100)}%`); + if (pct >= 1 || pct >= 100) { setStatus('transfer complete', 'ok'); return; } + } + await new Promise((r) => setTimeout(r, 1000)); + } + } + + /** + * What Storage is showing, in the one shape its grid reads. + * + * Three different sources - recordings, store.history and a file root - normalised + * here rather than in the renderer, so the store.view stays one thing. + */ + function storageData() { + const src = store.storageKind === 'timelapses' ? store.timelapses + : store.storageKind === 'prints' ? store.history + : store.files; + return { items: src.items || [], loading: src.loading, error: src.error, + hasMore: !!src.hasMore, loadingMore: store.storageMore }; + } + + function openStorage(kind) { + store.storageKind = kind; + // A page in flight for the kind being left must not append into the one being + // opened. It cannot be cancelled - the request is already out - so the flag it + // would raise is cleared here and its append is guarded by the page number. + store.storageMore = false; + // No cache to bust: the rebuild guard is keyed on `kind:shape`, so changing kind + // rebuilds by construction rather than by remembering to invalidate something. + if (kind === 'timelapses') cmd.loadTimelapses(); + else if (kind === 'prints') cmd.loadHistory(); + else cmd.openRoot(STORAGE_ROOT[kind]); + render(); + } + + return { + openStorage: (kind) => openStorage(kind), + + /** What Storage is showing, in the one shape its grid reads. */ + storageData, + + reloadStorage: () => openStorage(store.storageKind), + + /** + * The next page, for whichever kind is showing. + * + * All three sources page - `start`/`limit` for history, `page_index`/`page_rows` + * for recordings, `page_number`/`files_per_page` for files - and only history had + * it wired, so the other two showed their first page and no way to ask for a + * second. Where the cursor comes from is each source's own business, which is why + * this takes no argument: it used to be handed a count of the cards on screen, + * which is the DOM answering a question about the store. + */ + loadMoreStorage: () => { + if (store.storageMore) return null; + const kind = store.storageKind; + if (kind === 'prints') return cmd.loadHistory(store.history.items.length); + if (kind === 'timelapses') return cmd.loadTimelapses(store.timelapses.page + 1); + return cmd.openRoot(store.files.root, store.files.page + 1); + }, + + /** The one useful action from an idle job card: go and find something to print. */ + + openRoot: async (root, page = 0) => { + const f = store.files; + if (page === 0) { + store.files = { loading: true, error: '', root: root || f.root, roots: f.roots, + items: [], hasMore: false, page: 0 }; + } else { + store.storageMore = true; + } + render(); + try { + if (!store.files.roots.length) { + const r = await deps.bridge.request(CMD.FILES_ROOTS, {}); + store.files.roots = Array.isArray(r) ? r : (r && (r.roots || r.result)) || []; + if (!store.files.root) { + const first = store.files.roots[0]; + store.files.root = typeof first === 'string' ? first : (first && (first.name || first.root)) || 'logs'; + } + } + // Firmware builds differ on which listing command answers; try the paged + // one first and fall back to the directory walk. + let items = []; + // The walk has no page to ask for, so it answers the first read and nothing + // after it. Saying so is what keeps the button off a list that cannot grow. + let paged = true; + try { + const got = await deps.bridge.request(CMD.FILE_LIST_PAGE, + { root: store.files.root, page_number: page, files_per_page: PAGE }); + items = (got && (got.files || got.items || got.result)) || (Array.isArray(got) ? got : []); + } catch { + paged = false; + const dir = await deps.bridge.request(CMD.FILES_GET_DIRECTORY, + { path: store.files.root, extended: true }); + items = (dir && (dir.files || dir.result)) || []; + } + items = Array.isArray(items) ? items : []; + store.files.items = page === 0 ? items : store.files.items.concat(items); + store.files.page = page; + store.files.hasMore = paged && items.length >= PAGE; + } catch (e) { + store.files.error = `could not read the machine: ${e.message}`; + } + store.files.loading = false; + store.storageMore = false; + render(); + }, + + printFile: (path) => openDialog({ + title: 'Start this print?', + build: (b) => { + const p = document.createElement('p'); + p.style.cssText = 'margin:4px 0 6px;font-size:13px;line-height:1.55;color:#39434F'; + p.textContent = path; + b.appendChild(p); + }, + confirmLabel: 'Print', + onConfirm: () => { send(CMD.PRINT_START, { filename: path }, 'start print'); }, + }), + + /* ---- camera ---- */ + + loadHistory: async (start = 0) => { + store.history.loading = start === 0; + store.storageMore = start > 0; + store.history.error = ''; + render(); + try { + const r = await deps.bridge.request(CMD.PRINT_HISTORY, + { start, limit: PAGE, order: 'desc' }); + const jobs = (r && (r.jobs || r.items)) || (Array.isArray(r) ? r : []); + store.history.items = start === 0 ? jobs : store.history.items.concat(jobs); + store.history.hasMore = jobs.length >= PAGE; + } catch (e) { + store.history.error = `could not read print history: ${e.message}`; + if (start === 0) store.history.items = []; + } + store.history.loading = false; + store.storageMore = false; + render(); + }, + + loadTimelapses: async (page = 0) => { + const t = store.timelapses; + t.loading = page === 0; + store.storageMore = page > 0; + t.error = ''; + render(); + try { + const r = await deps.bridge.request(CMD.TIMELAPSE_LIST, + { page_index: page, page_rows: PAGE, thumbnail_direct: true }); + // `instances` is the printer's own name for the list, and the reply is now + // unwrapped from its JSON-RPC envelope before it gets here (see unwrapRpc). + const got = (r && (r.instances || r.list || r.items)) + || (Array.isArray(r) ? r : []); + t.items = page === 0 ? got : t.items.concat(got); + t.page = page; + t.hasMore = got.length >= PAGE; + } catch (e) { + if (page === 0) t.items = []; + t.error = `could not list recordings: ${e.message}`; + } + t.loading = false; + store.storageMore = false; + render(); + }, + + /** + * Play a recording, rather than only offering to delete it. + * + * The sheet used to say playback was Orca's job and then show a Delete button, which + * made "open" mean "destroy". The file is served by Moonraker and the URL is on the + * instance itself; see timelapseUrl for the port that actually answers with video. + */ + + openTimelapse: (t) => { + const url = timelapseUrl(store.device, t); + const name = t.gcode_name || t.name || t.filename || 'Recording'; + openDialog({ + title: name, + wide: true, + build: (b) => { + // Whether this engine can play the file at all is a different question from + // whether the printer serves it, and they need different answers. Measured: + // WebKitGTK here reports canPlayType('video/mp4') === '' and fails with + // MEDIA_ERR_SRC_NOT_SUPPORTED - it ships without H.264. Orca's Windows and + // macOS webviews do not have that limitation. + const playable = !!document.createElement('video').canPlayType('video/mp4'); + if (url && playable) { + const v = document.createElement('video'); + v.src = url; + v.controls = true; + v.autoplay = true; + v.style.cssText = 'width:100%;max-height:52vh;background:#000;border-radius:6px'; + v.onerror = () => { + v.remove(); + const p = document.createElement('p'); + p.style.cssText = 'margin:4px 0;font-size:13px;color:#9A5B12'; + p.textContent = `The printer did not serve this recording (${url}).`; + b.prepend(p); + }; + b.appendChild(v); + } else if (url) { + // Show the still it already has, and say why there is no player. + const thumb = t.thumbnail_base64 || ''; + if (thumb) { + const im = document.createElement('img'); + im.src = thumb.startsWith('data:') ? thumb : `data:image/jpeg;base64,${thumb}`; + im.style.cssText = 'width:100%;max-height:40vh;object-fit:contain;' + + 'background:#000;border-radius:6px;display:block'; + b.appendChild(im); + } + const p = document.createElement('p'); + p.style.cssText = 'margin:10px 0 0;font-size:13px;line-height:1.5;color:#9A5B12'; + p.textContent = 'This browser engine has no H.264 support, so the recording ' + + 'cannot play here. Download plays it in any video player.'; + b.appendChild(p); + } else { + const p = document.createElement('p'); + p.style.cssText = 'margin:4px 0 6px;font-size:13px;color:#39434F'; + p.textContent = 'This recording carries no playable URL.'; + b.appendChild(p); + } + const meta = [t.generate_date, t.video_duration, + t.video_file_size ? `${(t.video_file_size / 1048576).toFixed(1)} MB` : ''] + .filter(Boolean).join(' \u00B7 '); + if (meta) { + const m = document.createElement('p'); + m.style.cssText = 'margin:10px 0 0;font-size:12px;color:#666'; + m.textContent = meta; + b.appendChild(m); + } + // Deleting is still reachable, but it is no longer what opening a recording + // does. It asks again, because this one cannot be undone. + const row = document.createElement('div'); + row.style.cssText = 'margin-top:14px;display:flex;gap:10px'; + if (url) { + const dl = document.createElement('a'); + dl.href = url; + dl.download = `${name}.mp4`; + dl.className = 'btn'; + dl.textContent = 'Download'; + row.appendChild(dl); + } + const del = document.createElement('button'); + del.className = 'btn'; + del.textContent = 'Delete from printer\u2026'; + del.onclick = () => cmd.deleteTimelapse(t); + row.appendChild(del); + b.appendChild(row); + }, + confirmLabel: 'Close', + onConfirm: () => true, + }); + }, + + deleteTimelapse: (t) => openDialog({ + title: 'Delete this recording?', + build: (b) => { + const p = document.createElement('p'); + p.style.cssText = 'margin:4px 0 6px;font-size:13px;line-height:1.55;color:#39434F'; + p.textContent = `${t.gcode_name || t.name || 'This recording'} will be removed ` + + 'from the printer. This cannot be undone.'; + b.appendChild(p); + }, + confirmLabel: 'Delete', + onConfirm: async () => { + await send(CMD.TIMELAPSE_DELETE, + { date_index: t.date_index || t.dateIndex || '', + name: t.name || t.gcode_name || '' }, 'delete recording'); + cmd.loadTimelapses(); + }, + }), + + /* ---- diagnostics ---- */ + + fileDetails: async (path) => { + const [meta, thumb] = await Promise.all([ + deps.bridge.request(CMD.FILES_METADATA, { filename: path }).catch((e) => ({ error: e.message })), + // Both thumbnail commands ship, and the order matters more than it looks. + // sw_MachineFilesThumbnails *succeeds* and returns {width,height,size, + // thumbnail_path} - paths, no image data - so asking it first meant the + // catch-fallback never fired and no thumbnail was ever shown. The base64 + // command is the only one that returns bytes, so it goes first. + deps.bridge.request(CMD.FILE_THUMBS_B64, { path }) + .catch(() => deps.bridge.request(CMD.FILE_THUMBNAILS, { filename: path })) + .catch(() => null), + ]); + openDialog({ + title: path.split('/').pop() || path, + build: (b) => { + const img = pickThumb(thumb); + if (img) { + const im = document.createElement('img'); + im.src = img.startsWith('data:') ? img : `data:image/png;base64,${img}`; + im.style.cssText = 'width:100%;max-height:180px;object-fit:contain;' + + 'border:1px solid #E6E6E6;border-radius:6px;margin-bottom:12px'; + b.appendChild(im); + } + const dl = document.createElement('dl'); + dl.className = 'info-grid'; + Object.entries(meta || {}).forEach(([k, v]) => { + if (v == null || typeof v === 'object') return; + const dt = document.createElement('dt'); dt.textContent = k; + const dd = document.createElement('dd'); dd.textContent = String(v); + dl.appendChild(dt); dl.appendChild(dd); + }); + b.appendChild(dl); + }, + confirmLabel: 'Download to this computer', + onConfirm: () => { + send(CMD.DOWNLOAD_MACHINE_FILE, { filename: path, url: '' }, 'download'); + pollTransfer(); + }, + }); + }, + + /* ---- saved-store.device management ---- */ + }; +} diff --git a/resources/web/device_page/js/views/storage/storage/storage-panel.js b/resources/web/device_page/js/views/storage/storage/storage-panel.js new file mode 100644 index 00000000000..41ee9b8e134 --- /dev/null +++ b/resources/web/device_page/js/views/storage/storage/storage-panel.js @@ -0,0 +1,57 @@ +/* + * Storage: one grid for everything the printer is holding. + * + * Recordings, finished prints and logs were separate shapes behind separate tabs on two + * panels. They are all "things on the machine you might want to look at", so they get + * one view, one picker and one card - normalised in app.js rather than kept in step by + * hand across a renderer each. + */ +'use strict'; + +import { renderStorage } from './storage-view.js'; + +/** + * The kinds, and the one that went. + * + * There was a fourth - "Print files", the gcodes root - and next to Prints it was the + * same list twice: the same files, the same thumbnails, the same names, one offering + * Print and the other Reprint. Two tabs that answer the same question with the same + * rows is a worse page than one, so the history is the one that stayed - it is the list + * that knows how each of them went. + * + * `iconFile` and `iconModelFileFolder` are both folders in the shipped bundle, which is + * how the last two tabs came to wear the same picture. Logs get a log: `iconLog` is + * ours, drawn to the bundle's 24px stroke because it has nothing of the kind. + */ +export const KINDS = [ + { value: 'timelapses', icon: 'videoCall', title: 'Time-lapses' }, + { value: 'prints', icon: 'printHistory', title: 'Prints' }, + { value: 'logs', icon: 'iconLog', title: 'Logs and other files' }, +]; + +export default { + id: 'storage', + title: 'Storage', + view: 'storage', + sectionId: 'storage', + panelClass: 'storage', + bodyId: 'storage-body', + bodyClass: 'storage-body', + + header: [ + { kind: 'sep' }, + { kind: 'tabs', group: 'kind', cls: 'tab', items: KINDS, + active: (ctx) => ctx.store.storageKind, + on: (ctx, v) => ctx.handlers.openStorage(v) }, + { kind: 'spacer' }, + { kind: 'icon', id: 'storage-refresh', cls: 'icon-btn', icon: 'refresh', + title: 'Re-read this folder', on: (ctx) => ctx.handlers.reloadStorage() }, + ], + + reads: ['store.timelapses', 'store.history', 'store.files', 'store.storageMore'], + + update(root, ctx) { + renderStorage(root, ctx.store.storageKind, ctx.handlers.storageData(), + ctx.handlers, ctx.store.device); + }, +}; diff --git a/resources/web/device_page/js/views/storage/storage/storage-view.js b/resources/web/device_page/js/views/storage/storage/storage-view.js new file mode 100644 index 00000000000..60578082c58 --- /dev/null +++ b/resources/web/device_page/js/views/storage/storage/storage-view.js @@ -0,0 +1,237 @@ +/* + * Storage's DOM: one scrolling grid, one card, three kinds of thing on the machine. + */ +'use strict'; + +import { $, el, icon } from '../../../core/dom.js'; +import { clock } from '../../../widgets/format.js'; +import { illustration, placeholder } from '../../../widgets/art.js'; +import { jobThumbUrl } from '../../../../../shared/js/protocol.js'; +import { rebuildOn, keyedList, text, data } from '../../../core/render.js'; + +/** Klipper's own job outcomes; anything else is shown verbatim. */ +const JOB_STATUS = { + completed: 'Completed', cancelled: 'Cancelled', error: 'Error', + klippy_shutdown: 'Interrupted', klippy_disconnect: 'Interrupted', + in_progress: 'In progress', server_exit: 'Interrupted', +}; + +/* ---- storage ---------------------------------------------------------- */ + +/** + * One scrolling grid for everything the printer is holding. + * + * Recordings, finished prints and logs were different shapes behind different tabs on + * two different panels. They are all "things on the machine you might want to look at", + * so they get one view, one picker, and one card - normalised here rather than three + * renderers kept in step by hand. + * + * The same rebuild guard as the task lists: this repaints on every state push, and + * rebuilding threw away the scroll position. + */ +export function renderStorage(root, kind, data, handlers, device) { + const items = data.items || []; + // What the body *is*, rather than what it contains: four shapes of body, and only a + // change of shape needs the chrome rebuilt. The old guard hashed the item COUNT and + // so never repainted a list whose contents changed without its length - a print + // going from in_progress to completed left the old badge on screen. + const shape = data.loading ? 'loading' + : data.error ? 'error' + : items.length ? 'grid' : 'empty'; + + rebuildOn(root, `${kind}:${shape}`, () => { + if (shape === 'loading') { + const wrap = el('div', 'stor-empty'); + wrap.appendChild(el('div', 'spinner')); + wrap.appendChild(el('div', 'cam-msg', 'Reading the machine\u2026')); + root.appendChild(wrap); + return; + } + if (shape === 'error') { + const wrap = el('div', 'stor-empty'); + wrap.appendChild(el('div', 'cam-msg', '')); + const again = el('button', 'btn', 'Try again'); + again.onclick = () => handlers.reloadStorage(); + wrap.appendChild(again); + root.appendChild(wrap); + return; + } + if (shape === 'empty') { + const wrap = el('div', 'stor-empty'); + illustration(wrap); + wrap.appendChild(el('div', 'cam-msg', EMPTY_TEXT[kind] || 'Nothing here')); + root.appendChild(wrap); + return; + } + const grid = el('div', 'stor-grid'); + grid.dataset.kind = kind; + root.appendChild(grid); + const foot = el('div', 'stor-foot'); + foot.appendChild(el('span', null, '')); + foot.appendChild(el('div', 'spinner sm')); + const more = el('button', 'btn stor-more', 'Load more'); + // No argument: where the next page starts is the store's business, not a count of + // the cards that happen to be on screen. + more.onclick = () => handlers.loadMoreStorage(); + foot.appendChild(more); + root.appendChild(foot); + }); + + if (shape === 'error') { text(root.querySelector('.cam-msg'), data.error); return; } + if (shape !== 'grid') return; + + // The grid is never replaced, so its scroll position needs no saving and restoring - + // that hand-rolled `at = keep.scrollTop` was paying for the rebuild above it. + keyedList(root.querySelector('.stor-grid'), items, { + key: (it, i) => cardKey(it, i), + sig: (it) => cardSig(kind, it), + create: (it) => storageCard(kind, it, handlers, device), + }); + + // The count says how much of the machine you are looking at, so it shows whenever + // anything does. Only the button depends on there being more to fetch - and hiding + // the whole footer on `hasMore` hid the count for every kind that never sets it, + // which is every kind but Prints, and for Prints too once the last page was in. + // + // A further page does not replace the grid, so there is nothing on screen to say it + // is happening: the spinner stands where the button was, in the one place a person + // is already looking after pressing it. + const foot = root.querySelector('.stor-foot'); + foot.querySelector('.stor-more').hidden = !data.hasMore || !!data.loadingMore; + foot.querySelector('.spinner').hidden = !data.loadingMore; + text(foot.querySelector('span'), `${items.length} shown`); +} + +/** + * What makes two entries the same entry across frames. + * + * `date_index` is first because a RECORDING has no other identity, and the one it was + * falling through to is not one. Measured on 811002511261022618B3 (2026-09-01): sixty + * recordings carry `date_index`, `gcode_name`, `gcode_path`, `generate_date`, + * `thumbnail_base64`, `thumbnail_path`, `timelapse_dir`, `unix_timestamp_s`, + * `video_duration`, `video_file_size`, `video_local_url_suffix`, `video_path` - and no + * `name`, no `id`. So the key was `gcode_name`, and a file recorded more than once + * repeats it: 48 distinct keys across 60 recordings, one of them four times over. Two + * nodes under one key cannot both be addressed, so the grid drew 72 cards for 60 + * recordings and would have grown at every repaint. `date_index` is unique across all + * sixty, and is what the printer itself addresses a recording by - it is half of what + * TIMELAPSE_DELETE takes. + * + * Then the index, because neither remaining source promises an id either: history rows + * have a filename that repeats across reprints, and a log file has only its path. An + * index-keyed list still reconciles correctly for the one mutation that happens here - + * "load more" appending to the end. + */ +function cardKey(it, i) { + return it.job_id || it.id || it.date_index || it.path || it.filename + || it.gcode_name || `#${i}`; +} + +/** What has to change before a card is worth rebuilding. */ +function cardSig(kind, it) { + if (kind === 'prints') return `${it.status}:${it.exists}:${it.end_time}`; + if (kind === 'timelapses') return `${it.video_file_size}:${it.video_duration}`; + return `${it.size}:${it.modified}`; +} + +const EMPTY_TEXT = { + timelapses: 'No recordings on this printer', + prints: 'No completed prints on this printer', + logs: 'No files in this folder', +}; + +/** The empty-box art the shipped page uses where there is nothing to show. */ + +/** + * One card, from whichever shape the source hands over. + * + * Each kind answers the same four questions - what does it look like, what is it + * called, what else is worth knowing, and what can be done with it - so the card is + * built once and the differences live in this switch. + */ +function storageCard(kind, it, handlers, device) { + const card = el('div', 'stor-card'); + const shot = el('div', 'stor-shot'); + const body = el('div', 'stor-info'); + const act = el('div', 'stor-actions'); + + const withImg = (src, onFail) => { + const im = el('img'); + im.src = src; + im.alt = ''; + im.onerror = () => { im.remove(); shot.appendChild(onFail()); }; + shot.appendChild(im); + }; + + if (kind === 'timelapses') { + const t = it.thumbnail_base64 || it.thumbnail || ''; + if (t) withImg(t.startsWith('data:') ? t : `data:image/jpeg;base64,${t}`, + () => el('span', 'tl-none', 'no preview')); + else shot.appendChild(el('span', 'tl-none', 'no preview')); + if (it.video_duration) shot.appendChild(el('span', 'tl-dur', it.video_duration)); + body.appendChild(el('span', 'stor-name', it.gcode_name || 'recording')); + body.appendChild(el('span', 'stor-sub', + [it.generate_date, it.video_file_size ? fmtSize(it.video_file_size) : ''] + .filter(Boolean).join(' \u00B7 '))); + const play = el('button', 'btn primary', 'View'); + play.onclick = () => handlers.openTimelapse(it); + act.appendChild(play); + + } else if (kind === 'prints') { + const url = jobThumbUrl(device, it); + if (url) withImg(url, placeholder); + else shot.appendChild(placeholder()); + const status = String(it.status || ''); + const badge = el('span', 'stor-badge', JOB_STATUS[status] || status || '\u2014'); + badge.dataset.status = status === 'completed' ? 'ok' + : (status === 'in_progress' ? 'busy' : 'bad'); + shot.appendChild(badge); + body.appendChild(el('span', 'stor-name', + String(it.filename || '(unnamed)').split('/').pop())); + const when = it.end_time || it.start_time; + const bits = []; + if (when) bits.push(new Date(when * 1000).toLocaleDateString()); + if (it.print_duration) bits.push(clock(it.print_duration)); + const layers = it.metadata && it.metadata.layer_count; + if (layers) bits.push(`${layers} layers`); + body.appendChild(el('span', 'stor-sub', bits.join(' \u00B7 '))); + const again = el('button', 'btn primary', 'Reprint'); + if (it.exists === false) { + again.disabled = true; + again.title = 'This file is no longer on the machine'; + } else { + again.onclick = () => handlers.printFile(it.filename); + } + act.appendChild(again); + + } else { + // A log is a plain file: a name, a size, a date, and one thing to do with it. + const path = it.path || it.filename || it.name || ''; + shot.appendChild(icon('iconLog', 'stor-glyph')); + body.appendChild(el('span', 'stor-name', path.split('/').pop() || path)); + const bits = []; + if (it.size != null) bits.push(fmtSize(it.size)); + if (it.modified) bits.push(new Date(Number(it.modified) * 1000).toLocaleDateString()); + body.appendChild(el('span', 'stor-sub', bits.join(' \u00B7 '))); + const info = el('button', 'btn', 'Details'); + info.onclick = () => handlers.fileDetails(path); + act.appendChild(info); + } + + card.appendChild(shot); + card.appendChild(body); + card.appendChild(act); + card.title = it.filename || it.gcode_name || it.path || ''; + return card; +} + +/* ---- shared formatting ----------------------------------------------- */ + +function fmtSize(n) { + const v = Number(n) || 0; + if (v > 1024 * 1024) return `${(v / 1048576).toFixed(1)} MB`; + if (v > 1024) return `${(v / 1024).toFixed(0)} KB`; + return `${v} B`; +} + +/* ---- trace ---------------------------------------------------------- */ diff --git a/resources/web/device_page/js/widgets/art.js b/resources/web/device_page/js/widgets/art.js new file mode 100644 index 00000000000..88ed306a420 --- /dev/null +++ b/resources/web/device_page/js/widgets/art.js @@ -0,0 +1,36 @@ +/* + * art.js - the two pieces of empty-state art the shipped page uses, shared by the + * panels that have nothing to show: the not-connected device, and the empty box. + */ +'use strict'; + +import { el } from '../core/dom.js'; + +export function illustration(root) { + const wrap = el('div'); + wrap.style.textAlign = 'center'; + const img = el('img', 'placeholder'); + img.src = 'icons/deviceNotConnected.webp'; + img.alt = ''; + img.onerror = () => img.remove(); + wrap.appendChild(img); + root.appendChild(wrap); +} + +/** hh:mm:ss from seconds, the granularity the shipped page uses for a job. */ + +/** The empty-box art the shipped page uses where there is nothing to show. */ +export function placeholder(cls = 'stor-ph') { + const im = el('img', cls); + im.src = 'icons/empty-box.png'; + im.alt = ''; + return im; +} + +/** + * One card, from whichever shape the source hands over. + * + * Each kind answers the same four questions - what does it look like, what is it + * called, what else is worth knowing, and what can be done with it - so the card is + * built once and the differences live in this switch. + */ diff --git a/resources/web/device_page/js/widgets/format.js b/resources/web/device_page/js/widgets/format.js new file mode 100644 index 00000000000..1a85b205f23 --- /dev/null +++ b/resources/web/device_page/js/widgets/format.js @@ -0,0 +1,25 @@ +/* + * format.js - formatting shared by more than one view. Small on purpose: a helper only + * one panel uses belongs in that panel. + */ +'use strict'; + +import { $ } from '../core/dom.js'; +import { illustration } from './art.js'; + +/** hh:mm:ss from seconds, the granularity the shipped page uses for a job. */ +export function clock(sec) { + const s = Math.max(0, Math.round(Number(sec) || 0)); + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); + return `${h}h ${String(m).padStart(2, '0')}m`; +} + +/** + * The job card, laid out the way the shipped page lays it out. + * + * The rebuild used to show an illustration and the words "No active print" when the + * machine was idle, which is a different card rather than the same card at zero. The + * original keeps ONE card and zeroes it: status badge, machine name, thumbnail, + * filename, percentage, layers, time, progress bar, and one round button. An idle + * printer and a printing one differ in the numbers, not in the furniture. + */ diff --git a/resources/web/device_page/js/widgets/rail-commands.js b/resources/web/device_page/js/widgets/rail-commands.js new file mode 100644 index 00000000000..ff15566d2fe --- /dev/null +++ b/resources/web/device_page/js/widgets/rail-commands.js @@ -0,0 +1,227 @@ +/* + * commands/device.js - Commands the rail's device menu issues. + * + * Not a panel: this is the page's relationship to ORCA - which machines are saved, pair, + * rename, forget, discover - rather than to any one part of the printer, which is why it + * has no panel to belong to. + * + * check_coverage.py reads the CMD references out of this file to answer "can a user + * reach this command", and attributes them to the device panel because this is the module + * that panel is handed. That makes the attribution a fact about the imports rather than + * a promise in a declaration - which is the difference that let a handler nothing called + * count as implemented for as long as it did. + */ +'use strict'; + +import { CMD, deviceLabel, DEVICE } + from '../../../shared/js/protocol.js'; +import { openDialog, toggleField, numberField } from '../core/overlay.js'; + +export function create(deps) { + // `bridge` is deliberately NOT destructured: it does not exist yet when these are + // built - boot() decides between the real host and the simulator - so it is reached + // through deps each time rather than captured as null once. + const { state, store, pending, session, cmd, + send, setpoint, setStatus, render } = deps; + + let findSub = null; // discovery subscription + + + + return { + showSystemInfo: async () => { + const gather = async (cmd, params = {}) => { + try { return await deps.bridge.request(cmd, params); } catch (e) { return { error: e.message }; } + }; + const [printer, sys, dev, storage, objects] = await Promise.all([ + gather(CMD.GET_PRINTER_INFO), + gather(CMD.GET_MACHINE_SYSTEM_INFO), + gather(CMD.SYSTEM_DEVICE_INFO), + gather(CMD.STORAGE_SPACE), + gather(CMD.MACHINE_OBJECTS), + ]); + openDialog({ + title: 'Printer information', + wide: true, + build: (b) => { + const dl = document.createElement('dl'); + dl.className = 'info-grid'; + const add = (k, v) => { + if (v == null || v === '' || typeof v === 'object') return; + const dt = document.createElement('dt'); dt.textContent = k; + const dd = document.createElement('dd'); dd.textContent = String(v); + dl.appendChild(dt); dl.appendChild(dd); + }; + add('Name', store.device && deviceLabel(store.device)); + add('Model', store.device && store.device[DEVICE.MODEL]); + add('Serial', store.device && store.device[DEVICE.SN]); + add('Address', store.device && `${store.device[DEVICE.IP]}:${store.device[DEVICE.PORT] || 8883}`); + add('State', printer.state); + add('Host name', printer.hostname); + add('Firmware', printer.software_version || sys.firmware_version || dev.firmware); + add('Klipper objects', Array.isArray(objects && objects.objects) + ? objects.objects.length : undefined); + b.appendChild(dl); + + const free = Number(storage && storage.free_space); + const total = Number(storage && storage.total_space); + if (Number.isFinite(free) && Number.isFinite(total) && total > 0) { + const used = total - free; + const p = document.createElement('div'); + p.style.cssText = 'margin-top:16px;font-size:12px;color:#5F6B79'; + p.textContent = `Storage — ${used.toFixed(1)} of ${total.toFixed(1)} ` + + `${storage.units || 'GB'} used`; + b.appendChild(p); + const bar = document.createElement('div'); + bar.className = 'bar-mini'; + const fill = document.createElement('div'); + fill.style.width = `${Math.round((used / total) * 100)}%`; + bar.appendChild(fill); + b.appendChild(bar); + } + }, + confirmLabel: 'Close', + onConfirm: () => true, + }); + }, + + /** Defect detection is a pass-through to the printer; params are its own. */ + + showDefectSettings: async () => { + let cur = {}; + try { cur = (await deps.bridge.request(CMD.DEFECT_DETECTION, {})) || {}; } catch { /* use defaults */ } + let enable, sens; + openDialog({ + title: 'Defect detection', + build: (b) => { + enable = toggleField(b, { label: 'Detect first-layer and spaghetti failures', + checked: cur.enable !== false }); + sens = numberField(b, { label: 'Sensitivity', value: cur.sensitivity ?? 1, + min: 0, max: 2, + hint: 'Higher values report more possible defects.' }); + }, + confirmLabel: 'Apply', + onConfirm: () => { + send(CMD.DEFECT_DETECTION, + { enable: enable.checked, sensitivity: Number(sens.value) }, 'defect detection'); + }, + }); + }, + + /* ---- discovery ---- */ + + findMachines: async () => { + store.found = []; + let dlg; + const paint = () => { + const host = document.querySelector('#found-host'); + if (!host) return; + host.innerHTML = ''; + if (!store.found.length) { + const p = document.createElement('div'); + p.className = 'empty'; + p.textContent = 'Searching the network…'; + host.appendChild(p); + return; + } + store.found.forEach((m) => { + const row = document.createElement('div'); + row.className = 'found-row'; + const meta = document.createElement('div'); + meta.className = 'found-meta'; + const n = document.createElement('span'); + n.className = 'found-name'; + n.textContent = m.dev_name || m.name || m.sn || m.ip || 'printer'; + const sub = document.createElement('span'); + sub.className = 'found-sub'; + sub.textContent = [m.ip, m.model_name || m.machineType].filter(Boolean).join(' · '); + meta.appendChild(n); meta.appendChild(sub); + row.appendChild(meta); + host.appendChild(row); + }); + }; + dlg = openDialog({ + title: 'Printers on this network', + build: (b) => { + const host = document.createElement('div'); + host.id = 'found-host'; + host.className = 'found-list'; + b.appendChild(host); + const note = document.createElement('p'); + note.style.cssText = 'margin:12px 0 2px;font-size:12px;color:#9AA3AF'; + note.textContent = 'Adding a printer opens Orca\u2019s own dialog, which owns ' + + 'that flow.'; + b.appendChild(note); + }, + confirmLabel: 'Add a printer…', + onConfirm: () => { cmd.stopFind(); send(CMD.ADD_DEVICE, {}, 'add device'); }, + }); + paint(); + try { + findSub = await deps.bridge.subscribe(CMD.FIND_START, { last_time: -1 }, (d) => { + const list = Array.isArray(d) ? d : (d && (d.devices || d.machines)) || []; + if (list.length) { store.found = list; paint(); } + }); + } catch (e) { + setStatus(`discovery failed: ${e.message}`, 'err'); + } + }, + + stopFind: async () => { + if (findSub && findSub.cancel) findSub.cancel(); + findSub = null; + try { await deps.bridge.request(CMD.FIND_STOP, {}); } catch { /* already stopped */ } + }, + + connectOther: () => send(CMD.CONNECT_OTHER, {}, 'connect another machine'), + + /* ---- file extras ---- */ + + renameDevice: (d) => { + let input; + openDialog({ + title: 'Rename printer', + build: (b) => { + const f = document.createElement('label'); + f.className = 'field'; + const lab = document.createElement('span'); + lab.className = 'field-label'; + lab.textContent = 'Name'; + f.appendChild(lab); + const row = document.createElement('div'); + row.className = 'field-row'; + input = document.createElement('input'); + input.value = deviceLabel(d); + row.appendChild(input); + f.appendChild(row); + b.appendChild(f); + }, + confirmLabel: 'Rename', + onConfirm: async () => { + const name = input.value.trim(); + if (!name) return false; + await send(CMD.RENAME_DEVICE, + { dev_id: d[DEVICE.ID] || d[DEVICE.SN], dev_name: name }, 'rename'); + if (d[DEVICE.CONNECTED]) await send(CMD.SET_DEVICE_NAME, { name }, 'set machine name'); + await cmd.refresh(); + }, + }); + }, + + forgetDevice: (d) => openDialog({ + title: 'Forget this printer?', + build: (b) => { + const p = document.createElement('p'); + p.style.cssText = 'margin:4px 0 6px;font-size:13px;line-height:1.55;color:#39434F'; + p.textContent = `${deviceLabel(d)} will be removed from Orca. Pairing keys are lost, ` + + 'so it has to be paired again.'; + b.appendChild(p); + }, + confirmLabel: 'Forget', + onConfirm: async () => { + await send(CMD.DELETE_DEVICES, { dev_ids: [d[DEVICE.ID] || d[DEVICE.SN]] }, 'forget device'); + await cmd.refresh(); + }, + }), + }; +} diff --git a/resources/web/device_page/js/widgets/rail.js b/resources/web/device_page/js/widgets/rail.js new file mode 100644 index 00000000000..3d53468b337 --- /dev/null +++ b/resources/web/device_page/js/widgets/rail.js @@ -0,0 +1,55 @@ +/* + * rail.js - the left rail's device selector: which machine, and whether it answers. + */ +'use strict'; + +import { $ } from '../core/dom.js'; +import { DEVICE, deviceLabel } from '../../../shared/js/protocol.js'; +import { data } from '../core/render.js'; + +/** + * The selector names the machine even when it is not connected — Orca keeps + * saved printers in app_config whether or not a session is up, and showing + * "Unconnected" for a machine the user has configured just hides it. + */ +export function renderRail(device, connection, devices = [], reachable = false) { + const nameEl = $('#dev-name'); + const dot = $('#dev-state'); + + if (!device) { + nameEl.textContent = devices.length ? 'Select a printer' : 'Unconnected'; + nameEl.title = devices.length ? `${devices.length} saved` : 'No printer configured'; + dot.dataset.state = 'none'; + return; + } + + const label = deviceLabel(device); + // Live state beats the persisted flag - see the note in app.js render(). + const linked = reachable || !!device[DEVICE.CONNECTED]; + nameEl.textContent = label; + nameEl.title = [ + label, + device[DEVICE.MODEL], + device[DEVICE.SN] ? `SN ${device[DEVICE.SN]}` : '', + device[DEVICE.IP] || '', + linked ? 'connected' : 'not connected', + connection && connection.message ? connection.message : '', + ].filter(Boolean).join('\n'); + dot.dataset.state = linked ? 'on' : 'off'; +} + +/* ---- camera --------------------------------------------------------- */ + +/** + * `cam` is { mode, streaming, frameUrl, timelapses[], error }. + * + * Where a frame comes from, measured rather than guessed. `camera.start_monitor` + * replies `{ state, url }` and then sends nothing further - there is no frame push to + * wait for. The printer rewrites one file at the monitor interval and the image is + * fetched over HTTP; see CAMERA_* in protocol.js for the whole finding. + * + * The reply's own `url` is relative to the printer's :80 web UI, which answers that + * path with its SPA shell rather than an image, so it is used only when it is already + * absolute - which is how the simulator hands back a data: URI. Otherwise the frame is + * addressed on Moonraker's file server, keeping the filename the printer named. + */ diff --git a/resources/web/device_page/js/widgets/trace.js b/resources/web/device_page/js/widgets/trace.js new file mode 100644 index 00000000000..3f48d169feb --- /dev/null +++ b/resources/web/device_page/js/widgets/trace.js @@ -0,0 +1,30 @@ +/* + * trace.js - the live WCP trace pane. A development aid, not part of the shipped page. + */ +'use strict'; + +import { $, el } from '../core/dom.js'; + +/* ---- trace ---------------------------------------------------------- */ +export function makeTrace(pane) { + return (kind, packet) => { + if (!pane || pane.dataset.paused === '1') return; + const line = el('div', `t t-${kind}`); + const cmd = (packet && packet.payload && packet.payload.cmd) + || (packet && packet.header && packet.header.event_id ? 'push' : ''); + line.textContent = `${String(kind).padEnd(9)} ${cmd}`; + line.title = JSON.stringify(packet); + pane.appendChild(line); + while (pane.childElementCount > 200) pane.removeChild(pane.firstChild); + pane.scrollTop = pane.scrollHeight; + }; +} + +/* ---- filament ------------------------------------------------------- */ + +/** + * Four slots, drawn on the bundle's own extruder artwork. + * + * Values come from `print_task_config` - the same object the print-processing + * popup edits. See docs/u1-webui/00-shared/01-shared-models.md + */ diff --git a/resources/web/device_page/tests/capture_screenshots.py b/resources/web/device_page/tests/capture_screenshots.py new file mode 100644 index 00000000000..7b389da3066 --- /dev/null +++ b/resources/web/device_page/tests/capture_screenshots.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Capture screenshots of the Device page driving the simulated U1. + +Serves resources/web over loopback, loads the page in ?mock=1, drives it into a +series of states through the exposed window.__devicePage handle, and writes PNGs. + +Usage: python3 resources/web/device_page/tests/capture_screenshots.py +""" +import sys, os, threading, functools, http.server, socketserver, time + +HERE = os.path.dirname(os.path.abspath(__file__)) +PAGE = os.path.dirname(HERE) +WEB = os.path.dirname(PAGE) + +def serve(directory): + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory) + class Q(socketserver.TCPServer): + allow_reuse_address = True + httpd = Q(("127.0.0.1", 0), handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd, httpd.server_address[1] + +def main(): + out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(PAGE, "screenshots") + os.makedirs(out, exist_ok=True) + from playwright.sync_api import sync_playwright + + httpd, port = serve(WEB) + base = f"http://127.0.0.1:{port}/device_page" + shots = [] + + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": 1240, "height": 1000}, + device_scale_factor=2) + page.goto(f"{base}/index.html?mock=1", wait_until="networkidle") + page.wait_for_function("window.__devicePage && window.__devicePage.mock", timeout=30_000) + # let the job advance so the progress bar is not at zero + page.wait_for_timeout(6000) + + def shot(name, sel=None, note=""): + path = os.path.join(out, name) + if sel: + page.locator(sel).screenshot(path=path) + else: + page.screenshot(path=path, full_page=True) + shots.append((name, note)) + print(f" wrote {name} {note}") + + shot("01-device-page-printing.png", None, "full page, job running, 4 toolheads live") + shot("02-toolheads.png", "#toolheads", "four-toolhead grid with filament colours") + shot("03-bed-and-chamber.png", "#environment", "bed, fans, LED, purifier controls") + shot("04-sswcp-trace.png", "#trace", "live SSWCP protocol trace") + + # paused + page.evaluate("window.__devicePage.handlers.pause()") + page.wait_for_timeout(1800) + shot("05-paused.png", "#job", "job card after sw_MachinePrintPause") + page.evaluate("window.__devicePage.handlers.resume()") + page.wait_for_timeout(1500) + + # fault banner: a real code from the catalogue (toolhead 3 filament runout) + page.evaluate("window.__devicePage.mock.printer.actionCode = '0002052300020000'") + page.wait_for_timeout(1800) + shot("06-fault-banner.png", "#banner", "decoded fault 0002052300020000") + page.evaluate("window.__devicePage.mock.printer.actionCode = null") + page.wait_for_timeout(1500) + + # a control round-trip: raise bed target and show it followed + page.evaluate("window.__devicePage.handlers.setBedTemp(85)") + page.wait_for_timeout(2500) + shot("07-control-roundtrip.png", "#environment", "bed target 85C after sw_ControlBedTemp") + + # self test + page2 = browser.new_page(viewport={"width": 1000, "height": 1100}, + device_scale_factor=2) + page2.goto(f"{base}/tests/selftest.html", wait_until="networkidle") + page2.wait_for_function("document.getElementById('sum').textContent !== ''", + timeout=90_000) + page2.screenshot(path=os.path.join(out, "08-selftest.png"), full_page=True) + shots.append(("08-selftest.png", "28/28 browser assertions")) + print(" wrote 08-selftest.png") + + browser.close() + httpd.shutdown() + print(f"\n{len(shots)} screenshots -> {out}") + +if __name__ == "__main__": + main() diff --git a/resources/web/print_processing/README.md b/resources/web/print_processing/README.md new file mode 100644 index 00000000000..6aa9dd5982e --- /dev/null +++ b/resources/web/print_processing/README.md @@ -0,0 +1,57 @@ +# Print processing — clean-room reimplementation + +A dependency-free HTML/CSS/JS reconstruction of the Snapmaker U1 **print-processing +popup** — the modal Orca opens between "Send to printer" and the job starting — built +from the reverse engineering in [`docs/u1-webui/`](../../../docs/u1-webui/). + +This is **one of two** reconstructed surfaces. The bridge client, the state model, the +protocol constants and the simulated host live in [`../shared/`](../shared/) and are +shared with [`../device_page/`](../device_page/); only this surface's own `app.js`, +`ui.js`, `mock.js` and `preprint.css` are here. + +A build badge in the bottom-right corner names the surface and the build it reports — +the marker that tells this reconstruction apart from the shipped Flutter page. + +## Running it + +ES modules need a real origin, so serve rather than opening the file directly: + +```bash +python3 -m http.server 8099 --directory resources/web +``` + +| Mode | URL | Mirrors | +|---|---|---| +| Upload **and** print | `http://127.0.0.1:8099/print_processing/index.html?mock=1` | Orca's `?path=4` `preUploadAndPrint` | +| Upload **only** | `http://127.0.0.1:8099/print_processing/index.html?mock=1&mode=upload` | Orca's `?path=5` `preUpload` | + +Drop `?mock=1` when running inside Orca — the page uses the real bridge whenever +`window.wx` is present, and only falls back to the simulator when it is not. + +## What it covers + +- **Model Information** — filename, estimated time, estimated materials +- **Select Printer** — resolved from `sw_GetConnectedMachine`, with the + `sw_GetPrintLegal` preset-vs-machine check +- **Edit Filament** — one row per filament with its colour, type, weight and toolhead + assignment, written back as `extruder_map_table` +- **Print Preferences** — Extrusion Flow Calibration, Time-lapse Camera, Auto Leveling +- **Send** — the real three-command close protocol, in order + +Upload-only mode hides the filament and preference sections entirely and keeps Send +disabled until a printer resolves, matching the shipped popup. + +## Tests + +The shared conformance test guards this surface too, since both import the same +constants: + +```bash +python3 resources/web/shared/tests/conformance_test.py +``` + +## Limits + +The printer picker is a stub, upload progress is simulated locally, and everything is +verified against the shared simulator rather than real hardware. See +[the implementation notes](../../../docs/u1-webui/03-print-processing/03-implementation.md). diff --git a/resources/web/print_processing/css/preprint.css b/resources/web/print_processing/css/preprint.css new file mode 100644 index 00000000000..3d6a6e8ed21 --- /dev/null +++ b/resources/web/print_processing/css/preprint.css @@ -0,0 +1,214 @@ +/* + * preprint.css - the print-processing popup. + * + * Layered on ../shared/css/base.css. Sized to the dialog Orca actually opens: + * WebPreprintDialog sets 714x750 DIP. + */ + +.sheet { + max-width: 714px; + margin: 0 auto; + min-height: 100vh; + /* bottom room so content can scroll clear of the fixed build badge */ + padding: 10px 12px 44px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); +} +.status.ok { color: var(--ok); } +.status.warn { color: var(--warn); } +.status.err { color: var(--err); } +.status.mock { color: var(--accent); } +.mode { + padding: 2px 7px; + border: 1px solid var(--line); + border-radius: 999px; + letter-spacing: .04em; +} +.mode.mock { color: var(--accent); border-color: var(--accent); } + +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); +} +.card[hidden] { display: none; } +.card-head { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px 0; +} +.card-head h2 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--ink); +} +.card-body { padding: 10px 14px 14px; } + +.icon-btn { + border: 1px solid var(--line); + background: var(--panel-2); + color: var(--ink-2); + border-radius: 6px; + width: 24px; + height: 24px; + line-height: 1; + cursor: pointer; + font-size: 13px; +} +.icon-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* ---- model / printer rows ---- */ +.model-row { display: flex; gap: 14px; align-items: flex-start; } +.thumb { + flex: 0 0 96px; + height: 76px; + border-radius: 6px; + background: var(--panel-2); + border: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: center; +} +.thumb-tag { + font-family: var(--mono); + font-size: 10px; + color: var(--muted); + letter-spacing: .06em; +} +.printer-thumb { background: var(--panel-2); } + +.facts { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; } +.facts dt { color: var(--muted); font-size: 12px; } +.facts dd { margin: 0; font-size: 12px; color: var(--ink); } + +.picker { flex: 1; min-width: 0; } +.select { + width: 100%; + text-align: left; + padding: 9px 11px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-2); + color: var(--ink-2); + font: inherit; + font-size: 12px; + cursor: pointer; +} +.select:hover { border-color: var(--accent); } +.warn-line { margin: 8px 0 0; font-size: 12px; color: var(--warn); } +.empty { margin: 0; font-size: 12px; color: var(--muted); } + +/* ---- filament ---- */ +.fil-grid { display: flex; flex-direction: column; gap: 8px; } +.fil-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-2); +} +.chip { + width: 18px; height: 18px; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, .18); + flex: 0 0 auto; +} +.fil-meta { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.fil-name { font-size: 12px; color: var(--ink); } +.fil-sub { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } +.arrow { color: var(--muted); } +.slot { + font: inherit; + font-size: 12px; + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--ink); +} + +/* ---- preferences ---- */ +.prefs { display: flex; flex-direction: column; } +.pref-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 0; + font-size: 12px; + cursor: pointer; +} +.pref-row + .pref-row { border-top: 1px solid var(--line); } +.pref-label { color: var(--ink-2); } + +/* ---- send bar ---- */ +.sendbar { + margin-top: auto; + display: flex; + align-items: center; + gap: 10px; + padding-top: 6px; +} +.progress { + flex: 1; + height: 6px; + border-radius: 999px; + background: var(--panel-2); + border: 1px solid var(--line); + overflow: hidden; +} +#progress-fill { height: 100%; width: 0; background: var(--accent); } +.pct { + font-family: var(--mono); + font-size: 11px; + color: var(--ok); + font-variant-numeric: tabular-nums; + min-width: 34px; + text-align: right; +} +.btn { + font: inherit; + font-size: 13px; + padding: 9px 26px; + border-radius: 6px; + border: 1px solid var(--line); + background: var(--panel-2); + color: var(--ink); + cursor: pointer; +} +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-in); + font-weight: 600; +} +.btn:disabled { opacity: .45; cursor: not-allowed; } + +/* ---- trace ---- */ +.trace-wrap { font-family: var(--mono); font-size: 11px; } +.trace-wrap summary { color: var(--muted); cursor: pointer; padding: 4px 0; } +#trace { + max-height: 130px; + overflow: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 8px; +} +#trace .t { white-space: pre; color: var(--ink-2); } +#trace .t-mock-rx { color: var(--accent); } +#trace .t-mock-push { color: var(--muted); } diff --git a/resources/web/print_processing/index.html b/resources/web/print_processing/index.html new file mode 100644 index 00000000000..61c6c54c3f3 --- /dev/null +++ b/resources/web/print_processing/index.html @@ -0,0 +1,47 @@ + + + + + +Snapmaker U1 — Print processing + + + + +
+ +
+ starting… + +
+ +
+
+
+
+ +
+
+ 0% + +
+ +
+ WCP trace +
+
+ +
+ +
+ + + + + diff --git a/resources/web/print_processing/js/app.js b/resources/web/print_processing/js/app.js new file mode 100644 index 00000000000..4a589f557f7 --- /dev/null +++ b/resources/web/print_processing/js/app.js @@ -0,0 +1,215 @@ +/* + * app.js - wiring for the print-processing popup. + * + * Startup mirrors the shipped popup's own order (docs: + * 03-print-processing/02-lifecycle.md): + * 1. sw_GetActiveFile which file Orca is about to send + * 2. sw_GetConnectedMachine which printer is attached + * 3. sw_GetPrintLegal does the preset match that printer + * 4. sw_GetFileFilamentMapping what filaments the file needs + * 5. sw_SetSubscribeFilter + sw_SubscribeMachineState + * so print_task_config stays live + * + * Two modes, matching Orca's two routes: + * ?mode=print -> ?path=4 preUploadAndPrint (filament + preferences shown) + * ?mode=upload -> ?path=5 preUpload (those sections hidden) + */ +'use strict'; + +import { CMD, SUBSCRIBE_OBJECTS, MAPPING_STATUS, TASK_CONFIG } + from '../../shared/js/protocol.js'; +import { Sswcp } from '../../shared/js/sswcp.js'; +import { MachineState } from '../../shared/js/state.js'; +import { mountBuildBadge } from '../../shared/js/buildinfo.js'; +import { installMock } from './mock.js'; +import * as ui from './ui.js'; + +const qs = new URLSearchParams(location.search); +const wantMock = qs.get('mock') === '1'; +/** upload-only is ?path=5 in Orca; anything else is the print flow. */ +const MODE = qs.get('mode') === 'upload' ? 'upload' : 'print'; +const WITH_PRINT_SETUP = MODE === 'print'; + +const state = new MachineState(); +let bridge = null; +let device = null; +let legal = null; +let file = { filename: '', predictionTime: 0, weight: 0 }; +let mapping = null; +let prefs = {}; +let trace = () => {}; +let uploadPct = 0; + +function setStatus(text, kind = '') { + const n = ui.$('#status'); + n.textContent = text; + n.className = 'status ' + kind; +} + +async function boot() { + trace = ui.makeTrace(ui.$('#trace')); + + let mock = null; + if (wantMock || !Sswcp.hasHost()) { + mock = installMock({ log: trace, onClose: (ok) => setStatus( + ok ? 'dialog closed: success' : 'dialog closed: canceled', ok ? 'ok' : 'warn') }); + window.__preprint.mock = mock; + setStatus('simulated host (no Orca)', 'mock'); + } else { + setStatus('connected to Orca', 'ok'); + } + ui.$('#mode').textContent = (mock ? 'MOCK' : 'LIVE') + ' · ' + + (WITH_PRINT_SETUP ? 'path=4 upload+print' : 'path=5 upload only'); + ui.$('#mode').className = 'mode ' + (mock ? 'mock' : 'live'); + + bridge = new Sswcp({ log: trace }); + + try { + const f = await bridge.request(CMD.GET_ACTIVE_FILE, {}); + if (f && f.filename) file.filename = f.filename; + } catch (e) { console.warn('[preprint] active file:', e.message); } + + try { + device = await bridge.request(CMD.GET_CONNECTED_MACHINE, {}); + } catch (e) { + console.warn('[preprint] no connected machine:', e.message); + setStatus('no connected machine', 'warn'); + } + + if (device) { + try { + legal = await bridge.request(CMD.GET_PRINT_LEGAL, { + connected_model: device.model_name || device.machineType || '', + }); + } catch (e) { console.warn('[preprint] legality check:', e.message); } + } + + if (WITH_PRINT_SETUP) await loadMapping(); + + // print_task_config arrives over the shared state subscription - the same + // objects and field filter the Device tab uses. + try { + await bridge.request(CMD.SET_SUBSCRIBE_FILTER, { objects: SUBSCRIBE_OBJECTS }); + const snap = await bridge.request(CMD.GET_MACHINE_STATE, { objects: SUBSCRIBE_OBJECTS }); + state.applyPayload(snap); + await bridge.subscribe(CMD.SUBSCRIBE_MACHINE_STATE, {}, (d) => state.applyPayload(d)); + } catch (e) { console.warn('[preprint] state:', e.message); } + + prefs = Object.assign({}, state.taskConfig()); + state.onChange(render); + render(); + + mountBuildBadge(ui.$('#build-badge'), 'Print processing', bridge) + .then((info) => { window.__preprint.build = info; }) + .catch(() => {}); +} + +async function loadMapping() { + try { + mapping = await bridge.request(CMD.GET_FILE_FILAMENT_MAPPING, + { filename: file.filename }); + if (mapping) { + file.predictionTime = mapping.prediction_time ?? file.predictionTime; + file.weight = mapping.weight ?? file.weight; + } + } catch (e) { + console.warn('[preprint] filament mapping:', e.message); + } +} + +/* ---- handlers ------------------------------------------------------ */ + +const handlers = { + pickPrinter: () => setStatus('printer picker not implemented in this reconstruction', 'warn'), + + refreshFilament: async () => { await loadMapping(); render(); }, + + assignSlot: (filamentIndex, toolhead) => { + if (!mapping || !mapping.filaments[filamentIndex]) return; + mapping.filaments[filamentIndex].extruder = toolhead; + const table = mapping.filaments.map((f) => Number(f.extruder) || 0); + bridge.request(CMD.UPDATE_MACHINE_FILAMENT_INFO, + { [TASK_CONFIG.MAP_TABLE]: table }) + .catch((e) => console.warn('[preprint] update mapping:', e.message)); + render(); + }, + + setPreference: (key, value) => { + prefs[key] = value; + bridge.request(CMD.UPDATE_MACHINE_FILAMENT_INFO, { [key]: value }) + .catch((e) => console.warn('[preprint] update preference:', e.message)); + render(); + }, + + /** + * Send. Mirrors the real close protocol: report the outcome, record the + * result, then close - three separate commands, in that order. + */ + send: async () => { + const btn = ui.$('#send'); + btn.disabled = true; + setStatus('uploading…'); + try { + await bridge.request(CMD.GET_PRINT_ZIP, {}); + for (let p = 0; p <= 100; p += 10) { + uploadPct = p; + ui.renderSend(uploadPct, false, 'Sending…'); + await new Promise((r) => setTimeout(r, 60)); + } + if (WITH_PRINT_SETUP) await bridge.request(CMD.START_LOCAL_PRINT, {}); + await bridge.request(CMD.FINISH_PREPRINT, { status: 'success' }); + await bridge.request(CMD.SET_FILAMENT_MAPPING_COMPLETE, + { status: MAPPING_STATUS.SUCCESS }); + await bridge.request(CMD.FINISH_FILAMENT_MAPPING, {}); + setStatus('sent', 'ok'); + ui.renderSend(100, false, 'Sent'); + } catch (e) { + setStatus(`send failed: ${e.message}`, 'err'); + await bridge.request(CMD.FINISH_PREPRINT, { status: 'failed' }).catch(() => {}); + ui.renderSend(uploadPct, true, 'Send'); + } + }, +}; + +/* ---- render -------------------------------------------------------- */ + +let raf = 0; +function render() { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + const tc = Object.assign({}, state.taskConfig(), prefs); + + ui.renderModel(ui.$('#model'), file); + ui.renderPrinter(ui.$('#printer'), device, legal, handlers); + + // path=5 (upload only) drops filament mapping and print preferences + // entirely - that is the whole difference between the two routes. + const filCard = ui.$('#filament'); + const prefCard = ui.$('#preferences'); + filCard.hidden = !WITH_PRINT_SETUP; + prefCard.hidden = !WITH_PRINT_SETUP; + if (WITH_PRINT_SETUP) { + ui.renderFilament(filCard, mapping, tc, handlers); + ui.renderPreferences(prefCard, tc, handlers); + } + + // Send needs a printer, and a legal preset match when one was checked. + const ready = !!device && (!legal || legal.legal !== false); + ui.renderSend(uploadPct, ready, 'Send'); + }); +} + +window.addEventListener('DOMContentLoaded', () => { + ui.$('#send').onclick = () => handlers.send(); + boot().catch((e) => { + console.error(e); + setStatus(`startup failed: ${e.message}`, 'err'); + }); +}); + +window.__preprint = { + get state() { return state; }, get bridge() { return bridge; }, + get mapping() { return mapping; }, get device() { return device; }, + mode: MODE, handlers, mock: null, +}; diff --git a/resources/web/print_processing/js/mock.js b/resources/web/print_processing/js/mock.js new file mode 100644 index 00000000000..d8b826c536a --- /dev/null +++ b/resources/web/print_processing/js/mock.js @@ -0,0 +1,88 @@ +/* + * mock.js - the print-processing popup's simulated host. + * + * Reuses the shared U1 simulation and adds only the commands this surface + * issues. The filament mapping it returns is derived from the SAME + * print_task_config the Device tab reads, which is the point: one model, two + * surfaces, one reading it and one editing it. + */ +'use strict'; + +import { installMockHost } from '../../shared/js/mockhost.js'; +import { MAPPING_STATUS } from '../../shared/js/protocol.js'; + +const FILE = { + filename: 'mock_multicolor_benchy.gcode', + prediction_time: 3182, + weight: 31.4, +}; + +export function installMock({ log = () => {}, onClose = () => {} } = {}) { + let mapping = null; + + const handlers = { + sw_GetActiveFile: () => ({ filename: FILE.filename }), + + // The preset the plate was sliced for vs the machine actually connected. + sw_GetPrintLegal: (params) => ({ + preset_model: 'Snapmaker U1', + legal: !params.connected_model || params.connected_model === 'Snapmaker U1', + }), + + // Built from print_task_config so both surfaces agree on the filament set. + sw_GetFileFilamentMapping: (_params, ctx) => { + const tc = ctx.printer.snapshot().print_task_config; + mapping = { + filename: FILE.filename, + prediction_time: FILE.prediction_time, + weight: FILE.weight, + filaments: (tc.filament_type || []).map((type, i) => ({ + id: i, + type, + color: (tc.filament_color || [])[i] || '#888888', + vendor: (tc.filament_vendor || [])[i] || '', + used_g: [12.4, 9.1, 6.7, 3.2][i] ?? 0, + extruder: (tc.extruders_used || [])[i] ?? i, + })), + }; + return mapping; + }, + + sw_UpdateMachineFilamentInfo: (params, ctx) => { + // Mirror the edit into print_task_config, exactly as the real flow does. + const tc = ctx.printer.taskConfigOverride || (ctx.printer.taskConfigOverride = {}); + if (Array.isArray(params.extruder_map_table)) { + tc.extruder_map_table = params.extruder_map_table; + } + Object.assign(tc, params); + return {}; + }, + + sw_GetPrintZip: () => ({ name: FILE.filename.replace(/\.gcode$/, '.zip'), content: '' }), + sw_StartLocalPrint: () => ({ task_id: 'mock-task-1' }), + sw_StartCloudPrint: () => ({ task_id: 'mock-task-1' }), + + // The two-step close: record the outcome, then end the dialog. + sw_SetFilamentMappingComplete: (params) => { + mockState.finish = params.status === MAPPING_STATUS.SUCCESS; + return {}; + }, + sw_FinishPreprint: (params) => { + mockState.preprintStatus = params.status || null; + return {}; + }, + sw_FinishFilamentMapping: () => { + mockState.closed = true; + onClose(mockState.finish); + return {}; + }, + }; + + const host = installMockHost({ log, handlers }); + if (!host) return null; + + const mockState = { finish: false, closed: false, preprintStatus: null, + get mapping() { return mapping; } }; + host.state = mockState; + return host; +} diff --git a/resources/web/print_processing/js/ui.js b/resources/web/print_processing/js/ui.js new file mode 100644 index 00000000000..c883b5fed7d --- /dev/null +++ b/resources/web/print_processing/js/ui.js @@ -0,0 +1,188 @@ +/* + * ui.js - rendering for the print-processing popup. + * + * Section order and labels follow the shipped Flutter popup exactly: + * Model Information / Select Printer / Edit Filament / Print Preferences + * then a send bar carrying upload progress and the Send button. + * See docs/u1-webui/03-print-processing/01-overview.md + */ +'use strict'; + +import { PRINT_PREFERENCES } from '../../shared/js/protocol.js'; + +export const $ = (sel, root = document) => root.querySelector(sel); + +function el(tag, cls, text) { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; +} + +function section(root, title, extraHead) { + root.innerHTML = ''; + const head = el('div', 'card-head'); + head.appendChild(el('h2', null, title)); + if (extraHead) head.appendChild(extraHead); + root.appendChild(head); + const body = el('div', 'card-body'); + root.appendChild(body); + return body; +} + +/** Seconds -> "1 h 12 min", the popup's own granularity. */ +export function humanDuration(sec) { + const s = Number(sec); + if (!Number.isFinite(s) || s <= 0) return 'N/A'; + const h = Math.floor(s / 3600); + const m = Math.round((s % 3600) / 60); + return h ? `${h} h ${m} min` : `${m} min`; +} + +export function humanGrams(g) { + const n = Number(g); + return Number.isFinite(n) && n > 0 ? `${n.toFixed(1)} g` : 'N/A'; +} + +/* ---- Model Information -------------------------------------------- */ +export function renderModel(root, file) { + const b = section(root, 'Model Information'); + const row = el('div', 'model-row'); + + const thumb = el('div', 'thumb'); + thumb.appendChild(el('span', 'thumb-tag', 'G-code')); + row.appendChild(thumb); + + const facts = el('dl', 'facts'); + const add = (k, v) => { + facts.appendChild(el('dt', null, k)); + facts.appendChild(el('dd', null, v)); + }; + add('Filename', file.filename || 'N/A'); + add('Estimated Time', humanDuration(file.predictionTime)); + add('Estimated Materials', humanGrams(file.weight)); + row.appendChild(facts); + + b.appendChild(row); +} + +/* ---- Select Printer ------------------------------------------------ */ +export function renderPrinter(root, device, legal, handlers) { + const b = section(root, 'Select Printer'); + const row = el('div', 'model-row'); + row.appendChild(el('div', 'thumb printer-thumb')); + + const pick = el('div', 'picker'); + const sel = el('button', 'select'); + sel.type = 'button'; + sel.textContent = device + ? (device.name || device.deviceName || device.model_name || device.sn) + : 'Click to select printer'; + sel.onclick = () => handlers.pickPrinter && handlers.pickPrinter(); + pick.appendChild(sel); + + if (device && legal && legal.legal === false) { + // sw_GetPrintLegal compares the edited printer preset against the machine + // actually connected; a mismatch is what blocks the send. + const w = el('p', 'warn-line', + `This plate was sliced for ${legal.preset_model || 'another model'}, ` + + 'which does not match the connected printer.'); + pick.appendChild(w); + } + row.appendChild(pick); + b.appendChild(row); +} + +/* ---- Edit Filament ------------------------------------------------- */ +export function renderFilament(root, mapping, taskConfig, handlers) { + const refresh = el('button', 'icon-btn', '⟳'); + refresh.title = 'Re-read the filament mapping'; + refresh.onclick = () => handlers.refreshFilament && handlers.refreshFilament(); + + const b = section(root, 'Edit Filament', refresh); + + const rows = (mapping && mapping.filaments) || []; + if (!rows.length) { + b.appendChild(el('p', 'empty', 'No filament requirements read from this file yet.')); + return; + } + + const types = taskConfig.filament_type || []; + const colors = taskConfig.filament_color || []; + + const grid = el('div', 'fil-grid'); + rows.forEach((f, i) => { + const card = el('div', 'fil-row'); + + const chip = el('span', 'chip'); + chip.style.background = f.color || colors[i] || '#888'; + card.appendChild(chip); + + const meta = el('div', 'fil-meta'); + meta.appendChild(el('span', 'fil-name', `Filament ${i + 1} · ${f.type || types[i] || '—'}`)); + meta.appendChild(el('span', 'fil-sub', `${Number(f.used_g || 0).toFixed(1)} g`)); + card.appendChild(meta); + + card.appendChild(el('span', 'arrow', '→')); + + // Which physical toolhead this filament is assigned to. The popup writes + // this back as print_task_config.extruder_map_table. + const sel = el('select', 'slot'); + for (let t = 0; t < 4; t++) { + const o = el('option', null, `Toolhead ${t + 1}`); + o.value = String(t); + if (Number(f.extruder) === t) o.selected = true; + sel.appendChild(o); + } + sel.onchange = () => handlers.assignSlot && handlers.assignSlot(i, Number(sel.value)); + card.appendChild(sel); + + grid.appendChild(card); + }); + b.appendChild(grid); +} + +/* ---- Print Preferences --------------------------------------------- */ +export function renderPreferences(root, taskConfig, handlers) { + const b = section(root, 'Print Preferences'); + const list = el('div', 'prefs'); + + PRINT_PREFERENCES.forEach(({ key, label }) => { + const row = el('label', 'pref-row'); + row.appendChild(el('span', 'pref-label', label)); + const box = el('input'); + box.type = 'checkbox'; + box.checked = !!taskConfig[key]; + box.onchange = () => handlers.setPreference && handlers.setPreference(key, box.checked); + row.appendChild(box); + list.appendChild(row); + }); + + b.appendChild(list); +} + +/* ---- send bar ------------------------------------------------------- */ +export function renderSend(pct, enabled, label) { + const fill = $('#progress-fill'); + const p = Math.max(0, Math.min(100, Math.round(pct))); + fill.style.width = `${p}%`; + $('#progress-pct').textContent = `${p}%`; + const btn = $('#send'); + btn.disabled = !enabled; + if (label) btn.textContent = label; +} + +/* ---- trace ---------------------------------------------------------- */ +export function makeTrace(pane) { + return (kind, packet) => { + if (!pane || pane.dataset.paused === '1') return; + const line = el('div', `t t-${kind}`); + const cmd = (packet && packet.payload && packet.payload.cmd) + || (packet && packet.header && packet.header.event_id ? 'push' : ''); + line.textContent = `${kind.padEnd(9)} ${cmd}`; + line.title = JSON.stringify(packet); + pane.appendChild(line); + while (pane.childElementCount > 200) pane.removeChild(pane.firstChild); + pane.scrollTop = pane.scrollHeight; + }; +} diff --git a/resources/web/shared/README.md b/resources/web/shared/README.md new file mode 100644 index 00000000000..cc5eb8509d9 --- /dev/null +++ b/resources/web/shared/README.md @@ -0,0 +1,38 @@ +# Shared layer + +What the two reconstructed U1 surfaces have in common, factored out so it is written +once and guarded once. + +| Module | Role | +|---|---| +| `js/sswcp.js` | The WCP bridge client — envelope, `seqid` correlation, subscriptions. Identical for both surfaces because both forward to the same `SSWCP::handle_web_message`. | +| `js/protocol.js` | Command names, the 24-object state model and its field filters, `print_task_config` field names, control limits, fault decoding. | +| `js/state.js` | The machine-state store: partial-push merging plus typed accessors (`toolheads()`, `taskConfig()`, `bed()`, `job()`). | +| `js/mockhost.js` | A simulated Orca host and U1. Each surface passes its own `handlers` for the commands only it needs. | +| `js/buildinfo.js` | Resolves and renders the build badge. | +| `css/base.css` | Design tokens and the badge. Surfaces layer their own stylesheet on top. | +| `tests/conformance_test.py` | Re-derives the constant tables from `docs/u1-webui/data/` and fails if the code has drifted. Guards **both** surfaces. | + +Consumers: + +- [`../device_page/`](../device_page/) — the Device tab (`?path=2`) +- [`../print_processing/`](../print_processing/) — the print popup (`?path=4` / `?path=5`) + +## Two things worth knowing before you change anything here + +**The bridge success code is `200`, not `0`.** `SSWCP.hpp` defaults `m_status = 200` and +the shipped bundle compares `payload.code` against the literal `200` in eleven places. +A client that accepts only `0` rejects every real response from Orca. `OK_CODE` and +`isOk()` in `sswcp.js` are the single definition; the conformance test pins them. + +**`print_task_config` is read by one surface and written by the other.** The Device tab +labels its toolheads from it; the popup edits the filament mapping and the three +preference toggles in it. A change to that model touches both surfaces. + +Full analysis: [what the two surfaces share](../../../docs/u1-webui/00-shared/01-shared-models.md). + +## Verifying + +```bash +python3 resources/web/shared/tests/conformance_test.py +``` diff --git a/resources/web/shared/build-stamp.json b/resources/web/shared/build-stamp.json new file mode 100644 index 00000000000..b4aca642ce8 --- /dev/null +++ b/resources/web/shared/build-stamp.json @@ -0,0 +1,8 @@ +{ + "commit": "40ce3f0c38", + "commit_full": "40ce3f0c3882367e54e7d90fd1995e52fc0b78a3", + "branch": "re/u1-device-page", + "dirty": true, + "subject": "refactor(sidebar): one size for the sync mark, everywhere it appears", + "committed": "2026-08-23T10:41:59+02:00" +} diff --git a/resources/web/shared/css/base.css b/resources/web/shared/css/base.css new file mode 100644 index 00000000000..2f67016074f --- /dev/null +++ b/resources/web/shared/css/base.css @@ -0,0 +1,84 @@ +/* + * base.css - tokens and primitives shared by both reconstructed surfaces. + * + * Surface-specific rules live in each surface's own stylesheet and are layered + * on top of this file. + */ +:root { + --bg: #f4f6f8; + --panel: #ffffff; + --panel-2: #eef2f6; + --ink: #16202b; + --ink-2: #47566a; + --muted: #7c8b9d; + --line: #d8e0e8; + --accent: #0f6e7b; + --accent-in: #ffffff; + --ok: #1f7a4d; + --warn: #9a5b12; + --err: #a32b2b; + --radius: 8px; + --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0e1319; + --panel: #161d26; + --panel-2: #1d2732; + --ink: #e6ecf3; + --ink-2: #b3c0cf; + --muted: #7f8d9d; + --line: #26313d; + --accent: #4fbecd; + --accent-in: #08181c; + --ok: #4cc38a; + --warn: #d79a4f; + --err: #e07070; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; +} + +/* ---- build badge ------------------------------------------------------ + The reconstruction marker. Deliberately unmissable but out of the way: + fixed to the bottom-right of whichever surface is running. */ +.build-badge { + position: fixed; + right: 10px; + bottom: 10px; + z-index: 9999; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--ink-2); + font-family: var(--mono); + font-size: 11px; + line-height: 1; + letter-spacing: .01em; + box-shadow: 0 1px 2px rgba(0, 0, 0, .10), 0 6px 18px rgba(0, 0, 0, .10); + pointer-events: auto; + user-select: text; +} +.build-badge .bb-surface { color: var(--accent); font-weight: 600; } +.build-badge .bb-sep { color: var(--muted); } +.build-badge .bb-build { font-variant-numeric: tabular-nums; } +.build-badge .bb-commit { color: var(--ink); font-weight: 600; } + +@media (prefers-reduced-motion: reduce) { + * { animation: none !important; transition: none !important; } +} diff --git a/resources/web/shared/js/activity.js b/resources/web/shared/js/activity.js new file mode 100644 index 00000000000..66372c0520f --- /dev/null +++ b/resources/web/shared/js/activity.js @@ -0,0 +1,118 @@ +/* + * activity.js - what the machine says it is doing. GENERATED, do not edit. + * python3 docs/u1-webui/tools/gen_activity_module.py + * + * Two tables, because the printer reports at two granularities and they are not + * interchangeable: + * + * machine_state_manager.main_state coarse - "XYZ calibrating", "Docking Coordinate + * Calibrating" - the thing that makes a toolchange + * take minutes + * machine_state_manager.action_code fine - "Checking Extruder Pick...", and only + * populated for some operations + * + * They must stay keyed apart: their case spaces overlap, so 1 is "Working" in one and + * "Homing" in the other. Merging them would be wrong wherever it is ambiguous. + * + * This is not decoration. A toolchange can trigger a calibration that runs far longer + * than the bridge's 15s request timeout, so without these a client cannot tell a slow + * success from a failure - it only sees its own request give up. + */ +'use strict'; + +export const ACTION_LABELS = { + 128: "Resuming printing...", + 129: "Paused", + 130: "Resuming print...", + 131: "Auto-reloading filament...", + 132: "Checking toolheads...", + 133: "Auto-loading filament...", + 134: "Pre-extruding...", + 135: "Auto-unloading filament...", + 136: "Bed detecting...", + 192: "Cleaning Nozzle 1 (1/4)", + 193: "Cleaning Nozzle 2 (2/4)", + 194: "Cleaning Nozzle 3 (3/4)", + 195: "Cleaning Nozzle 4 (4/4)", + 196: "Calibrating Toolhead 1...", + 197: "Calibrating Toolhead 2...", + 198: "Calibrating Toolhead 3...", + 199: "Calibrating Toolhead 4...", + 200: "Wiping Nozzle...", + 201: "Cooling Nozzle...", + 256: "Leveling progress...", + 320: "Toolhead 1 Calibrating extrusion flow", + 321: "Toolhead 2 Calibrating extrusion flow", + 322: "Toolhead 3 Calibrating extrusion flow", + 323: "Toolhead 4 Calibrating extrusion flow", + 384: "Calibrating vibration compensation...", + 512: "Reset to Initial Position", + 513: "Sampling to Calculate Reference Height", + 514: "Adjust Leveling Wheels", + 515: "Re-checking reference points...", + 576: "Auto Loading", + 640: "Unloading", + 704: "Manual Loading", + 768: "Extruder Docking Calibrating...", + 769: "Checking Extruder Park...", + 770: "Checking Extruder Pick...", + 832: "Homing Calibration...", +}; + +export const MAIN_STATE_LABELS = { + 0: "Idle", + 1: "Working", + 2: "XYZ calibrating", + 3: "Heated bed Calibrating", + 4: "Flow Calibrating", + 5: "Vibration Compensation Calibrating", + 6: "Updating", + 7: "Error", + 8: "Manual Adjusting Spring Screw", + 9: "Auto Loading", + 10: "Unloading", + 11: "Manual Loading", + 12: "Docking Coordinate Calibrating", + 13: "Homing Calibration...", + 14: "Offline", +}; + +/* Idle says nothing, and Working says nothing a caller waiting on a specific operation + * did not already know. Everything else is worth showing. */ +const UNINFORMATIVE_MAIN_STATES = new Set([0, 1]); + +/** Text for an action code, or null when idle or silent. */ +export function activityLabel(code) { + if (code == null) return null; + const n = Number(code); + if (!Number.isFinite(n) || n === 0) return null; + return ACTION_LABELS[n] || `Working (code ${n})`; +} + +/** Text for a main_state, or null when it carries no information. */ +export function mainStateLabel(state) { + if (state == null) return null; + const n = Number(state); + if (!Number.isFinite(n) || UNINFORMATIVE_MAIN_STATES.has(n)) return null; + return MAIN_STATE_LABELS[n] || null; +} + +/** + * The best sentence available for what the machine is doing. + * + * action_code first because it is the more specific of the two, then main_state, which + * is where a calibration triggered by a toolchange actually shows up. + */ +export function machineActivity(activity) { + if (!activity) return null; + return activityLabel(activity.actionCode) || mainStateLabel(activity.mainState); +} + +/** Is the machine reporting that it is busy with anything at all? */ +export function isBusy(activity) { + if (!activity) return false; + const a = Number(activity.actionCode); + if (Number.isFinite(a) && a !== 0) return true; + const m = Number(activity.mainState); + return Number.isFinite(m) && !UNINFORMATIVE_MAIN_STATES.has(m) && m !== 14; +} diff --git a/resources/web/shared/js/buildinfo.js b/resources/web/shared/js/buildinfo.js new file mode 100644 index 00000000000..ca5030ff7d3 --- /dev/null +++ b/resources/web/shared/js/buildinfo.js @@ -0,0 +1,145 @@ +/* + * buildinfo.js - resolve and display the running build number. + * + * Shared by both reconstructed surfaces. It exists so a rebuilt surface is + * visually identifiable at a glance: if the badge is showing, you are looking + * at the reconstruction, not the shipped Flutter bundle. + * + * The build number is read, in order of preference: + * 1. sw_GetSoftwareInfo over the bridge - what Orca itself reports + * 2. the shipped bundle's version.json - correct even with no host + * 3. a literal fallback, so the badge always renders something + * + * version.json is the same file the Flutter bundle reads: + * resources/web/flutter_web/version.json + * {"app_name":"orca","version":"2.3.26","build_number":"20260813142841", ...} + * + * The git commit cannot come from the bundle, so it is stamped separately into + * build-stamp.json by resources/web/shared/stamp_build.py. That file is optional: + * with it, the badge shows the commit the surfaces were built from; without it, + * the badge simply omits the commit. + */ +'use strict'; + +import { CMD } from './protocol.js'; + +/** Used only if both the bridge and version.json are unreachable. */ +const FALLBACK = { version: '2.3.26', build_number: '20260813142841', source: 'fallback' }; + +/** Both paths are relative to a surface served out of resources/web//. */ +const VERSION_URL = '../flutter_web/version.json'; +const STAMP_URL = '../shared/build-stamp.json'; + +/** 20260813142841 -> 2026-08-13 14:28:41 */ +export function formatBuildNumber(bn) { + const s = String(bn || ''); + if (!/^\d{14}$/.test(s)) return s; + return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)} ` + + `${s.slice(8, 10)}:${s.slice(10, 12)}:${s.slice(12, 14)}`; +} + +/** Read the git stamp. Returns null when it has not been generated. */ +async function readStamp() { + try { + const r = await fetch(STAMP_URL, { cache: 'no-store' }); + if (!r.ok) return null; + const j = await r.json(); + return (j && j.commit) ? j : null; + } catch { + return null; + } +} + +/** + * Resolve build info by MERGING the available sources rather than picking one. + * + * They carry different facts and none of them has all three: + * - build-stamp.json the git commit these surfaces were built from + * - sw_GetSoftwareInfo Orca's own version (it returns no build number) + * - version.json the Flutter bundle's version and build number + * + * Never rejects: a badge showing a fallback is better than no badge, because the + * point of it is to be visible. + */ +export async function resolveBuildInfo(bridge) { + const info = { ...FALLBACK, sources: [] }; + info.stamp = await readStamp(); + if (info.stamp) info.sources.push('build-stamp.json'); + + // The shipped bundle's own file: version + build number. + try { + const r = await fetch(VERSION_URL, { cache: 'no-store' }); + if (r.ok) { + const j = await r.json(); + if (j && j.build_number) { + info.version = j.version || info.version; + info.build_number = j.build_number; + info.sources.push('version.json'); + } + } + } catch { /* keep the fallback */ } + + // Orca itself, when a host is present. It reports `version` but no build + // number, so it refines the version and leaves the build number alone. + if (bridge) { + try { + const d = await bridge.request(CMD.GET_SOFTWARE_INFO, {}); + if (d && (d.version || d.build_number)) { + if (d.version) info.version = d.version; + if (d.build_number || d.buildNumber) { + info.build_number = d.build_number || d.buildNumber; + } + info.sources.push('sw_GetSoftwareInfo'); + } + } catch { /* no host, or the command failed - keep what we have */ } + } + + info.source = info.sources.length ? info.sources.join(' + ') : 'fallback'; + return info; +} + +/** + * Render the badge into `el`. + * + * `surface` names which reconstruction this is, so the two are told apart when + * both are open - the whole point of the marker. + */ +export function renderBuildBadge(el, info, surface) { + if (!el) return; + const st = info.stamp; + el.className = 'build-badge'; + el.title = `build ${info.build_number} (${formatBuildNumber(info.build_number)})` + + `\nreported by: ${info.source}` + + (st ? `\ncommit ${st.commit_full || st.commit}` + + (st.branch ? ` on ${st.branch}` : '') + + (st.dirty ? ' (uncommitted changes present)' : '') + + (st.subject ? `\n"${st.subject}"` : '') + : '\ncommit: not stamped - run resources/web/shared/stamp_build.py'); + el.innerHTML = ''; + + const add = (cls, text) => { + const s = document.createElement('span'); + s.className = cls; + s.textContent = text; + el.appendChild(s); + return s; + }; + add('bb-surface', surface); + add('bb-sep', '·'); + add('bb-version', `v${info.version}`); + add('bb-sep', '·'); + add('bb-build', `build ${info.build_number}`); + if (st) { + add('bb-sep', '·'); + // A dirty tree means the running surface is not exactly this commit, so say so + // rather than showing a hash that does not describe what is on screen. + add('bb-commit', st.commit + (st.dirty ? '+' : '')); + } +} + +/** Resolve and render in one call. Returns the resolved info. */ +export async function mountBuildBadge(el, surface, bridge) { + const info = await resolveBuildInfo(bridge); + renderBuildBadge(el, info, surface); + return info; +} diff --git a/resources/web/shared/js/errors.js b/resources/web/shared/js/errors.js new file mode 100644 index 00000000000..418194d7249 --- /dev/null +++ b/resources/web/shared/js/errors.js @@ -0,0 +1,1837 @@ +/* + * errors.js - the U1 fault catalogue. + * + * GENERATED by docs/u1-webui/tools/gen_error_module.py from + * docs/u1-webui/data/error-catalog.json. Do not edit by hand. + * + * Codes are 16 hex digits: SSSS MMMM UUUU EEEE + * class/severity, subsystem, unit index (toolhead), specific fault. + * See docs/u1-webui/06-errors/01-live-error-catalogue.md + */ +'use strict'; + +export const SUBSYSTEMS = { + "0522": "system / motion", + "0523": "toolhead", + "0525": "filament", + "0526": "heated bed", + "0527": "chamber", + "0528": "homing / axis", + "0530": "calibration / detection", + "0531": "job / storage", + "0532": "defect detection (vision)", + "0533": "top cover / chamber" +}; + +/** 442 codes, [title, description]. */ +export const FAULTS = { +"0001052300000037": [ +"Toolhead Maintenance", +"Toolhead swapping times have reached maintenance requirement. Clean the Pogopins on both sides, and add grease to the steel balls and steel pins of the toolhead." +], +"0001052300010048": [ +"Toolhead Swapping Anomaly", +"A Pogo Pin connection error was detected during printing. Clean the contacts on the toolhead and toolhead swapper. If the error persists, tap to view the error count and contact support." +], +"0001052500000008": [ +"Filament Anomaly", +"Toolhead 1 has residual filament. Unload the filament from the toolhead first, then retry." +], +"0001052500000010": [ +"Filament Pre-loading Anomaly", +"Feeder 1 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0001052500000011": [ +"Filament Pre-loading Anomaly", +"Feeder 1 motor speed anomaly. Check and ensure the feeder cable is properly connected. If the error persists, contact Technical Support." +], +"0001052500000012": [ +"Filament Pre-loading Anomaly", +"Feeder 1 encoder wheel speed anomaly. The filament may be tangled or the feeding path obstructed. Reload the filament after resolving the issue." +], +"0001052500000013": [ +"Filament Pre-loading Anomaly", +"No filament detected in Feeder 1. Insert filament." +], +"0001052500000014": [ +"Filament Pre-loading Anomaly", +"Feeder 1 feeding timeout. The filament may be tangled or the feeding path obstructed. Reload the filament after resolving the issue." +], +"0001052500000015": [ +"Filament Pre-loading Anomaly", +"Residual filament in Toolhead 1. Remove the residual filament from the toolhead, then reload." +], +"0001052500000090": [ +"Manual Loading Anomaly", +"Feeder 1 manual loading anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0001052500010008": [ +"Filament Anomaly", +"Toolhead 2 has residual filament. Unload the filament from the toolhead first, then retry." +], +"0001052500010010": [ +"Filament Pre-loading Anomaly", +"Feeder 2 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0001052500010011": [ +"Filament Pre-loading Anomaly", +"Feeder 2 motor speed anomaly. Check and ensure the feeder cable is properly connected. If the error persists, contact Technical Support." +], +"0001052500010012": [ +"Filament Pre-loading Anomaly", +"Feeder 2 encoder wheel speed anomaly. The filament may be tangled or the feeding path obstructed. Reload the filament after resolving the issue." +], +"0001052500010013": [ +"Filament Pre-loading Anomaly", +"No filament detected in Feeder 2. Insert filament." +], +"0001052500010014": [ +"Filament Pre-loading Anomaly", +"Feeder 2 feeding timeout. The filament may be tangled or the feeding path obstructed. Reload the filament after resolving the issue." +], +"0001052500010015": [ +"Filament Pre-loading Anomaly", +"Residual filament in Toolhead 2. Remove the residual filament from the toolhead, then reload." +], +"0001052500010090": [ +"Manual Loading Anomaly", +"Feeder 2 manual loading anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0001052500020008": [ +"Filament Anomaly", +"Toolhead 3 has residual filament. Unload the filament from the toolhead first, then retry." +], +"0001052500020010": [ +"Filament Pre-loading Anomaly", +"Feeder 3 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0001052500020011": [ +"Filament Pre-loading Anomaly", +"Feeder 3 motor speed anomaly. Check and ensure the feeder cable is properly connected. If the error persists, contact Technical Support." +], +"0001052500020012": [ +"Filament Pre-loading Anomaly", +"Feeder 3 encoder wheel speed anomaly. The filament may be tangled or the feeding path obstructed. Reload the filament after resolving the issue." +], +"0001052500020013": [ +"Filament Pre-loading Anomaly", +"No filament detected in Feeder 3. Insert filament." +], +"0001052500020014": [ +"Filament Pre-loading Anomaly", +"Feeder 3 feeding timeout. The filament may be tangled or the feeding path obstructed. Reload the filament after resolving the issue." +], +"0001052500020015": [ +"Filament Pre-loading Anomaly", +"Residual filament in Toolhead 3. Remove the residual filament from the toolhead, then reload." +], +"0001052500020090": [ +"Manual Loading Anomaly", +"Feeder 3 manual loading anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0001052500030008": [ +"Filament Anomaly", +"Toolhead 4 has residual filament. Unload the filament from the toolhead first, then retry." +], +"0001052500030010": [ +"Filament Pre-loading Anomaly", +"Feeder 4 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0001052500030011": [ +"Filament Pre-loading Anomaly", +"Feeder 4 motor speed anomaly. Check and ensure the feeder cable is properly connected. If the error persists, contact Technical Support." +], +"0001052500030012": [ +"Filament Pre-loading Anomaly", +"Feeder 4 encoder wheel speed anomaly. The filament may be tangled or the feeding path obstructed. Reload the filament after resolving the issue." +], +"0001052500030013": [ +"Filament Pre-loading Anomaly", +"No filament detected in Feeder 4. Insert filament." +], +"0001052500030014": [ +"Filament Pre-loading Anomaly", +"Feeder 4 feeding timeout. The filament may be tangled or the feeding path obstructed. Reload the filament after resolving the issue." +], +"0001052500030015": [ +"Filament Pre-loading Anomaly", +"Residual filament in Toolhead 4. Remove the residual filament from the toolhead, then reload." +], +"0001052500030090": [ +"Manual Loading Anomaly", +"Feeder 4 manual loading anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0001053000000029": [ +"Uneven Heated Bed Detected", +"The heated bed flatness deviation is too large. Please perform manual bed leveling." +], +"0001053100000000": [ +"System Anomaly", +"Failed to retrieve file list." +], +"0001053100000001": [ +"System Anomaly", +"Printer is printing, cannot operate slicing files. Retry when printer is idle." +], +"0001053100000002": [ +"System Anomaly", +"Commands M28, M29, M30 are not supported. Please remove them." +], +"0001053100000003": [ +"System Anomaly", +"Parsing G-code commands from slicing file. Resetting slicing file is not allowed." +], +"0001053100000004": [ +"System Anomaly", +"Cannot open slicing file." +], +"0001053100000005": [ +"System Anomaly", +"Failed to resume printing after power outage." +], +"0001053100000006": [ +"System Anomaly", +"Deleting power-off data is not allowed during printing." +], +"0001053100000007": [ +"System Anomaly", +"Cloud print file download failed. Check your network connection and retry. If issue persists, contact technical support." +], +"0001053100000008": [ +"System Anomaly", +"USB flash drive read anomaly. Ensure the USB flash drive is properly connected and retry. If issue persists, contact technical support." +], +"0001053100000011": [ +"Cloud Print File Verification Failed", +"Resend the print file." +], +"0001053100000012": [ +"Cloud Print File Extraction Failed", +"Resend the print file." +], +"0001053100000015": [ +"Modification Failed", +"Channel mapping cannot be modified while a print is in progress. Please make adjustments after the task is completed." +], +"0001053100000016": [ +"Modification Failed", +"Preference settings cannot be modified while a print is in progress. Please make adjustments after the task is completed." +], +"0002052300000000": [ +"Filament Anomaly", +"Filament runout detected in Toolhead 1. Try reloading filament, then tap the play button to resume the print job." +], +"0002052300000007": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 1 and Toolhead 2 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000008": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 1 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000009": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 1 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000010": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000011": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000012": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000013": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 1, Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000014": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 1, Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000015": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 1, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000016": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check if Toolhead 2, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000017": [ +"Toolhead Swapping Failure", +"Toolhead 1 swapping failed. Check all toolheads one by one for dropping or tilting, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300000018": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300000019": [ +"Toolhead Parking Failure", +"Toolhead 1 parking failed. Retry. If the issue persists, contact technical support." +], +"0002052300000020": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300000021": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Retry. If issue persists, contact technical support." +], +"0002052300000022": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Retry. If issue persists, contact technical support." +], +"0002052300000023": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300000024": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 1 and Toolhead 2 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000025": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 1 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000026": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 1 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000027": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000028": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000029": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000030": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 1, Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000031": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 1, Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000032": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 1, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000033": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check if Toolhead 2, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000034": [ +"Toolhead Picking Failure", +"Toolhead 1 picking failed. Check all toolheads one by one for dropping or tilting, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300000035": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300000038": [ +"Printing Anomaly", +"Check if the filament for Toolhead 1 is broken inside the tube, tangled on the spool, or if the toolhead is clogged. After troubleshooting, tap to resume printing. If issue persists, contact technical support." +], +"0002052300000039": [ +"Filament Anomaly", +"Filament information not set for Toolhead 1. Go to the filament page to edit, then tap to resume printing." +], +"0002052300000040": [ +"Toolhead Swapping Anomaly", +"Toolhead 1 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300000041": [ +"Toolhead Swapping Anomaly", +"Toolhead 1 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300000042": [ +"Toolhead Swapping Anomaly", +"Toolhead 1 return failed. Foreign objects may be present in the dock or working area. Remove any obstructions, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300000043": [ +"Toolhead Swapping Anomaly", +"Toolhead 1 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300000044": [ +"Toolhead Swapping Anomaly", +"Toolhead 1 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300000045": [ +"Toolhead Swapping Anomaly", +"Toolhead 1 Pogopin contact anomaly. Clean both the toolhead and the Pogopin contacts on the toolhead swapper using a dust-free cloth. Then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300000046": [ +"Toolhead Swapping Anomaly", +"Toolhead 1 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300000047": [ +"Toolhead Swapping Anomaly", +"Toolhead 1 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300009000": [ +"Flow Calibration Anomaly", +"Toolhead 1 flow calibration anomaly. Contact technical support." +], +"0002052300010000": [ +"Filament Anomaly", +"Filament runout detected in Toolhead 2. Try reloading filament, then tap the play button to resume the print job." +], +"0002052300010007": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 1 and Toolhead 2 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010008": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 1 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010009": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 1 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010010": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010011": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010012": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010013": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 1, Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010014": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 1, Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010015": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 1, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010016": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check if Toolhead 2, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010017": [ +"Toolhead Swapping Failure", +"Toolhead 2 swapping failed. Check all toolheads one by one for dropping or tilting, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300010018": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300010019": [ +"Toolhead Parking Failure", +"Toolhead 2 parking failed. Retry. If the issue persists, contact technical support." +], +"0002052300010020": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300010021": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Retry. If issue persists, contact technical support." +], +"0002052300010022": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Retry. If issue persists, contact technical support." +], +"0002052300010023": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300010024": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 1 and Toolhead 2 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010025": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 1 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010026": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 1 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010027": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010028": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010029": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010030": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 1, Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010031": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 1, Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010032": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 1, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010033": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check if Toolhead 2, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010034": [ +"Toolhead Picking Failure", +"Toolhead 2 picking failed. Check all toolheads one by one for dropping or tilting, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300010035": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300010038": [ +"Printing Anomaly", +"Check if the filament for Toolhead 2 is broken inside the tube, tangled on the spool, or if the toolhead is clogged. After troubleshooting, tap to resume printing. If issue persists, contact technical support." +], +"0002052300010039": [ +"Filament Anomaly", +"Filament information not set for Toolhead 2. Go to the filament page to edit, then tap to resume printing." +], +"0002052300010040": [ +"Toolhead Swapping Anomaly", +"Toolhead 2 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300010041": [ +"Toolhead Swapping Anomaly", +"Toolhead 2 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300010042": [ +"Toolhead Swapping Anomaly", +"Toolhead 2 return failed. Foreign objects may be present in the dock or working area. Remove any obstructions, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300010043": [ +"Toolhead Swapping Anomaly", +"Toolhead 2 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300010044": [ +"Toolhead Swapping Anomaly", +"Toolhead 2 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300010045": [ +"Toolhead Swapping Anomaly", +"Toolhead 2 Pogopin contact anomaly. Clean both the toolhead and the Pogopin contacts on the toolhead swapper using a dust-free cloth. Then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300010046": [ +"Toolhead Swapping Anomaly", +"Toolhead 2 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300010047": [ +"Toolhead Swapping Anomaly", +"Toolhead 2 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300019000": [ +"Flow Calibration Anomaly", +"Toolhead 2 flow calibration anomaly. Contact technical support." +], +"0002052300020000": [ +"Filament Anomaly", +"Filament runout detected in Toolhead 3. Try reloading filament, then tap the play button to resume the print job." +], +"0002052300020007": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 1 and Toolhead 2 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020008": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 1 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020009": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 1 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020010": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020011": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020012": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020013": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 1, Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020014": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 1, Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020015": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 1, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020016": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check if Toolhead 2, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020017": [ +"Toolhead Swapping Failure", +"Toolhead 3 swapping failed. Check all toolheads one by one for dropping or tilting, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300020018": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300020019": [ +"Toolhead Parking Failure", +"Toolhead 3 parking failed. Retry. If the issue persists, contact technical support." +], +"0002052300020020": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300020021": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Retry. If issue persists, contact technical support." +], +"0002052300020022": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Retry. If issue persists, contact technical support." +], +"0002052300020023": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300020024": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 1 and Toolhead 2 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020025": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 1 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020026": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 1 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020027": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020028": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020029": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020030": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 1, Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020031": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 1, Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020032": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 1, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020033": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check if Toolhead 2, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020034": [ +"Toolhead Picking Failure", +"Toolhead 3 picking failed. Check all toolheads one by one for dropping or tilting, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300020035": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300020038": [ +"Printing Anomaly", +"Check if the filament for Toolhead 3 is broken inside the tube, tangled on the spool, or if the toolhead is clogged. After troubleshooting, tap to resume printing. If issue persists, contact technical support." +], +"0002052300020039": [ +"Filament Anomaly", +"Filament information not set for Toolhead 3. Go to the filament page to edit, then tap to resume printing." +], +"0002052300020040": [ +"Toolhead Swapping Anomaly", +"Toolhead 3 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300020041": [ +"Toolhead Swapping Anomaly", +"Toolhead 3 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300020042": [ +"Toolhead Swapping Anomaly", +"Toolhead 3 return failed. Foreign objects may be present in the dock or working area. Remove any obstructions, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300020043": [ +"Toolhead Swapping Anomaly", +"Toolhead 3 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300020044": [ +"Toolhead Swapping Anomaly", +"Toolhead 3 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300020045": [ +"Toolhead Swapping Anomaly", +"Toolhead 3 Pogopin contact anomaly. Clean both the toolhead and the Pogopin contacts on the toolhead swapper using a dust-free cloth. Then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300020046": [ +"Toolhead Swapping Anomaly", +"Toolhead 3 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300020047": [ +"Toolhead Swapping Anomaly", +"Toolhead 3 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300029000": [ +"Flow Calibration Anomaly", +"Toolhead 3 flow calibration anomaly. Contact technical support." +], +"0002052300030000": [ +"Filament Anomaly", +"Filament runout detected in Toolhead 4. Try reloading filament, then tap the play button to resume the print job." +], +"0002052300030007": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 1 and Toolhead 2 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030008": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 1 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030009": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 1 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030010": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030011": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030012": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030013": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 1, Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030014": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 1, Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030015": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 1, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030016": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check if Toolhead 2, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030017": [ +"Toolhead Swapping Failure", +"Toolhead 4 swapping failed. Check all toolheads one by one for dropping or tilting, adjust manually and retry. If the anomaly persists, contact technical support." +], +"0002052300030018": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300030019": [ +"Toolhead Parking Failure", +"Toolhead 4 parking failed. Retry. If the issue persists, contact technical support." +], +"0002052300030020": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300030021": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Retry. If issue persists, contact technical support." +], +"0002052300030022": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Retry. If issue persists, contact technical support." +], +"0002052300030023": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300030024": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 1 and Toolhead 2 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030025": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 1 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030026": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 1 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030027": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030028": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030029": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030030": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 1, Toolhead 2 and Toolhead 3 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030031": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 1, Toolhead 2 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030032": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 1, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030033": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check if Toolhead 2, Toolhead 3 and Toolhead 4 are dropped or tilted, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030034": [ +"Toolhead Picking Failure", +"Toolhead 4 picking failed. Check all toolheads one by one for dropping or tilting, adjust manually and retry. If anomaly persists, contact technical support." +], +"0002052300030035": [ +"Toolhead Swapping Anomaly", +"Check if the current toolhead is properly picked and placed, and ensure all idle toolheads on the dock are securely installed. Reinstall any misaligned toolheads, then tap to resume the task." +], +"0002052300030038": [ +"Printing Anomaly", +"Check if the filament for Toolhead 4 is broken inside the tube, tangled on the spool, or if the toolhead is clogged. After troubleshooting, tap to resume printing. If issue persists, contact technical support." +], +"0002052300030039": [ +"Filament Anomaly", +"Filament information not set for Toolhead 4. Go to the filament page to edit, then tap to resume printing." +], +"0002052300030040": [ +"Toolhead Swapping Anomaly", +"Toolhead 4 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300030041": [ +"Toolhead Swapping Anomaly", +"Toolhead 4 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300030042": [ +"Toolhead Swapping Anomaly", +"Toolhead 4 return failed. Foreign objects may be present in the dock or working area. Remove any obstructions, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300030043": [ +"Toolhead Swapping Anomaly", +"Toolhead 4 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300030044": [ +"Toolhead Swapping Anomaly", +"Toolhead 4 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300030045": [ +"Toolhead Swapping Anomaly", +"Toolhead 4 Pogopin contact anomaly. Clean both the toolhead and the Pogopin contacts on the toolhead swapper using a dust-free cloth. Then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300030046": [ +"Toolhead Swapping Anomaly", +"Toolhead 4 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300030047": [ +"Toolhead Swapping Anomaly", +"Toolhead 4 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052300039000": [ +"Flow Calibration Anomaly", +"Toolhead 4 flow calibration anomaly. Contact technical support." +], +"0002052500000000": [ +"Filament Anomaly", +"Toolhead 1 filament feeder anomaly. Contact technical support." +], +"0002052500000001": [ +"Filament Anomaly", +"Toolhead 1 filament feeder anomaly. Contact technical support." +], +"0002052500000002": [ +"Filament Anomaly", +"Filament Feeder 1 Loading Anomaly. Check for filament tangles or loose connector connections. If printing is in progress, tap the play button to resume the print job after troubleshooting." +], +"0002052500000003": [ +"Filament Not Loaded", +"Toolhead 1 filament feeder detected no filament entry. Insert filament." +], +"0002052500000004": [ +"Extrusion Blockage", +"Filament extrusion blocked at the drive gear of Toolhead 1. Check and clean, then retry. If printer is printing, tap the play button to the play button the print job." +], +"0002052500000005": [ +"Filament Anomaly", +"Toolhead 1 filament feeder loading timed out. Check if the loading path is blocked, ensure it is clear, then retry. If printer is printing, tap the play button to the play button the print job." +], +"0002052500000006": [ +"Filament Anomaly", +"Toolhead 1 filament feeder loading timed out. Check if the filament tube is detached or the loading path is blocked, ensure it is clear, then retry. If printer is printing, tap the play button to resume the print job." +], +"0002052500000030": [ +"Filament Loading Anomaly", +"Feeder 1 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500000031": [ +"Filament Loading Anomaly", +"Feeder 1 motor speed anomaly. Check and ensure the feeder cable is properly connected. If the error persists, contact Technical Support." +], +"0002052500000032": [ +"Filament Loading Anomaly", +"Feeder 1 encoder wheel speed anomaly. The filament may be tangled or the feeding path obstructed. Tap to resume, or reload the filament after resolving the issue." +], +"0002052500000033": [ +"Filament Loading Anomaly", +"No filament detected in Feeder 1. Insert filament." +], +"0002052500000034": [ +"Filament Loading Anomaly", +"Feeder 1 feeding timeout. The filament may be tangled or the feeding path obstructed. Tap to resume, or reload the filament after resolving the issue." +], +"0002052500000035": [ +"Filament Loading Anomaly", +"Feeder 1 feeding stroke overrun. Check if the filament tube has detached from this feeder. After reconnecting, tap to resume, or reload the filament." +], +"0002052500000050": [ +"Extrusion Anomaly", +"Feeder 1 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500000051": [ +"Extrusion Anomaly", +"Feeder 1 Feeding Failure. The filament may be tangled, jammed in the extrusion gears, or the nozzle may be clogged. Troubleshoot and retry." +], +"0002052500000070": [ +"Filament Unloading Anomaly", +"Feeder 1 unloading anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500010000": [ +"Filament Anomaly", +"Toolhead 2 filament feeder anomaly. Contact technical support." +], +"0002052500010001": [ +"Filament Anomaly", +"Toolhead 2 filament feeder anomaly. Contact technical support." +], +"0002052500010002": [ +"Filament Anomaly", +"Filament Feeder 2 Loading Anomaly. Check for filament tangles or loose connector connections. If printing is in progress, tap the play button to resume the print job after troubleshooting." +], +"0002052500010003": [ +"Filament Not Loaded", +"Toolhead 2 filament feeder detected no filament entry. Insert filament." +], +"0002052500010004": [ +"Extrusion Blockage", +"Filament extrusion blocked at the drive gear of Toolhead 2. Check and clean, then retry. If printer is printing, tap the play button to the play button the print job." +], +"0002052500010005": [ +"Filament Anomaly", +"Toolhead 2 filament feeder loading timed out. Check if the loading path is blocked, ensure it is clear, then retry. If printer is printing, tap the play button to resume the print job." +], +"0002052500010006": [ +"Filament Anomaly", +"Toolhead 2 filament feeder loading timed out. Check if the filament tube is detached or the loading path is blocked, ensure it is clear, then retry. If printer is printing, tap the play button to resume the print job." +], +"0002052500010030": [ +"Filament Loading Anomaly", +"Feeder 2 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500010031": [ +"Filament Loading Anomaly", +"Feeder 2 motor speed anomaly. Check and ensure the feeder cable is properly connected. If the error persists, contact Technical Support." +], +"0002052500010032": [ +"Filament Loading Anomaly", +"Feeder 2 encoder wheel speed anomaly. The filament may be tangled or the feeding path obstructed. Tap to resume, or reload the filament after resolving the issue." +], +"0002052500010033": [ +"Filament Loading Anomaly", +"No filament detected in Feeder 2. Insert filament." +], +"0002052500010034": [ +"Filament Loading Anomaly", +"Feeder 2 feeding timeout. The filament may be tangled or the feeding path obstructed. Tap to resume, or reload the filament after resolving the issue." +], +"0002052500010035": [ +"Filament Loading Anomaly", +"Feeder 2 feeding stroke overrun. Check if the filament tube has detached from this feeder. After reconnecting, tap to resume, or reload the filament." +], +"0002052500010050": [ +"Extrusion Anomaly", +"Feeder 2 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500010051": [ +"Extrusion Anomaly", +"Feeder 2 Feeding Failure. The filament may be tangled, jammed in the extrusion gears, or the nozzle may be clogged. Troubleshoot and retry." +], +"0002052500010070": [ +"Filament Unloading Anomaly", +"Feeder 2 unloading anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500020000": [ +"Filament Anomaly", +"Toolhead 3 filament feeder anomaly. Contact technical support." +], +"0002052500020001": [ +"Filament Anomaly", +"Toolhead 3 filament feeder anomaly. Contact technical support." +], +"0002052500020002": [ +"Filament Anomaly", +"Filament Feeder 3 Loading Anomaly. Check for filament tangles or loose connector connections. If printing is in progress, tap the play button to resume the print job after troubleshooting." +], +"0002052500020003": [ +"Filament Not Loaded", +"Toolhead 3 filament feeder detected no filament entry. Insert filament." +], +"0002052500020004": [ +"Extrusion Blockage", +"Filament extrusion blocked at the drive gear of Toolhead 3. Check and clean, then retry. If printer is printing, tap the play button to the play button the print job." +], +"0002052500020005": [ +"Filament Anomaly", +"Toolhead 3 filament feeder loading timed out. Check if the loading path is blocked, ensure it is clear, then retry. If printer is printing, tap the play button to resume the print job." +], +"0002052500020006": [ +"Filament Anomaly", +"Toolhead 3 filament feeder loading timed out. Check if the filament tube is detached or the loading path is blocked, ensure it is clear, then retry. If printer is printing, tap the play button to resume the print job." +], +"0002052500020030": [ +"Filament Loading Anomaly", +"Feeder 3 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500020031": [ +"Filament Loading Anomaly", +"Feeder 3 motor speed anomaly. Check and ensure the feeder cable is properly connected. If the error persists, contact Technical Support." +], +"0002052500020032": [ +"Filament Loading Anomaly", +"Feeder 3 encoder wheel speed anomaly. The filament may be tangled or the feeding path obstructed. Tap to resume, or reload the filament after resolving the issue." +], +"0002052500020033": [ +"Filament Loading Anomaly", +"No filament detected in Feeder 3. Insert filament." +], +"0002052500020034": [ +"Filament Loading Anomaly", +"Feeder 3 feeding timeout. The filament may be tangled or the feeding path obstructed. Tap to resume, or reload the filament after resolving the issue." +], +"0002052500020035": [ +"Filament Loading Anomaly", +"Feeder 3 feeding stroke overrun. Check if the filament tube has detached from this feeder. After reconnecting, tap to resume, or reload the filament." +], +"0002052500020050": [ +"Extrusion Anomaly", +"Feeder 3 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500020051": [ +"Extrusion Anomaly", +"Feeder 3 Feeding Failure. The filament may be tangled, jammed in the extrusion gears, or the nozzle may be clogged. Troubleshoot and retry." +], +"0002052500020070": [ +"Filament Unloading Anomaly", +"Feeder 3 unloading anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500030000": [ +"Filament Anomaly", +"Toolhead 4 filament feeder anomaly. Contact technical support." +], +"0002052500030001": [ +"Filament Anomaly", +"Toolhead 4 filament feeder anomaly. Contact technical support." +], +"0002052500030002": [ +"Filament Anomaly", +"Filament Feeder 4 Loading Anomaly. Check for filament tangles or loose connector connections. If printing is in progress, tap the play button to resume the print job after troubleshooting." +], +"0002052500030003": [ +"Filament Not Loaded", +"Toolhead 4 filament feeder detected no filament entry. Insert filament." +], +"0002052500030004": [ +"Extrusion Blockage", +"Filament extrusion blocked at the drive gear of Toolhead 4. Check and clean, then retry. If printer is printing, tap the play button to the play button the print job." +], +"0002052500030005": [ +"Filament Anomaly", +"Toolhead 4 filament feeder loading timed out. Check if the loading path is blocked, ensure it is clear, then retry. If printer is printing, tap the play button to resume the print job." +], +"0002052500030006": [ +"Filament Anomaly", +"Toolhead 4 filament feeder loading timed out. Check if the filament tube is detached or the loading path is blocked, ensure it is clear, then retry. If printer is printing, tap the play button to resume the print job." +], +"0002052500030030": [ +"Filament Loading Anomaly", +"Feeder 4 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500030031": [ +"Filament Loading Anomaly", +"Feeder 4 motor speed anomaly. Check and ensure the feeder cable is properly connected. If the error persists, contact Technical Support." +], +"0002052500030032": [ +"Filament Loading Anomaly", +"Feeder 4 encoder wheel speed anomaly. The filament may be tangled or the feeding path obstructed. Tap to resume, or reload the filament after resolving the issue." +], +"0002052500030033": [ +"Filament Loading Anomaly", +"No filament detected in Feeder 4. Insert filament." +], +"0002052500030034": [ +"Filament Loading Anomaly", +"Feeder 4 feeding timeout. The filament may be tangled or the feeding path obstructed. Tap to resume, or reload the filament after resolving the issue." +], +"0002052500030035": [ +"Filament Loading Anomaly", +"Feeder 4 feeding stroke overrun. Check if the filament tube has detached from this feeder. After reconnecting, tap to resume, or reload the filament." +], +"0002052500030050": [ +"Extrusion Anomaly", +"Feeder 4 anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052500030051": [ +"Extrusion Anomaly", +"Feeder 4 Feeding Failure. The filament may be tangled, jammed in the extrusion gears, or the nozzle may be clogged. Troubleshoot and retry." +], +"0002052500030070": [ +"Filament Unloading Anomaly", +"Feeder 4 unloading anomaly. Tap the error code for details. It is recommended to contact Technical Support for assistance." +], +"0002052800000000": [ +"Homing Anomaly", +"Home X and Y axes first, then home Z axis." +], +"0002052800000001": [ +"Homing Anomaly", +"Current toolhead is unavailable, cannot home. Restart printer and retry. If issue persists, contact technical support." +], +"0002052800000010": [ +"Homing Anomaly", +"Home X and Y axes first before performing this operation." +], +"0002052800000011": [ +"Homing Anomaly", +"Check if timing belts are tensioned properly, X and Y axes move smoothly, and there are no obstructions at the X and Y homing positions. Retry after troubleshooting. If issue persists, contact technical support." +], +"0002052800000012": [ +"Homing Anomaly", +"Check if timing belts are tensioned properly, X and Y axes move smoothly, and there are no obstructions at the X and Y homing positions. Retry after troubleshooting. If issue persists, contact technical support." +], +"0002052800000013": [ +"Homing Anomaly", +"Toolhead 1 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052800000014": [ +"Homing Anomaly", +"Toolhead 1 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052800010013": [ +"Homing Anomaly", +"Toolhead 2 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052800010014": [ +"Homing Anomaly", +"Toolhead 2 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052800020013": [ +"Homing Anomaly", +"Toolhead 3 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052800020014": [ +"Homing Anomaly", +"Toolhead 3 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052800030013": [ +"Homing Anomaly", +"Toolhead 4 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002052800030014": [ +"Homing Anomaly", +"Toolhead 4 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0002053100000009": [ +"Storage Space Low", +"Low device storage may affect printing functionality. Delete some G-code or timelapse files to free up space." +], +"0002053200000000": [ +"System Anomaly", +"See anomaly details. Contact technical support." +], +"0002053200000001": [ +"Possible Foreign Object Detected on Print Bed", +"Please check and remove any foreign objects. If none are found, click Continue to resume printing." +], +"0002053200000002": [ +"Possible Spaghetti Defect Detected", +"Please inspect the model. If any defects are acceptable\u2014or none are found\u2014click Continue to resume printing." +], +"0002053200000003": [ +"Possible Residue Detected on Print Bed", +"Please check and remove any residue. If none are found, click Continue to resume printing." +], +"0002053300000000": [ +"Top Cover Anomaly", +"Internal circulation fan speed is abnormal. Please check for debris or a loose connector." +], +"0002053300000001": [ +"Chamber Temperature Anomaly", +"The chamber is overheating. Please open the magnetic front door and the glass door to cool it down." +], +"0003052200000000": [ +"System Anomaly", +"See anomaly details. Contact technical support." +], +"0003052200000001": [ +"System Anomaly", +"Contact technical support." +], +"0003052200000002": [ +"System Anomaly", +"Printer stopped. See anomaly details. Contact technical support." +], +"0003052200000003": [ +"System Failed to Start", +"Configuration load anomaly. Contact technical support or restore factory settings." +], +"0003052200000004": [ +"System Failed to Start", +"Configuration load anomaly. Contact technical support or restore factory settings." +], +"0003052200000005": [ +"System Failed to Start", +"Communication protocol load anomaly. Contact technical support or update firmware." +], +"0003052200000006": [ +"System Failed to Start", +"Host MCU connection failed. Contact technical support." +], +"0003052200000007": [ +"System Failed to Start", +"Abnormal MCU connection. Contact technical support." +], +"0003052200000008": [ +"System Anomaly", +"Host MCU connection interrupted. Contact technical support." +], +"0003052200000009": [ +"Sensor Anomaly", +"Accelerometer storage anomaly. Try powering off and restarting to resume. If the problem recurs, contact technical support." +], +"0003052200000010": [ +"Sensor Anomaly", +"Accelerometer failed to obtain valid device ID. Try powering off and restarting to resume. If the problem recurs, contact technical support." +], +"0003052200000011": [ +"Motor Driver Anomaly", +"Motor driver anomaly. See anomaly details. Contact technical support." +], +"0003052200000012": [ +"Axis Movement Anomaly", +"Tap Home first, then move the X axis." +], +"0003052200000013": [ +"Axis Movement Anomaly", +"X-axis movement exceeded travel limit." +], +"0003052200000014": [ +"Sensor Anomaly", +"ADC sensor configuration exceeded limit. System has shut down for protection. Export printer logs and contact technical support." +], +"0003052200000015": [ +"Temperature Anomaly", +"Heating module failed to heat up normally. System has shut down for protection. Export printer logs and contact technical support." +], +"0003052200000016": [ +"System Anomaly", +"Host MCU control timed out. System has shut down for protection. Contact technical support." +], +"0003052200000017": [ +"System Anomaly", +"Mainboard MCU detected power-off signal. System has shut down for protection. Contact technical support." +], +"0003052200010006": [ +"System Failed to Start", +"Mainboard MCU connection failed. Contact technical support." +], +"0003052200010008": [ +"System Anomaly", +"Mainboard MCU connection interrupted. Contact technical support." +], +"0003052200010012": [ +"Axis Movement Anomaly", +"Tap Home first, then move the Y axis." +], +"0003052200010013": [ +"Axis Movement Anomaly", +"Y-axis movement exceeded travel limit." +], +"0003052200010016": [ +"System Anomaly", +"Mainboard MCU control timed out. System has shut down for protection. Contact technical support." +], +"0003052200010017": [ +"System Anomaly", +"Toolhead 1 MCU detected power-off signal. System has shut down for protection. Contact technical support." +], +"0003052200020006": [ +"System Failed to Start", +"Toolhead 1 MCU connection failed. Power off printer, check toolhead cable, then restart printer. If issue persists, contact technical support." +], +"0003052200020008": [ +"System Anomaly", +"Toolhead 1 MCU connection interrupted. Power off printer, check Toolhead 1 cable, then restart to resume operation." +], +"0003052200020012": [ +"Axis Movement Anomaly", +"Tap Home first, then move the Z axis." +], +"0003052200020013": [ +"Axis Movement Anomaly", +"Z-axis movement exceeded travel limit." +], +"0003052200020016": [ +"System Anomaly", +"Toolhead 1 MCU control timed out. System has shut down for protection. Power off printer, check Toolhead 1 cable, then restart to resume operation." +], +"0003052200020017": [ +"System Anomaly", +"Toolhead 2 MCU detected power-off signal. System has shut down for protection. Contact technical support." +], +"0003052200030006": [ +"System Failed to Start", +"Toolhead 2 MCU connection failed. Power off printer, check toolhead cable, then restart printer. If issue persists, contact technical support." +], +"0003052200030008": [ +"System Anomaly", +"Toolhead 2 MCU connection interrupted. Power off printer, check Toolhead 2 cable, then restart to resume operation." +], +"0003052200030016": [ +"System Anomaly", +"Toolhead 2 MCU control timed out. System has shut down for protection. Power off printer, check Toolhead 2 cable, then restart to resume operation." +], +"0003052200030017": [ +"System Anomaly", +"Toolhead 3 MCU detected power-off signal. System has shut down for protection. Contact technical support." +], +"0003052200040006": [ +"System Failed to Start", +"Toolhead 3 MCU connection failed. Power off printer, check toolhead cable, then restart printer. If issue persists, contact technical support." +], +"0003052200040008": [ +"System Anomaly", +"Toolhead 3 MCU connection interrupted. Power off printer, check Toolhead 3 cable, then restart to resume operation." +], +"0003052200040016": [ +"System Anomaly", +"Toolhead 3 MCU control timed out. System has shut down for protection. Power off printer, check Toolhead 3 cable, then restart to resume operation." +], +"0003052200040017": [ +"System Anomaly", +"Toolhead 4 MCU detected power-off signal. System has shut down for protection. Contact technical support." +], +"0003052200050006": [ +"System Failed to Start", +"Toolhead 4 MCU connection failed. Power off printer, check toolhead cable, then restart printer. If issue persists, contact technical support." +], +"0003052200050008": [ +"System Anomaly", +"Toolhead 4 MCU connection interrupted. Power off printer, check Toolhead 4 cable, then restart to resume operation." +], +"0003052200050016": [ +"System Anomaly", +"Toolhead 4 MCU control timed out. System has shut down for protection. Power off printer, check Toolhead 4 cable, then restart to resume operation." +], +"0003052201000006": [ +"System Failed to Start", +"No toolhead detected. Please contact Technical Support." +], +"0003052300000001": [ +"Nozzle Temperature Anomaly", +"Toolhead 1 nozzle temperature insufficient. Nozzle extrusion temperature must be above 170\u00b0C." +], +"0003052300000002": [ +"Nozzle Temperature Anomaly", +"Power off printer, then check the connection of Toolhead 1 thermistor." +], +"0003052300000003": [ +"Nozzle Temperature Anomaly", +"Power off printer, then check the connection of Toolhead 1 ceramic heater or thermistor." +], +"0003052300000004": [ +"Extrusion Length Exceeded", +"Toolhead 1 single extrusion distance was too long. There may be incorrect codes in the G-code file. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300000005": [ +"Extrusion Flow Exceeded", +"Toolhead 1 printing extrusion volume exceeded maximum allowed. There may be incorrect configurations in the G-code file. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300000006": [ +"Nozzle Temperature Anomaly", +"The G-code file may have set a toolhead unrecognized by printer. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300000036": [ +"Nozzle Temperature Out of Range", +"Nozzle temperature set for Toolhead 1 is outside the allowable range. Reset to valid parameters." +], +"0003052300009001": [ +"Flow Calibration Unavailable", +"Filament used in Toolhead 1 is prone to clogging Nozzle and does not meet the calibration requirements." +], +"0003052300010001": [ +"Nozzle Temperature Anomaly", +"Toolhead 2 nozzle temperature insufficient. Nozzle extrusion temperature must be above 170\u00b0C." +], +"0003052300010002": [ +"Nozzle Temperature Anomaly", +"Power off printer, then check the connection of Toolhead 2 thermistor." +], +"0003052300010003": [ +"Nozzle Temperature Anomaly", +"Power off printer, then check the connection of Toolhead 2 ceramic heater or thermistor." +], +"0003052300010004": [ +"Extrusion Length Exceeded", +"Toolhead 2 single extrusion distance was too long. There may be incorrect codes in the G-code file. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300010005": [ +"Extrusion Flow Exceeded", +"Toolhead 2 printing extrusion volume exceeded maximum allowed. There may be incorrect configurations in the G-code file. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300010036": [ +"Nozzle Temperature Out of Range", +"Nozzle temperature set for Toolhead 2 is outside the allowable range. Reset to valid parameters." +], +"0003052300019001": [ +"Flow Calibration Unavailable", +"Filament used in Toolhead 2 is prone to clogging Nozzle and does not meet the calibration requirements." +], +"0003052300020001": [ +"Nozzle Temperature Anomaly", +"Toolhead 3 nozzle temperature insufficient. Nozzle extrusion temperature must be above 170\u00b0C." +], +"0003052300020002": [ +"Nozzle Temperature Anomaly", +"Power off printer, then check the connection of Toolhead 3 thermistor." +], +"0003052300020003": [ +"Nozzle Temperature Anomaly", +"Power off printer, then check the connection of Toolhead 3 ceramic heater or thermistor." +], +"0003052300020004": [ +"Extrusion Length Exceeded", +"Toolhead 3 single extrusion distance was too long. There may be incorrect codes in the G-code file. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300020005": [ +"Extrusion Flow Exceeded", +"Toolhead 3 printing extrusion volume exceeded maximum allowed. There may be incorrect configurations in the G-code file. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300020036": [ +"Nozzle Temperature Out of Range", +"Nozzle temperature set for Toolhead 3 is outside the allowable range. Reset to valid parameters." +], +"0003052300029001": [ +"Flow Calibration Unavailable", +"Filament used in Toolhead 3 is prone to clogging Nozzle and does not meet the calibration requirements." +], +"0003052300030001": [ +"Nozzle Temperature Anomaly", +"Toolhead 4 nozzle temperature insufficient. Nozzle extrusion temperature must be above 170\u00b0C." +], +"0003052300030002": [ +"Nozzle Temperature Anomaly", +"Power off printer, then check the connection of Toolhead 4 thermistor." +], +"0003052300030003": [ +"Nozzle Temperature Anomaly", +"Power off printer, then check the connection of Toolhead 4 ceramic heater or thermistor." +], +"0003052300030004": [ +"Extrusion Length Exceeded", +"Toolhead 4 single extrusion distance was too long. There may be incorrect codes in the G-code file. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300030005": [ +"Extrusion Flow Exceeded", +"Toolhead 4 printing extrusion volume exceeded maximum allowed. There may be incorrect configurations in the G-code file. It is recommended to use G-code files exported by Snapmaker Orca for printing to avoid this issue." +], +"0003052300030036": [ +"Nozzle Temperature Out of Range", +"Nozzle temperature set for Toolhead 4 is outside the allowable range. Reset to valid parameters." +], +"0003052300039001": [ +"Flow Calibration Unavailable", +"Filament used in Toolhead 4 is prone to clogging Nozzle and does not meet the calibration requirements." +], +"0003052600000000": [ +"Heated Bed Temperature Abnormal", +"Possible temperature sensor failure. Contact Technical Support." +], +"0003052600000001": [ +"Heated Bed Temperature Abnormal", +"Heated bed heating anomaly. Contact technical support." +], +"0003052700000000": [ +"Chamber Temperature Abnormal", +"Check and ensure the temperature sensor cable is properly connected, or contact Technical Support." +], +"0003052800000002": [ +"Axis Movement Anomaly", +"X-axis movement anomaly. Retry. If anomaly occurs repeatedly, contact technical support." +], +"0003052800000003": [ +"Axis Movement Anomaly", +"X-axis movement anomaly. Retry. If anomaly occurs repeatedly, contact technical support." +], +"0003052800000004": [ +"Axis Movement Anomaly", +"Check if X-axis movement has obvious resistance, then retry. If issue persists, contact technical support." +], +"0003052800000005": [ +"Axis Movement Anomaly", +"Check if moving axes have obvious resistance, then retry. If issue persists, contact technical support." +], +"0003052800000006": [ +"Axis Movement Anomaly", +"Check if timing belts are tensioned properly, X and Y axes move smoothly, and there are no obstructions at the X and Y homing positions. Retry after troubleshooting. If issue persists, contact technical support." +], +"0003052800000007": [ +"System Anomaly", +"System is in shutdown protection, cannot home. Restart printer and retry. If the problem persists, contact technical support." +], +"0003052800000008": [ +"System Anomaly", +"System is in shutdown protection, cannot level. Restart printer and retry. If the problem persists, contact technical support." +], +"0003052800000009": [ +"Sensor Anomaly", +"Failed to activate. Retry. If anomaly occurs repeatedly, contact technical support." +], +"0003052800010002": [ +"Axis Movement Anomaly", +"Y-axis movement anomaly. Retry. If anomaly occurs repeatedly, contact technical support." +], +"0003052800010003": [ +"Axis Movement Anomaly", +"Y-axis movement anomaly. Retry. If anomaly occurs repeatedly, contact technical support." +], +"0003052800010004": [ +"Axis Movement Anomaly", +"Check if Y-axis movement has obvious resistance, then retry. If issue persists, contact technical support." +], +"0003052800020002": [ +"Axis Movement Anomaly", +"Z-axis movement anomaly. Retry. If anomaly occurs repeatedly, contact technical support." +], +"0003052800020003": [ +"Axis Movement Anomaly", +"Z-axis movement anomaly. Retry. If anomaly occurs repeatedly, contact technical support." +], +"0003052800020004": [ +"Axis Movement Anomaly", +"Check if Z-axis movement has obvious resistance, then retry. If issue persists, contact technical support." +], +"0003052800030002": [ +"Homing Anomaly", +"Retry. If issue persists, contact technical support." +], +"0003052800030003": [ +"Homing Anomaly", +"Retry. If issue persists, contact technical support." +], +"0003052800030004": [ +"Homing Anomaly", +"Homing sensor not triggered. Check the nozzle for residual filament and clean it before retrying. If issue persists, contact technical support." +], +"0003053000000000": [ +"Calibration Anomaly", +"Toolhead 1 calibration sensor anomaly. Check if the calibration sensor is disconnected. For assistance, contact technical support." +], +"0003053000000001": [ +"Calibration Anomaly", +"Toolhead 1 calibration sensor anomaly. Sensor data exceeded allowable range. Contact technical support." +], +"0003053000000002": [ +"Calibration Anomaly", +"Toolhead must be homed before calibration module works. Home and retry. If issue persists, contact technical support." +], +"0003053000000003": [ +"Calibration Anomaly", +"Incorrect calibration parameters. Contact technical support." +], +"0003053000000004": [ +"Calibration Anomaly", +"Calibration failed. Check if toolhead is properly positioned, manually return to home position, then retry. If issue persists, contact technical support." +], +"0003053000000005": [ +"Calibration Anomaly", +"Incorrect operation command. Check if toolheads match placement position numbers, ensure numbers match, then retry. If issue persists, contact technical support." +], +"0003053000000006": [ +"Calibration Anomaly", +"Calibration module model of current toolhead does not match. Contact technical support." +], +"0003053000000007": [ +"Calibration Anomaly", +"Probe command anomaly. Axes other than X, Y, Z are used. Use correct commands." +], +"0003053000000008": [ +"Calibration Anomaly", +"Check the X axis for any obstructions and if toolhead nozzle has residual material. Clean and retry. If issue persists, contact technical support." +], +"0003053000000009": [ +"Calibration Anomaly", +"Calibration unavailable due to system anomaly. Contact technical support." +], +"0003053000000010": [ +"Heated Bed Detection Anomaly", +"PEI sheet detected as removed. Place PEI sheet and retry printing." +], +"0003053000000011": [ +"Heated Bed Detection Anomaly", +"PEI sheet detected as not removed. Remove PEI sheet and retry calibration." +], +"0003053000000012": [ +"Heated Bed Detection Anomaly", +"Heated bed detection anomaly. Contact technical support." +], +"0003053000000013": [ +"Detection Anomaly", +"Attach any one toolhead and retry." +], +"0003053000000014": [ +"Detection Anomaly", +"Toolhead 1 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000000015": [ +"Detection Anomaly", +"Toolhead 1 Pogopin contact anomaly. Clean both the toolhead and the Pogopin contacts on the toolhead swapper using a dust-free cloth. Then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000000016": [ +"Detection Anomaly", +"Toolhead 1 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000000017": [ +"Heated Bed Detection Anomaly", +"The PEI sheet is not properly positioned. Correctly place the PEI sheet and retry." +], +"0003053000000018": [ +"Calibration Anomaly", +"Toolhead XYZ offset calibration value exceeds the maximum allowed range." +], +"0003053000000019": [ +"Calibration Failed", +"Toolhead Attach/Detach Coordinate Adjustment is not supported by the current printer profile." +], +"0003053000000020": [ +"Calibration Failed", +"One or more toolheads are not securely seated on the dock. Ensure all toolheads are properly installed and try again." +], +"0003053000000021": [ +"Toolhead Attach Verification Failed", +"" +], +"0003053000000022": [ +"Toolhead Detach Verification Failed", +"" +], +"0003053000000024": [ +"Homing Calibration Failed", +"Diagonal probing error." +], +"0003053000000025": [ +"Homing Calibration Failed", +"Failed to verify the probe point." +], +"0003053000000026": [ +"Homing Calibration Failed", +"Reached max retries." +], +"0003053000000027": [ +"Homing Calibration Failed", +"Please retension the timing belt and try again. If the issue persists, perform a homing calibration." +], +"0003053000000028": [ +"Homing Calibration Failed", +"Please retension the timing belt and try again. If the issue persists, perform a homing calibration." +], +"0003053000010000": [ +"Calibration Anomaly", +"Toolhead 2 calibration sensor anomaly. Check if the calibration sensor is disconnected. For assistance, contact technical support." +], +"0003053000010001": [ +"Calibration Anomaly", +"Toolhead 2 calibration sensor anomaly. Sensor data exceeded allowable range. Contact technical support." +], +"0003053000010008": [ +"Calibration Anomaly", +"Check the Y axis for any obstructions and if toolhead nozzle has residual material. Clean and retry. If issue persists, contact technical support." +], +"0003053000010014": [ +"Detection Anomaly", +"Toolhead 2 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000010015": [ +"Detection Anomaly", +"Toolhead 2 Pogopin contact anomaly. Clean both the toolhead and the Pogopin contacts on the toolhead swapper using a dust-free cloth. Then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000010016": [ +"Detection Anomaly", +"Toolhead 2 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000010021": [ +"Toolhead Attach Verification Failed", +"" +], +"0003053000010022": [ +"Toolhead Detach Verification Failed", +"" +], +"0003053000020000": [ +"Calibration Anomaly", +"Toolhead 3 calibration sensor anomaly. Check if the calibration sensor is disconnected. For assistance, contact technical support." +], +"0003053000020001": [ +"Calibration Anomaly", +"Toolhead 3 calibration sensor anomaly. Sensor data exceeded allowable range. Contact technical support." +], +"0003053000020008": [ +"Calibration Anomaly", +"Check if Z axis for any obstructions and if toolhead nozzle has residual material. Clean and retry. If issue persists, contact technical support." +], +"0003053000020014": [ +"Detection Anomaly", +"Toolhead 3 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000020015": [ +"Detection Anomaly", +"Toolhead 3 Pogopin contact anomaly. Clean both the toolhead and the Pogopin contacts on the toolhead swapper using a dust-free cloth. Then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000020016": [ +"Detection Anomaly", +"Toolhead 3 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000020021": [ +"Toolhead Attach Verification Failed", +"" +], +"0003053000020022": [ +"Toolhead Detach Verification Failed", +"" +], +"0003053000030000": [ +"Calibration Anomaly", +"Toolhead 4 calibration sensor anomaly. Check if the calibration sensor is disconnected. For assistance, contact technical support." +], +"0003053000030001": [ +"Calibration Anomaly", +"Toolhead 4 calibration sensor anomaly. Sensor data exceeded allowable range. Contact technical support." +], +"0003053000030014": [ +"Detection Anomaly", +"Toolhead 4 detached. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000030015": [ +"Detection Anomaly", +"Toolhead 4 Pogopin contact anomaly. Clean both the toolhead and the Pogopin contacts on the toolhead swapper using a dust-free cloth. Then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000030016": [ +"Detection Anomaly", +"Toolhead 4 placement anomaly. Manually reinstall the toolhead into its dock, then tap to attempt resuming the job. If the error persists, contact Technical Support." +], +"0003053000030017": [ +"Heated Bed Detection Anomaly", +"The PEI sheet is not properly positioned. Correctly place the PEI sheet and retry." +], +"0003053000030021": [ +"Toolhead Attach Verification Failed", +"" +], +"0003053000030022": [ +"Toolhead Detach Verification Failed", +"" +], +"0003053100000010": [ +"Unrecognized G-code Commands", +"The print file contains G-code commands that are not supported by this device. Printing has been terminated." +], +"0003053100000014": [ +"Print Start Failed", +"The nozzle diameter in the print file does not match the nozzle diameter of the target toolhead. Verify the slicer settings or replace the nozzle with a matching one, then try again." +], +"0003053100000018": [ +"Print Start Failed", +"Filament does not meet the calibration requirements for the nozzle diameter corresponding to nozzle." +] +}; + +/** Decode a 16-hex-digit code into its four groups. */ +export function decodeErrorCode(code) { + const c = String(code == null ? '' : code).trim(); + if (!/^[0-9A-Fa-f]{16}$/.test(c)) return null; + const subsystem = c.slice(4, 8); + const unit = parseInt(c.slice(8, 12), 16); + return { + code: c.toUpperCase(), + errorClass: c.slice(0, 4), + subsystem, + subsystemName: SUBSYSTEMS[subsystem] || 'unknown', + unitIndex: unit, + specific: c.slice(12, 16), + // toolhead and filament faults number their head from the unit group + toolhead: (subsystem === '0523' || subsystem === '0525') ? unit + 1 : null, + }; +} + +/** + * Look a fault up. Accepts the 16-digit form, and also a bare integer + * `action_code`, which the state model reports as a number. + */ +export function lookupFault(code) { + if (code == null || code === 0 || code === '') return null; + let key = String(code).trim(); + if (/^[0-9]+$/.test(key) && key.length < 16) { + key = Number(key).toString(16).toUpperCase().padStart(16, '0'); + } + key = key.toUpperCase(); + const hit = FAULTS[key] || FAULTS[key.toLowerCase()]; + const parts = decodeErrorCode(key); + if (!hit && !parts) return null; + return { + code: key, + title: (hit && hit[0]) || 'Printer fault', + description: (hit && hit[1]) || 'No description for this code in the shipped catalogue.', + known: !!hit, + ...(parts || {}), + }; +} diff --git a/resources/web/shared/js/mockhost.js b/resources/web/shared/js/mockhost.js new file mode 100644 index 00000000000..4fb6903f503 --- /dev/null +++ b/resources/web/shared/js/mockhost.js @@ -0,0 +1,1169 @@ +/* + * mockhost.js - a simulated Orca host + Snapmaker U1, shared by both surfaces. + * + * Installs window.wx.postMessage and answers with correctly-shaped WCP packets, + * including the ack-then-push subscription behaviour and the + * data:{data:{...},method:""} status envelope Orca produces. + * + * Split so the two reconstructions share one simulation: + * - the envelope machinery, the subscription bookkeeping, the U1 model and + * the commands BOTH surfaces use live here; + * - each surface passes its own `handlers` for the commands only it needs. + * + * Replies use OK_CODE (200), the same code real Orca sends. A mock that replied + * 0 would pass its own tests and then fail against the real host. + */ +'use strict'; + +import { SUBSCRIBE_OBJECTS, TOOLHEADS, PRINTER_BACKED } from './protocol.js'; +import { OK_CODE } from './sswcp.js'; + +const TICK_MS = 1000; + +/** A 1x1 PNG, so thumbnail plumbing can be exercised without real artwork. */ +const PNG_1PX = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +/** + * Install the simulated host. + * + * @param {object} opts + * @param {Function} opts.log trace sink (kind, packet) + * @param {object} opts.handlers extra commands: cmd -> (params, ctx) => data + * @param {object} opts.printer override the simulated printer + * @returns {{printer, stop}|null} null if a real host is already present + */ +export function installMockHost({ log = () => {}, handlers = {}, printer: given = null } = {}) { + if (window.wx && window.wx.__isMock !== true && typeof window.wx.postMessage === 'function') { + return null; // a real host is present; never shadow it + } + + const printer = given || makePrinter(); + const subs = new Map(); // event_id -> true + let filter = null; + + /** + * Commands the PRINTER answers come back wrapped in their JSON-RPC envelope, because + * Orca passes the reply through verbatim (SSWCP.cpp:1194 -> :946). Commands Orca + * answers itself do not. The simulator used to unwrap everything, which is precisely + * why a page that read fields off the envelope passed every browser test and showed + * nothing on hardware. + */ + let rpcId = 0; + + function reply(header, code, message, data, cmd) { + let out = data; + if (cmd && PRINTER_BACKED.has(cmd) && data && typeof data === 'object' + && !Array.isArray(data) && !('jsonrpc' in data)) { + out = { jsonrpc: '2.0', result: data, id: ++rpcId, + cli_time: Math.floor(Date.now() / 1000), dev_time: -1 }; + } + const packet = { header, payload: { code, message, data: out } }; + log('mock-tx', packet); + setTimeout(() => window.postMessage(JSON.stringify(packet), '*'), 0); + } + + const statusEnvelope = (objectMap, method = '') => ({ data: objectMap, method }); + + window.wx = { + __isMock: true, + postMessage(text) { + let msg; + try { msg = JSON.parse(text); } catch { return; } + const header = msg.header || {}; + const p = msg.payload || {}; + const { cmd, params = {}, event_id: eventId } = p; + log('mock-rx', msg); + + const ok = (data) => reply(header, OK_CODE, 'success', + data === undefined ? {} : data, cmd); + // an optional code, so a handler can reproduce the printer's own + // (camera.start_monitor answers -32000, not a generic failure) + const fail = (m, code = -1) => reply(header, code, m, {}, cmd); + const ctx = { printer, ok, fail, header, eventId, subs, push }; + + // A surface's own handlers win, so it can also override a shared one. + if (Object.prototype.hasOwnProperty.call(handlers, cmd)) { + const out = handlers[cmd](params, ctx); + if (out === undefined) return; // handler replied itself + return ok(out); + } + + switch (cmd) { + /* ---- identity ------------------------------------------------ */ + case 'sw_GetLocalDevices': + // Real Orca replies with a BARE ARRAY (m_res_data = devices), so the + // simulation must too, or a client that works here breaks there. + return ok(printer.devices); + case 'sw_SubscribeLocalDevices': + if (eventId) subs.set(eventId, true); + return ok(printer.devices); + + case 'sw_GetConnectedMachine': { + // Orca returns the DeviceInfo record of whichever saved device has its + // connected flag set, and an empty object when none has. Same here, so a + // client cannot come to depend on fields Orca does not send. + const conn = printer.devices.find((d) => d.connected); + return ok(conn ? conn : {}); + } + case 'sw_GetMachineSystemInfo': + return ok({ firmware_version: printer.firmware, model: 'Snapmaker U1', sn: printer.sn }); + case 'sw_GetPrinterInfo': + return ok({ state: 'ready', state_message: 'Printer is ready', + hostname: 'snapmaker-u1', software_version: printer.firmware }); + case 'sw_GetSoftwareInfo': + // Mirrors what Orca reports; the build badge prefers this over version.json. + return ok({ version: printer.orcaVersion, build_number: printer.orcaBuild, + app_name: 'orca' }); + + /* ---- state --------------------------------------------------- */ + case 'sw_SetSubscribeFilter': + filter = params.objects || null; + return ok({ accepted: Object.keys(filter || SUBSCRIBE_OBJECTS).length }); + case 'sw_GetMachineState': { + const objs = printer.snapshot(); + // `ace` is not in the subscription - the shipped bundle does not subscribe it + // and the page asks for it by name - so it comes back only when it is asked + // for, which is also what makes "no multiACE here" a state a test can reach. + if (params.objects && 'ace' in params.objects && printer.ace) objs.ace = printer.ace; + // `ace_bg_swap` is a second object in the same call, and it answers a different + // question: not what the ACE is, but which head may run a background swap. + if (params.objects && 'ace_bg_swap' in params.objects && printer.aceBg) + objs.ace_bg_swap = printer.aceBg; + // And `save_variables`, a third object for one field: the mode multiACE has + // SAVED, which is not the mode it is running until the machine is restarted. + if (params.objects && 'save_variables' in params.objects && printer.saveVars) + objs.save_variables = { variables: printer.saveVars }; + return ok(statusEnvelope(objs, 'query')); + } + case 'sw_SubscribeMachineState': { + if (!eventId) return fail('sw_SubscribeMachineState requires an event_id'); + subs.set(eventId, true); + ok({ subscribed: true }); // the ack + setTimeout(() => push(eventId, printer.snapshot()), 30); // then a full frame + return; + } + case 'sw_StopMachineStateSubscription': + case 'sw_UnSubscribeMachineState': + case 'sw_Webview_Unsubscribe': + if (params.event_id) subs.delete(params.event_id); + return ok({}); + case 'sw_UnsubscribeAll': + subs.clear(); + return ok({}); + + /* ---- print job ------------------------------------------------ */ + case 'sw_MachinePrintPause': printer.pause(); return ok({}); + case 'sw_MachinePrintResume': printer.resume(); return ok({}); + case 'sw_MachinePrintCancel': printer.cancel(); return ok({}); + + /* ---- hardware ------------------------------------------------- */ + case 'sw_ControlBedTemp': + printer.bed.target = Number(params.temp) || 0; return ok({}); + case 'sw_ControlExtruderTemp': { + const i = Number(params.index) || 0; + if (printer.toolheads[i]) printer.toolheads[i].target = Number(params.temp) || 0; + return ok({}); + } + case 'sw_ControlMainFan': + printer.fanMain = clamp01(Number(params.speed) / 100); return ok({}); + case 'sw_ControlGenericFan': + printer.fanCavity = clamp01(Number(params.speed) / 100); return ok({}); + case 'sw_ControlLed': + printer.ledWhite = clamp01(Number(params.white)); return ok({}); + case 'sw_ControlPrintSpeed': + printer.speedFactor = (Number(params.percentage) || 100) / 100; return ok({}); + case 'sw_ControlPurifier': + if (params.mode != null) printer.purifierMode = params.mode; return ok({}); + case 'sw_SendGCodes': { + printer.gcodeLog.push(params.script); + // The macros the Filament panel sends actually move the simulated ACE, so a + // control that waits to be told it happened has something to be told. + let raised = null; + if (printer.gcode) { + String(params.script).split('\n') + .forEach((l) => { raised = printer.gcode(l) || raised; }); + } + // A macro that RAISES is an outcome and not a transport failure. SET_ACE_MODE's + // normal transition succeeds AND raises, which is the whole reason this arm + // exists: the page has to see the refusal to recognise the success. + if (raised) return fail(raised); + return ok({ executed: params.script }); + } + + /* ---- bringing a session up ------------------------------------- */ + // Mirrors the real sequence: the printer shows a PIN, the page hands it + // back with sw_mqtt_set_engine, and Orca stores keys and flips the + // device's connected flag. See docs/u1-webui/02-device-page/06-connection.md + case 'sw_GetPincode': + printer.pendingPin = '4271'; + log('mock-note', { pin: printer.pendingPin }); + return ok({ state: 'success', code: printer.pendingPin }); + + case 'sw_create_mqtt_client': { + if (!params.server_address) return fail('param [server_address] is required'); + if (!params.clientId) return fail('param [clientId] is required'); + const tls = !!(params.ca && params.cert && params.key); + printer.engineId = 'engine-' + (printer.engineSeq = (printer.engineSeq || 0) + 1); + printer.engineAddress = params.server_address; + return ok({ type: tls ? 'mqtts' : 'mqtt', id: printer.engineId }); + } + case 'sw_mqtt_connect': + if (params.id !== printer.engineId) return fail('id is illegal'); + return ok({}); + case 'sw_mqtt_disconnect': + if (params.id && params.id !== printer.engineId) return fail('id is illegal'); + printer.engineId = null; + printer.devices.forEach((d) => { d.connected = false; }); + setTimeout(() => pushDevices(), 30); + return ok({}); + + case 'sw_mqtt_set_engine': { + if (params.engine_id !== printer.engineId) return fail('invalid engine'); + const dev = printer.devices.find((d) => d.sn === params.sn); + if (!dev) return fail('unknown sn'); + const prePaired = !!(params.ca && params.cert && params.key); + if (!prePaired) { + // The pairing path needs the code the printer displayed. + if (!params.code) return fail('pairing requires the code shown on the printer'); + // A bound machine accepts the fixed LAN code with no human involved; + // an unbound one only accepts the PIN it is displaying. + const okCode = printer.bound ? '12345678' : printer.pendingPin; + if (params.code !== okCode) return fail('incorrect pairing code'); + // What ask_for_tls_info() receives back and Orca then stores. + dev.ca = '-----BEGIN CERTIFICATE-----mock-ca-----END CERTIFICATE-----'; + dev.cert = '-----BEGIN CERTIFICATE-----mock-cert-----END CERTIFICATE-----'; + dev.key = '-----BEGIN PRIVATE KEY-----mock-key-----END PRIVATE KEY-----'; + dev.port = 8883; + dev.clientId = params.engine_id; + } + printer.devices.forEach((d) => { d.connected = d.sn === params.sn; }); + setTimeout(() => pushDevices(), 30); + return ok({}); + } + + /* ---- machine files --------------------------------------------- */ + case 'sw_MachineFilesRoots': + return ok(printer.roots); + case 'sw_GetFileListPage': + return ok({ files: printer.files, total: printer.files.length }); + case 'sw_MachineFilesGetDirectory': + return ok({ files: printer.files }); + case 'sw_MachineFilesMetadata': + return ok(printer.files.find((f) => f.path === params.filename) || {}); + case 'sw_DeleteMachineFile': + printer.files = printer.files.filter((f) => f.path !== params.path); + return ok({}); + case 'sw_MachinePrintStart': + printer.filename = params.filename || printer.filename; + printer.printState = 'printing'; + printer.mainState = 'printing'; + printer.filePosition = 0; + return ok({}); + + /* ---- camera ----------------------------------------------------- */ + // The camera LIST is not a bridge command on a real printer - it is plain HTTP + // to Moonraker, which a simulator has none of. It is answered here anyway, + // because otherwise the camera is the one panel a simulated run cannot exercise + // at all: with no printer, `discover()` gets a failed fetch and every run falls + // back to the monitor file, so the tile grid, the transport list and the focus + // rule would never be executed by any test. See mockWebcams() below, which is + // what the client actually calls. + + case 'sw_CameraStartMonitor': { + if (!eventId) return fail('sw_CameraStartMonitor requires an event_id'); + if (params.domain !== 'lan') { + // what the real firmware does with domain: '' - see 06-mqtt-topics.md + return fail('Start monitor failed', -32000); + } + subs.set(eventId, true); + // The monitor answers once with a URL and pushes nothing. A real printer + // names a file on its own HTTP server; a simulator has none, so it hands + // back a data: URI - which cameraFrameUrl() passes through unchanged + // precisely so this works without inventing a fake host. + const FRAME = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL' + + 'DBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/' + + '2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy' + + 'MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QA' + + 'HwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF' + + 'BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK' + + 'FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1' + + 'dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXG' + + 'x8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/9oACAEBAAA/APn+' + + 'iiigD//Z'; + return ok({ state: 'success', url: `data:image/jpeg;base64,${FRAME}` }); + } + case 'sw_CameraStopMonitor': + return ok({ stopped: true }); + case 'sw_GetCameraTimelapseInstance': + // `instances` is the printer's own spelling, and thumbnail_base64 only + // appears when the request asked for thumbnail_direct. + return ok({ count: printer.timelapses.length, instances: printer.timelapses }); + case 'sw_GetPrintHistory': { + // server.history.list's own shape: newest first, `jobs`, with the job's + // metadata nested. status is Klipper's, not a PRINT_STATE. + const jobs = printer.history || []; + const start = Number(params.start) || 0; + const limit = Number(params.limit) || 20; + const page = jobs.slice(start, start + limit); + // `count` is the size of THIS page, as the printer reports it - not the total + return ok({ count: page.length, jobs: page }); + } + case 'sw_DeleteCameraTimelapse': + printer.timelapses = printer.timelapses.filter( + (t) => t.name !== params.name && t.date_index !== params.date_index); + return ok({}); + + /* ---- saved devices ---------------------------------------------- */ + case 'sw_RenameDevice': { + const d = printer.devices.find((x) => x.dev_id === params.dev_id + || x.sn === params.dev_id); + if (d) d.dev_name = params.dev_name; + setTimeout(() => pushDevices(), 20); + return ok({}); + } + case 'sw_SetDeviceName': + return ok({}); + case 'sw_DeleteDevices': { + const ids = params.dev_ids || []; + printer.devices = printer.devices.filter( + (x) => !ids.includes(x.dev_id) && !ids.includes(x.sn)); + setTimeout(() => pushDevices(), 20); + return ok({}); + } + case 'sw_GetDeviceDataStorageSpace': + return ok({ free_space: 5.2, total_space: 8.0, units: 'GB' }); + case 'sw_PrinterDefectDetection': + Object.assign(printer.defect, params); + return ok(printer.defect); + + /* ---- diagnostics / system --------------------------------------- */ + case 'sw_exception_query': + return ok(printer.fault ? { code: printer.fault } : {}); + case 'sw_GetMachineObjects': + return ok({ objects: Object.keys(printer.snapshot()) }); + case 'sw_MachineHeartbeat': + printer.heartbeats = (printer.heartbeats || 0) + 1; + return ok({ alive: true }); + case 'sw_BedMesh_AbortProbeMesh': + printer.mainState = 'idle'; + return ok({}); + case 'sw_DefectDetactionConfig': + Object.assign(printer.defect, params); + return ok(printer.defect); + + /* ---- file transfer ---------------------------------------------- */ + case 'sw_MachineFilesThumbnails': + case 'sw_FilesThumbnailsBase64': + return ok({ thumbnail: PNG_1PX }); + case 'sw_DownloadMachineFile': + printer.transfer = 0; + return ok({ started: true }); + case 'sw_FileGetStatus': + printer.transfer = Math.min(1, (printer.transfer ?? 1) + 0.34); + return ok({ progress: printer.transfer }); + + /* ---- discovery --------------------------------------------------- */ + case 'sw_StartMachineFind': { + if (!eventId) return fail('sw_StartMachineFind requires an event_id'); + subs.set(eventId, true); + ok({ searching: true }); + // Discovery trickles results in, so push twice. + const send1 = (list) => { + if (!subs.has(eventId)) return; + const packet = { header: { event_id: eventId }, + payload: { code: OK_CODE, message: 'success', data: list } }; + log('mock-push', packet); + window.postMessage(JSON.stringify(packet), '*'); + }; + setTimeout(() => send1(printer.discoverable.slice(0, 1)), 500); + setTimeout(() => send1(printer.discoverable), 1400); + return; + } + case 'sw_StopMachineFind': + return ok({}); + case 'sw_AddDevice': + case 'sw_ConnectOtherMachine': + // Both open a native Orca dialog; there is nothing to simulate. + return ok({ opened: true }); + + /* ---- logging / telemetry the real page emits constantly -------- */ + case 'sw_FileLog': case 'sw_Log': case 'sw_UploadEvent': case 'sw_SetLogLevel': + return ok({}); + + default: + return fail(`mock: unhandled command ${cmd}`); + } + }, + }; + + /** The device-card push: a bare array, exactly as GUI_App::device_card_notify sends. */ + function pushDevices() { + for (const eventId of subs.keys()) { + const packet = { header: { event_id: eventId }, + payload: { code: OK_CODE, message: 'success', data: printer.devices } }; + log('mock-push', packet); + window.postMessage(JSON.stringify(packet), '*'); + } + } + + function push(eventId, objectMap, method = 'notify_status_update') { + if (!subs.has(eventId)) return; + const packet = { + header: { event_id: eventId }, + payload: { code: OK_CODE, message: 'success', + data: statusEnvelope(objectMap, method) }, + }; + log('mock-push', packet); + window.postMessage(JSON.stringify(packet), '*'); + } + + const timer = setInterval(() => { + const delta = printer.tick(); + for (const eventId of subs.keys()) push(eventId, delta); + }, TICK_MS); + + return { + printer, + webcams: mockWebcams, + // Not a bridge command: the page fetches this off the printer's own file server over + // HTTP, so with no printer there is nothing to answer it. Same seam as the camera's + // frames. + aceOverrides: () => printer.aceOverrides, + // Klipper's console, in Moonraker's own `server.gcode_store` shape. Same seam and the + // same reason: it is an HTTP GET against the printer, and a refused mode switch says + // so here and nowhere else. + gcodeStore: (n) => ({ + result: { gcode_store: printer.console.slice(-(Number(n) || 20)) }, + }), + stop() { clearInterval(timer); }, + }; +} + +function clamp01(v) { return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0)); } + +/* ------------------------------------------------------------------ * + * A small but honest U1 simulation: four toolheads, one active at a + * time, heaters that ramp toward target, a job that progresses. + * ------------------------------------------------------------------ */ +/* ------------------------------------------------------------------ * + * The ACE, as the printer reports it + * + * Copied field for field off 811002511261022618B3 on 2026-08-25: one ACE 2 Pro on + * `protocol: "v2"`, head mode, feeding Toolhead 4 from bay 3, four PETG spools, 38 % RH + * at 31 C. Including the two things about it that are easy to get wrong, because a + * simulator that smooths them over is a simulator that lets the bug through: + * + * `head_ace` reads {0:0, 1:1, 2:2, 3:0} with ONE unit attached, so heads 2 and 3 name + * units that are not there. A page that trusts it draws three cabinets that do not + * exist. The resolver has to reach for head_manual and head_feeder first, and this is + * the state that proves it does. + * + * Every raw slot reads {material:"", brand:"", rfid:0}. The hardware carries no + * per-bay identity at all - these spools have no tags and their names live in + * multiACE's own store, behind CORS - so the panel's job is to draw + * occupied-and-unnamed as exactly that. Only the bay that is LOADED has a name, and it + * comes from head_source. + * + * A drive script that wants a tagged spool, a second unit or a running dryer sets them + * on `mock.printer.ace` directly. Invented state belongs in the test that wants it, not + * in the record of what the machine said. + * ------------------------------------------------------------------ */ +function mockAce() { + const slot = (i) => ({ index: i, status: 'unknown', sku: '', material: '', subtype: '', + rfid: 0, brand: '', color: [0, 0, 0] }); + return { + api_version: 1, + mode: 'head', + status: 'ready', + temp: 30, + device_count: 1, + active_device: 0, + ace_head: 3, + ace_heads: [3], + swap_in_progress: false, + swap_phase: 'idle', + last_swap_result: null, + event_seq: 0, + gate_status: [1, 1, 1, 1], + // The settings a person sets once - all reported, which is what makes the panel's + // dialogs able to open on the machine's own values instead of on a default. + confirm_commands: false, + spoolman_url: 'http://192.168.2.30:7912', + spoolman_auto: false, + purge_matrix: true, + spool_mode: 'spoolman', + spool_binding: {}, + spools: {}, + dryer_status: { status: 'stop', target_temp: 0, duration: 0, remain_time: 0 }, + auto_dry_masters: [0], + head_manual: { 0: false, 1: false, 2: false, 3: false }, + head_feeder: { 0: true, 1: true, 2: true, 3: false }, + head_ace: { 0: 0, 1: 1, 2: 2, 3: 0 }, + head_reader_spool: { 0: 0, 1: 0, 2: 0, 3: 0 }, + head_tag_seen: {}, + head_source: { + 0: null, 1: null, 2: null, + 3: { ace_index: 0, slot: 2, type: 'PETG', subtype: 'Basic', + color: '632C2C', brand: 'Generic' }, + }, + aces: [{ + idx: 0, connected: true, protocol: 'v2', model: 'ACE 2 Pro', firmware: 'V1.1.26', + status: 'ready', temp: 30, humidity: 38, feed_assist: -1, fw_hold: false, + auto_dry: { enabled: false, rh_start: 45, rh_end: 35, temp: 50, + master: -1, add_time: 60 }, + auto_dry_running: false, + dryer_status: { status: 'stop', target_temp: 0, duration: 0, remain_time: 0 }, + gate_status: [1, 1, 1, 1], + slots: [slot(0), slot(1), slot(2), slot(3)], + }], + }; +} + +/** + * What multiACE says is in each bay — and the reason it is a separate thing. + * + * The `ace` object above reports four blank slots, because that is what the hardware + * reports: no tags, no identity. multiACE keeps the names its own web UI shows in this + * store and merges them in `_parse_state()`. Copied from the printer on 2026-08-26, + * where three of these four bays were being drawn as `?` while Orca's Prepare page — + * which polls the merged endpoint from C++ — showed them named. + * + * Keeping both halves in the simulator is the point: the blank slots AND the store that + * fills them. A simulator that only had the merged answer would never have shown the + * gap. + */ +function mockAceOverrides() { + const bay = (slot, brand, color) => ({ ace: 0, slot, material: 'PETG', brand, + subtype: 'Basic', color }); + return { + '0_0': bay(0, 'Kingroon', '#83AFFF'), + '0_1': bay(1, 'Kingroon', '#8FA7C8'), + '0_2': bay(2, 'Generic', '#632c2c'), + '0_3': bay(3, 'Kingroon', '#C47053'), + }; +} + +/** `ACE_DRY TEMP=55 DURATION=4` -> {TEMP: '55', DURATION: '4'}. */ +function gcodeArgs(script) { + const out = {}; + String(script).trim().split(/\s+/).slice(1).forEach((tok) => { + const m = /^([A-Za-z_]+)=(.*)$/.exec(tok); + if (m) out[m[1].toUpperCase()] = m[2]; + }); + return out; +} + +export function makePrinter() { + const p = { + sn: 'U1MOCK0000000001', + name: 'Snapmaker U1 (mock)', + // What app_config->get_devices() would hold. Field names are the C++ + // struct's, snake_case - see DeviceInfo in src/libslic3r/AppConfig.hpp. + // One connected and one saved-but-idle, so both paths get exercised. + devices: [ + // already paired: carries mTLS material, so it reconnects without a PIN + { ip: '192.168.1.42', dev_id: 'U1MOCK0000000001', dev_name: 'U1 Mock', + model_name: 'Snapmaker U1', preset_name: 'Snapmaker U1 0.4 nozzle', + connected: true, sn: 'U1MOCK0000000001', protocol: 1, port: 8883, + link_mode: 'lan', nozzle_sizes: ['0.4', '0.4', '0.4', '0.4'], + clientId: 'orca-mock-0001', user: '', password: '', + ca: '-----BEGIN CERTIFICATE-----mock-ca-----END CERTIFICATE-----', + cert: '-----BEGIN CERTIFICATE-----mock-cert-----END CERTIFICATE-----', + key: '-----BEGIN PRIVATE KEY-----mock-key-----END PRIVATE KEY-----' }, + { ip: '192.168.1.77', dev_id: 'U1MOCK0000000002', dev_name: 'U1 Spare', + model_name: 'Snapmaker U1', preset_name: 'Snapmaker U1 0.4 nozzle', + connected: false, sn: 'U1MOCK0000000002', protocol: 1, port: 8883, + link_mode: 'lan', nozzle_sizes: ['0.4', '0.4', '0.4', '0.4'] }, + ], + firmware: '1.0.0-mock', + // What sw_GetSoftwareInfo reports - the real bundle's shipped values, so the + // build badge shows the same number with or without a host. + orcaVersion: '2.3.26', + orcaBuild: '20260813142841', + bed: { temperature: 24, target: 0 }, + toolheads: TOOLHEADS.map((_, i) => ({ + temperature: 24, target: 0, power: 0, can_extrude: false, + pressure_advance: 0.035, smooth_time: 0.04, + state: i === 0 ? 'ready' : 'idle', nozzle_diameter: 0.4, + })), + active: 0, + // Which slots have filament in them. State rather than a literal in snapshot(), + // because a test that wants an empty head has to be able to say so: setting it on + // the mirror instead was overwritten by the next tick, which made the check pass or + // fail on how long the script before it took. + filamentExist: [true, true, true, true], + // No slot's record is the spool's own by default - the mock ships four untagged + // spools, so every one of them is a slot a person may edit. A drive script sets one + // true (with `filament_detect` to match) to reach the read-only branch. + filamentOfficial: [false, false, false, false], + filamentSubType: ['Basic', 'Basic', 'Basic', 'Basic'], + /* + * The identity per head, settable, so a drive script can reach the state multiACE + * puts a feeder head in when the machine enters head mode. + * + * `_clear_filament_display()` sends `FILAMENT_TYPE="" VENDOR="" + * FILAMENT_COLOR_RGBA=00000000` to every feeder head, and 00000000 is RRGGBBAA with + * alpha ZERO - the machine saying "no colour", not "black". These were three + * hard-coded literals, so the panel could not be shown that state at all. + */ + filamentVendor: ['Snapmaker', 'Snapmaker', 'Snapmaker', 'Generic'], + filamentType: ['PLA', 'PLA', 'PETG', 'ABS'], + filamentColorRgba: ['E03131FF', '1971C2FF', '2F9E44FF', 'F08C00FF'], + /** What multiACE does to a feeder head on entering head mode. */ + clearFilamentDisplay(head) { + this.filamentVendor[head] = ''; + this.filamentType[head] = ''; + this.filamentSubType[head] = ''; + this.filamentColorRgba[head] = '00000000'; + this.filamentOfficial[head] = false; + }, + /* + * What each head's feed channel is doing, as the machine reports it at rest. + * + * NOT `none`, which was the guess. Read off 811002511261022618B3 with everything + * settled: `load_finish` on the two heads that had finished loading and `wait_insert` + * on the two that had not. A terminal state is the resting state here - the field + * holds the last operation's ending rather than returning to a neutral word. + * + * A drive script sets one of the `load_*` / `unload_*` states to exercise the step + * bar, which is the only way to reach it without a three-minute physical swap. + */ + /* + * `state` and `act` are `channel_state` and `channel_action_state`, and `at` is the + * `filament_*` booleans - three answers that the machine gives independently and + * that the simulator must therefore be able to give independently too. + * + * It could not, twice. `at` was first computed from `filamentExist`, so the sensor + * and the job record could never disagree - and a head that had just been unloaded + * went on offering Unload. Splitting them fixed the simulator and not the printer, + * where `filament_at_extruder` stayed TRUE on both emptied heads and the field that + * moved was `channel_action_state`. So `at` is now what it is on the machine - the + * path having filament, true on empty heads included - and `act` is occupancy. + * Anything that reads occupancy from `at` will now fail here the way it failed there. + */ + channels: [ + { state: 'load_finish', error: 'ok', act: 'load_finish', at: true }, + { state: 'load_finish', error: 'ok', act: 'load_finish', at: true }, + { state: 'wait_insert', error: 'ok', act: 'none', at: false }, + // Toolhead 4, the ACE-fed one, exactly as 811002511261022618B3 reports it: an + // idle word, no action since boot, and the path reading full. Nothing in the feed + // channel says this head is loaded - the TOPOLOGY does, and that fallback is only + // exercised if the simulator is allowed to be this uninformative. + { state: 'wait_insert', error: 'ok', act: 'none', at: true }, + ], + fanMain: 0, fanCavity: 0, ledWhite: 1, speedFactor: 1, + purifierMode: 'inner', + printState: 'printing', + filename: 'mock_multicolor_benchy.gcode', + printDuration: 0, totalDuration: 0, filamentUsed: 0, + fileSize: 4_200_000, filePosition: 0, + mainState: 'printing', actionCode: null, + exceptions: [], + gcodeLog: [], + roots: [{ name: 'gcodes', path: '/data/gcodes' }, { name: 'timelapse', path: '/data/timelapse' }], + files: [ + { path: 'benchy_4colour.gcode', size: 19341122, modified: 1787300000 }, + { path: 'calibration_cube.gcode', size: 2841100, modified: 1787200000 }, + { path: 'bracket_v3.gcode', size: 7733001, modified: 1787100000 }, + ], + // shaped like camera.get_timelapse_instance's own reply + timelapses: [{ gcode_name: 'benchy_4colour', date_index: '20260813182603', + generate_date: '2026-08-13', video_duration: '00:13', + video_file_size: 23227748, thumbnail_base64: '' }], + // shaped like server.history.list's own reply + history: [ + { job_id: '0000F9', filename: 'XYZ Test Cube_PLA_3h58m.gcode', status: 'completed', + start_time: 1786615464.9, end_time: 1786629800.1, print_duration: 14180.2, + total_duration: 14335.2, filament_used: 12043.5, user: 'No User' }, + { job_id: '0000F8', filename: 'bracket_v3.gcode', status: 'cancelled', + start_time: 1786515464.9, end_time: 1786516477.3, print_duration: 900.4, + total_duration: 1012.4, filament_used: 812.0, user: 'No User' }, + { job_id: '0000F7', filename: 'benchy_4colour.gcode', status: 'klippy_shutdown', + start_time: 1786415464.9, end_time: 1786415477.3, print_duration: 0.0, + total_duration: 12.7, filament_used: 0.0, user: 'No User' }, + ], + defect: { enable: true, sensitivity: 1 }, + // The multiACE state, as the machine reports it - see mockAce() above. Null is a + // machine with no multiACE plugin, which is the other half the panel has to draw. + ace: mockAce(), + /* + * `ace_bg_swap`, exactly as the machine answered on 2026-08-26. A different Klipper + * object from `ace`, and the one that says which head may run a background swap. + * `enabled_heads` really is empty there: nobody has declared a head, so every + * background verb is refused - which is the state the panel has to draw well, and so + * the state the simulator opens in. + */ + aceBg: { version: 'v0.9', enabled_heads: [], busy: [], state: {} }, + /* + * `save_variables`, for one field: the mode multiACE has SAVED, which is a different + * question from the mode it is running. + * + * A normal-mode switch writes this immediately, swaps three Klipper extras on disk and + * then raises - so `ace.mode` goes on reporting the old mode until the machine is + * restarted, and the disagreement between the two is the only thing that says a restart + * is owed. Read off the machine on 2026-09-01, where the two agreed; they disagree here + * the moment a normal switch is sent. + */ + saveVars: { ace__mode: 'head', ace__revision: 62 }, + /* + * Klipper's console, which is where a refused mode switch says so and nowhere else. + * `//` is a note and `!!` an error; the two are different channels and the page reads + * both, for different things. + */ + console: [], + // And what is IN each bay, which the ace object does not carry. Null is a printer + // with no override store, where every occupied bay stays unnamed. + aceOverrides: mockAceOverrides(), + // A real 16-hex fault so the banner can be exercised: toolhead subsystem + // 0523, unit 1 -> "Toolhead 2". + fault: null, + transfer: 1, + heartbeats: 0, + // already bound to this client id, as the user's U1 is + bound: true, + discoverable: [ + { dev_name: 'U1 Bench', ip: '192.168.1.90', sn: 'U1DISCOVER000001', model_name: 'Snapmaker U1' }, + { dev_name: 'U1 Annex', ip: '192.168.1.91', sn: 'U1DISCOVER000002', model_name: 'Snapmaker U1' }, + ], + }; + + // Start mid-job and already up to temperature: an idle printer is a poor + // exercise of the UI, and a real Device page is almost always opened on a + // machine that is already running. + p.bed.target = 60; + p.bed.temperature = 60; + p.toolheads[0].target = 220; + p.toolheads[0].temperature = 219.6; + p.toolheads[1].target = 215; + p.toolheads[1].temperature = 214.4; + p.filePosition = Math.round(p.fileSize * 0.37); + p.printDuration = 2843; + p.totalDuration = 2975; + p.filamentUsed = 4831.5; + p.fanMain = 0.85; + p.fanCavity = 0.4; + + p.say = (line) => { + p.console.push({ message: line, time: 1000 + p.console.length, type: 'response' }); + if (p.console.length > 60) p.console.shift(); + }; + + /** + * Which heads the ACE drives, computed the way the plugin computes it - QUIRK INCLUDED. + * + * `head_uses_ace()` has no branch for normal mode: manual is False, head mode tests + * `head_feeder`, and everything else returns True. So a normal-mode machine publishes + * `ace_heads: [0,1,2,3]`, naming every head in the mode whose definition is that none of + * them is ACE-driven. That is what the source says and it has never been observed, so it + * is what the simulator answers - a simulator can only be wrong in the ways it was + * written to be wrong, and tidying this one up here would hide whether the page reads the + * MODE for normal rather than the list. + */ + const aceHeadsNow = (ace) => [0, 1, 2, 3].filter((h) => { + if (ace.head_manual[h]) return false; + if (ace.mode === 'head') return !ace.head_feeder[h]; + return true; + }); + + /* + * Entering head mode clears every feeder head's display identity - multiACE's own + * `_clear_filament_display`: empty type, empty vendor, `FILAMENT_COLOR_RGBA=00000000` - + * while the filament stays physically in the head. It does not touch `filament_exist`, + * which is why the panel has to ask the sensor about occupancy and the record about + * identity, and why they can disagree. + */ + p.wipeFeederHeads = () => { + [0, 1, 2, 3].forEach((h) => { + if (p.ace.head_manual[h] || !p.ace.head_feeder[h]) return; + p.filamentType[h] = ''; + p.filamentVendor[h] = ''; + p.filamentSubType[h] = ''; + p.filamentColorRgba[h] = '00000000'; + p.filamentOfficial[h] = false; + }); + }; + + /* + * And entering multi pushes each BAY's identity onto its lane's head - measured + * 2026-09-01, where `filament_type` went from `["","","","PETG"]` to four PETGs and + * `filament_exist` from `[T,T,F,T]` to all true, including a head whose extruder is + * empty. In multi the U1's display model mirrors the bays, and it believes the mode's + * claim instantly whether or not the tubes agree. + */ + p.pushLanesToHeads = () => { + const unit = p.ace.aces[p.ace.active_device] || p.ace.aces[0]; + const store = p.aceOverrides || {}; + [0, 1, 2, 3].forEach((h) => { + const o = store[`${unit ? unit.idx : 0}_${h}`] || {}; + const raw = (unit && unit.slots && unit.slots[h]) || {}; + p.filamentType[h] = o.material || raw.material || 'PETG'; + p.filamentVendor[h] = o.brand || raw.brand || 'Generic'; + p.filamentSubType[h] = o.subtype || raw.subtype || ''; + p.filamentColorRgba[h] = `${(o.color || '8FA7C8').replace('#', '')}FF`; + p.filamentExist[h] = true; + }); + }; + + /** + * What an ACE macro does to the simulated machine. + * + * The page never awaits one of these - an instant `ok` is indistinguishable from + * success, and ACE_BG_UNLOAD's own help says ~3 min - so it confirms what it asked for + * against machine state instead. That only means anything if the state actually moves, + * which is what this is for: the simulator answers `ok` AND changes, so + * core/pending.js has something to resolve against. + * + * Deliberately instant. Modelling the three minutes belongs in a test that wants to + * watch a wait time out, not in the default path. + */ + p.gcode = (script) => { + const line = String(script || '').trim(); + const name = line.split(/\s+/)[0].toUpperCase(); + const a = gcodeArgs(line); + const ace = p.ace; + if (!ace) return; + const head = Number(a.HEAD); + const unit = Number(a.ACE); + const slot = Number(a.SLOT); + switch (name) { + /* + * The mode, and all three of the things sending it does. Measured on + * 811002511261022618B3 on 2026-09-01 by switching a live machine to `multi` and back. + * + * multi <-> head LIVE. Exactly two keys of the 38 move - `mode` and + * `ace_heads` - and `head_feeder`, `head_ace` and `head_source` + * are left byte-identical, just ignored. Filament may stay + * loaded: the macro's `needs_unload` guard is only on the other + * transition. Entering multi PUSHES each bay's identity onto its + * lane's head in `print_task_config`; entering head wipes the + * feeder heads back out again. + * normal <-> either refused outright with filament in any head, on the console and + * nowhere else - the RPC still answers `ok`. Otherwise it swaps + * three Klipper extras, writes the saved `ace__mode`, and RAISES: + * `ace.mode` goes on reporting the old mode until a restart. + */ + case 'SET_ACE_MODE': { + // The macro's own four words, and its own aliasing: `single` is accepted and + // treated as `multi`. Written out here rather than imported from the page's model, + // because this is the printer's side and it should not agree with the page by + // sharing a constant with it. + let to = String(a.MODE || '').toLowerCase(); + if (to === 'single') to = 'multi'; + if (['normal', 'multi', 'head'].indexOf(to) < 0) break; + const restart = (to === 'normal') !== (ace.mode === 'normal'); + if (restart) { + const loaded = [0, 1, 2, 3].filter((h) => p.filamentExist[h]); + if (loaded.length) { + p.say(`// Cannot switch mode! Filament still loaded in: ` + + `${loaded.map((h) => `E${h}`).join(', ')}`); + p.say('// Please unload all toolheads first, then try again.'); + return; // `ok` on the wire, and nothing happened + } + // The saved variable moves NOW and `ace.mode` does not - which is the whole of + // the restart-pending state, and the only thing that reports it. + p.saveVars.ace__mode = to; + p.say(`[multiACE] Switched to ${to.toUpperCase()} mode. Please reboot!`); + // Returned rather than thrown: it is the macro's own outcome and has to reach the + // page as a refused sw_SendGCodes, which is what the real one does. + return `[multiACE] Switched to ${to.toUpperCase()} mode. ` + + 'Please reboot the printer to activate!'; + } + const was = ace.mode; + ace.mode = to; + p.saveVars.ace__mode = to; + ace.ace_heads = aceHeadsNow(ace); + if (to === 'head' && was !== 'head') p.wipeFeederHeads(); + if (to === 'multi' && was !== 'multi') p.pushLanesToHeads(); + p.say(`// Switching to ${to.toUpperCase()} mode...`); + p.say(`[multiACE] Switched to ${to.toUpperCase()} mode. No reboot needed.`); + break; + } + // Each of the three re-computes `ace_heads`, because the machine does: it is derived + // live from the mode and the wiring rather than stored, so a simulator that leaves it + // where it was would let a page that reads it go on drawing the state before. + case 'ACE_SET_HEAD_MANUAL': + if (Number.isInteger(head)) { + ace.head_manual[head] = a.ENABLE !== '0'; + if (ace.head_manual[head]) ace.head_feeder[head] = false; + ace.ace_heads = aceHeadsNow(ace); + } + break; + case 'ACE_SET_HEAD_FEEDER': + if (Number.isInteger(head)) { + ace.head_feeder[head] = a.ENABLE !== '0'; + if (ace.head_feeder[head]) ace.head_manual[head] = false; + ace.ace_heads = aceHeadsNow(ace); + } + break; + case 'ACE_SET_HEAD_ACE': + // Binding a head to a unit is the one that has to clear the other two, because + // head_feeder is what head mode's resolver reads FIRST. + if (Number.isInteger(head) && Number.isInteger(unit)) { + ace.head_ace[head] = unit; + ace.head_feeder[head] = false; + ace.head_manual[head] = false; + ace.ace_heads = aceHeadsNow(ace); + } + break; + case 'ACE_LOAD_HEAD': + case 'ACE_SWAP_HEAD': { + if (!Number.isInteger(head)) break; + const u = Number.isInteger(unit) ? unit : ace.active_device; + const s = Number.isInteger(slot) ? slot : 0; + const bay = (ace.aces[u] || {}).slots ? ace.aces[u].slots[s] : null; + ace.head_source[head] = { + ace_index: u, slot: s, + type: (bay && bay.material) || 'PETG', + subtype: (bay && bay.subtype) || '', + color: (bay && bay.color) || [99, 44, 44], + brand: (bay && bay.brand) || 'Generic', + }; + break; + } + case 'ACE_UNLOAD_HEAD': + delete ace.head_source[head]; + break; + case 'ACE_UNLOAD_ALL_HEADS': + ace.head_source = {}; + break; + case 'ACE_CLEAR_HEADS': + if (Number.isInteger(head)) delete ace.head_source[head]; + else ace.head_source = {}; + break; + case 'ACE_DRY': { + // As measured: DURATION is MINUTES on the wire, and the object reports seconds. + // `status` is `keeping` while it runs and `stop` when it does not. + const d = ace.aces[Number.isInteger(unit) ? unit : 0]; + if (!d) break; + const secs = (Number(a.DURATION) || 240) * 60; + d.dryer_status = { status: 'keeping', target_temp: Number(a.TEMP) || 55, + duration: secs, remain_time: secs }; + ace.dryer_status = d.dryer_status; + break; + } + case 'ACE_STOP_DRYING': + case 'ACED__DRY_STOP': { + // ACE_STOP_DRYING takes the unit; ACED__DRY_STOP stops the active one. The + // simulator answers both, because both exist on the machine. + const only = name === 'ACE_STOP_DRYING' && Number.isInteger(unit) ? unit : null; + ace.aces.forEach((d, k) => { + if (only !== null && k !== only) return; + d.dryer_status = { status: 'stop', target_temp: 0, duration: 0, remain_time: 0 }; + }); + ace.dryer_status = { status: 'stop', target_temp: 0, duration: 0, remain_time: 0 }; + break; + } + case 'ACE_SET_AUTO_DRY': { + // ENABLE and RH_START, both settled against the machine. THRESHOLD= was the + // guess before that: the printer answered `ok` and changed nothing, so the + // simulator ignores it exactly as the printer does. + const d = ace.aces[Number.isInteger(unit) ? unit : 0]; + if (!d) break; + if (a.ENABLE !== undefined) d.auto_dry.enabled = a.ENABLE !== '0'; + if (a.RH_START !== undefined) d.auto_dry.rh_start = Number(a.RH_START); + if (a.RH_END !== undefined) d.auto_dry.rh_end = Number(a.RH_END); + break; + } + case 'ACE_SET_CONFIRM_COMMANDS': + if (a.ENABLE !== undefined) ace.confirm_commands = a.ENABLE !== '0'; + break; + case 'ACE_SET_SPOOLMAN': + if (a.URL !== undefined) ace.spoolman_url = a.URL; + if (a.AUTO !== undefined) ace.spoolman_auto = a.AUTO !== '0'; + break; + case 'ACE_SET_PURGE': + if (a.MATRIX !== undefined) ace.purge_matrix = a.MATRIX !== '0'; + break; + default: + break; // any other script is just logged + } + }; + + p.pause = () => { p.printState = 'paused'; p.mainState = 'paused'; }; + p.resume = () => { p.printState = 'printing'; p.mainState = 'printing'; }; + p.cancel = () => { + p.printState = 'cancelled'; p.mainState = 'standby'; + p.bed.target = 0; p.toolheads.forEach((t) => { t.target = 0; }); + }; + + function ramp(cur, target) { + if (target <= 0) return Math.max(24, cur - 1.6); + const d = target - cur; + if (Math.abs(d) < 0.6) return target; + return cur + Math.sign(d) * Math.min(Math.abs(d), 3.2); + } + + p.tick = () => { + p.bed.temperature = round1(ramp(p.bed.temperature, p.bed.target)); + p.toolheads.forEach((t, i) => { + t.temperature = round1(ramp(t.temperature, t.target)); + t.can_extrude = t.temperature > 170; + t.power = t.target > 0 ? round2(Math.min(1, Math.max(0, (t.target - t.temperature) / 40))) : 0; + t.state = i === p.active ? (p.printState === 'printing' ? 'printing' : 'ready') : 'idle'; + }); + + if (p.printState === 'printing') { + p.printDuration += 1; + p.totalDuration += 1; + p.filamentUsed = round1(p.filamentUsed + 1.7 * p.speedFactor); + p.filePosition = Math.min(p.fileSize, p.filePosition + Math.round(9000 * p.speedFactor)); + if (p.printDuration % 25 === 0) p.active = (p.active + 1) % 4; + if (p.filePosition >= p.fileSize) { p.printState = 'complete'; p.mainState = 'standby'; } + } else if (p.printState !== 'complete') { + p.totalDuration += 1; + } + + // A running dryer counts down. Nothing pushes `ace` - it is not on the stream - so + // this is only seen when the page next asks for it, which is exactly how the real + // one behaves. + (p.ace ? p.ace.aces : []).forEach((d) => { + const st = d.dryer_status; + if (st && st.status === 'keeping' && st.remain_time > 0) st.remain_time -= 1; + }); + return p.snapshot(); + }; + + p.snapshot = () => { + const objs = { + heater_bed: { temperature: p.bed.temperature, target: p.bed.target }, + virtual_sdcard: { + progress: p.fileSize ? p.filePosition / p.fileSize : 0, + file_position: p.filePosition, is_active: p.printState === 'printing', + file_size: p.fileSize, file_path: p.filename, + }, + print_stats: { + filename: p.filename, state: p.printState, + total_duration: p.totalDuration, print_duration: p.printDuration, + filament_used: p.filamentUsed, message: '', info: {}, + }, + display_status: { + progress: p.fileSize ? p.filePosition / p.fileSize : 0, + message: p.printState === 'paused' ? 'Paused' : '', + }, + gcode_move: { speed_factor: p.speedFactor, speed: 100 * p.speedFactor, extrude_factor: 1 }, + webhooks: { state: 'ready', state_message: 'Printer is ready' }, + fan: { speed: p.fanMain }, + 'fan_generic cavity_fan': { speed: p.fanCavity }, + 'led cavity_led': { color_data: [[0, 0, 0, p.ledWhite]] }, + purifier: { + power_detected: true, power_det_value: 12.4, mode: p.purifierMode, + exhaust_fan: { delay: 0, speedThreshold: 30 }, + inner_fan: { rpm: 2400, delay: 0, work: true, speedThreshold: 30 }, + }, + machine_state_manager: { main_state: p.mainState, action_code: p.actionCode }, + print_task_config: { + filament_vendor: p.filamentVendor.slice(), + filament_type: p.filamentType.slice(), + // As the wire carries them: filament_color is an ARGB integer and + // filament_color_rgba is hex with NO leading '#'. Measured on a U1. + filament_color: p.filamentColorRgba.map( + (h) => (parseInt(String(h).slice(6, 8), 16) << 24 >>> 0) + | parseInt(String(h).slice(0, 6), 16)), + filament_color_rgba: p.filamentColorRgba.slice(), + filament_sub_type: p.filamentSubType.slice(), + /* + * The machine's own two answers about each slot's identity, in the shape it + * reports them - read off 811002511261022618B3 on 2026-08-28, where a tagged + * head that had been overridden came back `official: false, edit: true`. + * + * `filamentOfficial` is the simulated printer's, so a drive script can put a + * slot in either state; `filament_edit` follows the machine's own rule - a + * loaded slot whose record is NOT the spool's own may be edited - rather than + * being a second knob that can be set to disagree with itself. + */ + filament_official: p.filamentOfficial.slice(), + filament_edit: p.filamentExist.map( + (e, i) => e !== false && !p.filamentOfficial[i]), + filament_exist: p.filamentExist.slice(), + extruders_used: [0, 1, 2, 3], + extruder_map_table: { 0: 0, 1: 1, 2: 2, 3: 3 }, + flow_calibrate: true, + time_lapse_camera: false, + auto_bed_leveling: true, + }, + }; + TOOLHEADS.forEach((k, i) => { objs[k] = Object.assign({}, p.toolheads[i]); }); + /* + * The feed channels, which the simulator did not have at all - so the head's own + * sensor marker had nothing to read and neither did the step bar beside it. + * + * `filament_feed left|right` -> `extruder0..3` gives four positions along the path + * plus a fault, and `channel_state` names the step the printer is on. WHICH side + * carries which head has never been measured, and `feedChannels()` does not depend on + * it - it scans both and indexes by the number in the key - so everything is put on + * one side here rather than a split being invented. + */ + objs['filament_feed left'] = {}; + objs['filament_feed right'] = {}; + p.channels.forEach((c, i) => { + // Heads 0-1 on the left object and 2-3 on the right, which is where the machine + // puts them. Putting all four on one side worked because feedChannels() reads both + // and keys off the extruder number - so it proved nothing about the other side. + objs[i < 2 ? 'filament_feed left' : 'filament_feed right']['extruder' + i] = { + index: i, + module_exist: true, + filament_detected: !!c.at, + // The ACE side has filament whether or not the head does - measured `ace/-/-` on + // the one empty head, so this stays true where the other two go false. + filament_in_ace: true, + filament_in_toolhead: !!c.at, + filament_at_extruder: !!c.at, + channel_state: c.state, + // The last operation that FINISHED, which outlives `channel_state` settling back + // to `wait_insert` and is what says whether the head is holding filament. + channel_action_state: c.act || 'none', + channel_error: c.error, + channel_error_state: 'none', + }; + }); + return objs; + }; + + return p; +} + +function round1(v) { return Math.round(v * 10) / 10; } +function round2(v) { return Math.round(v * 100) / 100; } + +/** + * What `/server/webcams/list` answers, shaped exactly as the real one does. + * + * Copied from a U1 running paxx12's Extended Firmware on 2026-08-25, down to the + * `service` strings and the fact that **two of the four entries are not cameras**: the + * printer registers its own touchscreen as `gui` and the multiACE web panel as + * `multiACE`. Keeping both here is the point - `isCamera()` has to reject the one with + * no `snapshot_url`, and a simulator that only ever returned real cameras would never + * exercise that. + * + * `snapshot_url` is a data: URI rather than the real `/webcam/snapshot.jpg`, for the + * same reason the monitor reply is: there is no HTTP server behind a simulated printer, + * and `snapshotUrl()` passes an absolute URL through unchanged. + */ +const MOCK_FRAME = + 'data:image/svg+xml;utf8,' + encodeURIComponent( + '' + + '' + + '' + + '' + + '' + + ''); + +export function mockWebcams() { + return [ + { name: 'case', enabled: true, service: 'webrtc-camerastreamer', + target_fps: 15, target_fps_idle: 5, aspect_ratio: '16:9', + stream_url: '/webcam/webrtc', snapshot_url: MOCK_FRAME, + extra_data: { resolution: '1920x1080' } }, + { name: 'usb', enabled: true, service: 'webrtc-camerastreamer', + target_fps: 15, target_fps_idle: 5, aspect_ratio: '16:9', + stream_url: '/webcam2/webrtc', snapshot_url: MOCK_FRAME, + extra_data: { resolution: '1280x720' } }, + { name: 'gui', enabled: true, service: 'iframe', + target_fps: 15, aspect_ratio: '480:320', + stream_url: '/screen/', snapshot_url: MOCK_FRAME, + extra_data: { resolution: '480x320' } }, + // No snapshot_url: a web panel, not a camera. isCamera() must drop it. + { name: 'multiACE', enabled: true, service: 'iframe', + stream_url: '/multiace/?panel=1', snapshot_url: '', extra_data: {} }, + ]; +} diff --git a/resources/web/shared/js/multiACE.js b/resources/web/shared/js/multiACE.js new file mode 100644 index 00000000000..2113a106c75 --- /dev/null +++ b/resources/web/shared/js/multiACE.js @@ -0,0 +1,1379 @@ +/* + * multiACE.js - everything about multiACE, in one place. + * + * Named for the plugin and not for the hardware, because they are not the same thing and + * the difference is load-bearing. The **ACE** is Snapmaker's own unit. **multiACE** + * (github.com/decay71/multiACE) is the third-party Klipper plugin that drives up to four + * of them, and it is what publishes the `ace` object, the macros, the override store and + * the web service this page reads. None of it is official Snapmaker firmware: a stock U1 + * reports no `ace` object at all, which is the state the Filament panel degrades into. + * Anything here can change with a plugin release; the ACE itself cannot. + * + * It lives apart from state.js because it is not one model but two, from two sources + * that do not carry the same thing: + * + * the `ace` Klipper object topology, unit health, the dryer, occupancy, and the full + * identity of what is LOADED in each head. Machine state, + * read with everything else. + * the override store what is in each BAY. The Klipper object carries none of + * it - every raw slot reads {material:"", brand:"", rfid:0} + * because these spools have no tags - and multiACE keeps + * the names its own web UI shows in a file, merging them in + * its `_parse_state()`. Not machine state: a file the PAGE + * fetched, held in `store.aceBays`. + * + * The panel drew three of four bays as `?` on a machine that knew all four, because it + * read only the first. Orca's own Prepare page polls the merged endpoint from C++ and saw + * the names. Both were reading correctly, from two different things - which is exactly + * why the merge, and the precedence it follows, is one function here rather than a rule + * spread across a view and a command module. + * + * `MachineState.ace()` is a call into `parseAce()`, so the accessor still sits where every + * other accessor does; the constants stay in protocol.js, which is the table of things + * recovered from evidence. What is here is the modelling, and it is pure - no DOM, no + * transport - so unit_jsc.py can hold the precedence rule to account in JavaScriptCore. + */ +'use strict'; + +import { TOOLHEADS, DEVICE, MOONRAKER_HTTP_PORT, cssColor } from './protocol.js'; + +/** + * The multiACE this was written against, and verified on. + * + * A plugin, not firmware: `0.99.8b+9ba137e1` on a U1 running 1.5.2, publishing + * `api_version: 1`, with `ace_bg_swap` at its own `v0.9`. Everything here can move with a + * plugin release, which is why `parseAce()` reads defensively and why the version is + * carried through into the model rather than only living in a comment. + */ +export const MULTIACE_VERIFIED = { web: '0.99.8b', apiVersion: 1, bgSwap: 'v0.9', + on: '2026-08-26' }; + +/** Every ACE has four bays, whatever it is: multiACE's own SLOT_COUNT. */ +const ACE_SLOTS = 4; + +/** + * How an ACE unit is drawn, in the three forms the visual standard defines. + * + * Lifted verbatim from `docs/ace-mmu/16-ace-visuals.md` and its interactive sheet, which + * settled these against Orca's own `AMSItem.hpp` geometry rather than inventing them — + * and which the C++ Prepare page draws from too. An ACE had been drawn four different + * ways across the app; this panel had quietly made it five, with a badge of its own + * shape. Geometry only, no DOM: a view builds the nodes, and there is one place that + * says what an ACE looks like. + * + * badge 44x26 fill — a head box. ONE rounded body split by a hard colour stop, with + * the four spools drawn LAST so none is cropped, and 4 px of + * margin all round. Carries colour and emptiness only. + * glyph 44x26 line — a popover row, a label. The badge's own silhouette in line: the + * same body inset by half a stroke, the same bays filled. + * square 24x24 line — a tab, a menu, a button: where the wide cabinet cannot go. + * Deliberately NOT the same silhouette (body and bays, no hood), + * because a third of the width has to say the same thing. + * + * Four bays is not a variable — multiACE's `SLOT_COUNT` is 4 and `slots[]` is always + * four long — so the bay positions are a constant and not a computation. + */ +export const ACE_ART = { + /* + * x of each bay, in the wide forms: margin 4 == gap 4, Bambu's own proportion. + * + * It used to be [6, 15, 24, 33] with 5x14 bays, which is the drawing the standard calls + * A - a narrower hood over a wider base, with the base painted OVER the bays so their + * lower 2.5 px was cropped. The standard settled on G instead: the cabinet the Device + * page actually draws has parallel sides and whole spools, so its portrait should too. + * That left the margin unequal - 6 at the sides against 3.5 on top - and making it one + * number took the width back out of the box and gave it to the spools. + */ + bayX: [4, 14, 24, 34], + badge: { + w: 44, h: 26, + /** One rounded body; the colour stop below is what makes it two halves. */ + body: { x: 0, y: 2, w: 44, h: 24, rx: 5 }, + bay: { y: 6, w: 6, h: 16, rx: 3 }, + /** The lower half, rounded at the bottom only - a stop, not a second box. */ + base: 'M0 16 h44 v5 a5 5 0 0 1 -5 5 h-34 a5 5 0 0 1 -5 -5 Z', + }, + glyph: { + w: 44, h: 26, stroke: 1.6, + /** The badge's body, inset by half a stroke so 1.6 does not clip the viewBox. */ + body: { x: 0.8, y: 2.8, w: 42.4, h: 22.4, rx: 4.6 }, + bay: { y: 6, w: 6, h: 16, rx: 3 }, + }, + square: { + w: 24, h: 24, stroke: 1.6, + body: { x: 1.8, y: 4.4, w: 20.4, h: 15.2, rx: 3.2 }, + bay: { x0: 4.2, dx: 4.2, y: 7.4, w: 3.2, h: 9.2, rx: 1.6 }, + }, + /* + * The badge's own neutrals, and they are the standard's own. + * + * These were bound to the drawn cabinet's #EEEEEE / #CECECE first, on the reasoning + * that a badge which is a picture of the thing in the card should be the same two + * greys. Wrong, and visibly: the cabinet sits on a white card body and the badge sits + * on the header's #F5F6FA, where #EEEEEE all but disappears and the badge reads as four + * bars on a plinth. The standard declares badge-specific values for exactly that + * reason - it is 26 px tall and lands on chrome. + */ + hood: '#E8E8E8', + base: '#CFCFCF', + emptyBay: '#FFFFFF', + /** AMS_CONTROL_DISABLE_COLOUR: a unit that is configured and not answering. */ + disabled: '#CECECE', +}; + +/* ------------------------------------------------------------------ * + * multiACE + * + * Every control on the Filament panel is a plain G-code macro with documented + * arguments, read out of `printer.gcode.help` on the machine on 2026-08-25, and the + * page already owns sw_SendGCodes - so none of this needs a new bridge command. + * + * Names only. What each one takes lives beside the call in filament-commands.js, + * because an argument list written twice is an argument list that drifts. + * ------------------------------------------------------------------ */ +export const ACE = { + SET_MODE: 'SET_ACE_MODE', // MODE=normal|multi|head [HEAD=n] + SET_HEAD_FEEDER: 'ACE_SET_HEAD_FEEDER', // HEAD=n ENABLE=0|1 (head mode only) + SET_HEAD_ACE: 'ACE_SET_HEAD_ACE', // HEAD=n ACE=a (head mode only) + SET_HEAD_MANUAL: 'ACE_SET_HEAD_MANUAL', // HEAD=n ENABLE=0|1 + LOAD_HEAD: 'ACE_LOAD_HEAD', // HEAD=n [ACE=a] [SLOT=s] + UNLOAD_HEAD: 'ACE_UNLOAD_HEAD', // HEAD=n [RETRACT_LENGTH=] [KEEP_HEAT=] + // The PRINT's swap, and not this panel's: it opens with a Z hop that Klipper + // refuses on an unhomed Z. Kept named so it is recognisable in a trace, and so + // the reason not to send it is written down. See the Swap verb in aceVerbs(). + SWAP_HEAD: 'ACE_SWAP_HEAD', // HEAD=n ACE=a [SLOT=s] + DRY: 'ACE_DRY', // ACE=a [TEMP=C] [DURATION=MINUTES] + // NOT ACED__DRY_STOP, which stops "the current ACE" - on a machine with two units that + // is whichever one is active, and not necessarily the one whose chip was pressed. + DRY_STOP: 'ACE_STOP_DRYING', // [ACE=a] + SET_AUTO_DRY: 'ACE_SET_AUTO_DRY', // ACE=a ENABLE=0|1 [RH_START=%] + UNLOAD_ALL: 'ACE_UNLOAD_ALL_HEADS', // no arguments + SET_PURGE: 'ACE_SET_PURGE', // LENGTH= | RESET=1 + SET_CONFIRM: 'ACE_SET_CONFIRM_COMMANDS',// ENABLE=0|1 + SET_SPOOLMAN: 'ACE_SET_SPOOLMAN', // URL= AUTO=0|1 + CLEAR_HEADS: 'ACE_CLEAR_HEADS', // [HEAD=n] + /* + * The background family, and the gate in front of it. + * + * ACE_BG_SWAP's own help spells out what it needs: head mode, 1:1 wiring, an OPEN dock + * below the head - it purges ~60 mm through it - and the head docked for the whole ~3 + * min sequence. None of that is in the `ace` object; `ace_bg_swap` is a Klipper object + * of its own, and `enabled_heads` is the list ACE_BG_SET_HEAD writes. + */ + BG_SWAP: 'ACE_BG_SWAP', // HEAD=0-3 SLOT=0-3 [ACE=n] [TEMP=] ... + BG_UNLOAD: 'ACE_BG_UNLOAD', // HEAD=0-3 [TEMP=] + BG_SET_HEAD: 'ACE_BG_SET_HEAD', // HEAD=n ENABLE=0|1 +}; + +/* + * ------------------------------------------------------------------ * + * What can be done to one filament, and what the machine is doing about it + * ------------------------------------------------------------------ * + * + * Both settled in docs/u1-webui/02-device-page/multiace-actions.html, and both PURE, so + * unit_jsc.py holds them to account with no DOM - the same reason the override precedence + * lives here rather than in the panel. + */ + +/** The one refusal that can be lifted, spelled once so a caller can match on it. */ +/* + * The U1's OWN feeder macro, which is not multiACE's and is here because aceVerbs() is + * the one place that knows a head has no ACE behind it. + * + * A stock feeder head was being offered `ACE_LOAD_HEAD HEAD=n` with no ACE= and no SLOT=, + * and that macro's own help says what it does: "Load a toolhead FROM ACE". There is no + * ACE. The load was accepted and nothing happened, which is the machine's usual way of + * saying no - `ACE_SET_AUTO_DRY THRESHOLD=` answers `ok` and changes nothing too. + * + * `AUTO_FEEDING EXTRUDER=n` is the wrapper the printer's own config defines: it maps the + * extruder to a (module, channel) through `_FILAMENT_FEED_VARIABLE` and calls `FEED_AUTO`. + * The UNLOAD form is the machine's own usage, read out of its config - + * `SM_PRINT_END_AUTO_UNLOAD_FILAMENT` runs exactly this at the end of every print: + * + * 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 told anything new. + * + * The LOAD form is INFERRED and is the one thing here that no macro on the machine + * spells out: `LOAD=1` is from `SM_PRINT_AUTO_FEED` (`FEED_AUTO ... LOAD=1 PRINTING=1`) + * and the two stages are from the unload above. Everything else on this page was settled + * by sending it and reading the object back; this one is waiting for the same treatment. + */ +export const FEEDER = { + FEED: 'AUTO_FEEDING', // EXTRUDER=n LOAD=1|UNLOAD=1 STAGE=prepare|doing +}; + +export const NOT_DECLARED = 'not enabled for this toolhead'; + +/** + * The verbs, in the state the machine is actually in. + * + * The first design listed all five always and greyed whatever did not apply. That is + * right for a verb the MACHINE is refusing - a background swap on a head nobody has + * declared, where the reason names a macro you can send - and wrong for a verb that is + * not a thing at all in this state. Loading a head that is already loaded is not a load, + * it is a swap; swapping to the bay you are already fed from is a three-minute no-op. + * + * not applicable here left out. There is no reason to show for a verb that does not + * exist. + * applicable, refused listed, with `off` set and `cmd` naming the macro that would + * lift the refusal. + * + * `slot` may be null, which asks "what could be done with SOME other bay" - what a menu + * needs before a bay has been picked. + * + * @param ace parseAce()'s model + * @param head toolhead index + * @param slot bay index within that head's unit, or null + * @param loaded whether the head holds filament. Passed in rather than read: a feeder + * head's answer lives in `print_task_config`, which is not multiACE's. + */ +export function aceVerbs(ace, head, slot, loaded, unitIndex) { + const h = (ace.heads || [])[head]; + if (!h) return []; + // Which unit the bay is in. In head mode a card draws one cabinet and this is always the + // head's own; in multi a card draws slot `head` of every unit, so the caller has to say. + const uIdx = unitIndex == null ? h.unitIndex : unitIndex; + const unit = h.source === 'ace' ? (ace.units || [])[uIdx] : null; + const list = []; + + // A stock feeder has no second bay, so swap and both background verbs are not + // operations it has - and it either holds filament or it does not, never both verbs. + if (!unit) { + list.push({ name: loaded ? 'Unload' : 'Load', macro: FEEDER.FEED, slotted: false, + args: loaded ? { EXTRUDER: head, UNLOAD: 1 } : { EXTRUDER: head, LOAD: 1 }, + stages: ['prepare', 'doing'] }); + return list.map(withCmd); + } + + /* + * `loaded` is the authority on whether there is filament in the head, and `fed` only on + * where it CAME FROM - which is meaningful only while something is there. + * + * This read `fed == null && !loaded`, so a head whose `head_source` still named a bay + * was never empty however the sensor answered. That is the same trap one level down + * from the one that started this: `head_source` is multiACE's record of the last feed + * and it does not stop naming a bay because the filament came out, exactly as + * `print_task_config.filament_exist` does not stop existing. + */ + const fed = h.bay; + const bay = slot == null ? null : (unit.bays || [])[slot]; + const empty = !loaded; + // Feeding means the same BAY of the same UNIT. In head mode there is only ever one unit + // on a card so the second half is free; in multi it is the whole difference between the + // bay that is feeding and its neighbour on the next cabinet. + const same = loaded && slot != null && fed != null && slot === fed && uIdx === h.unitIndex; + const there = slot == null ? true : !!(bay && bay.occupied); + /* + * Outside head mode a bay is NOT free to feed any head. + * + * `cmd_ACE_LOAD_HEAD`'s non-head branch is `slot = gcmd.get_int('SLOT', head)`: bay s is + * plumbed to head s, one lane each. The machine will not refuse a `SLOT=` naming somebody + * else's lane - it will ask the ACE to push filament somewhere it is not routed - so the + * refusal has to be here. HelixScreen reached the same rule independently and calls it + * PARALLEL against head mode's HUB. + * + * A bay that is not this head's lane is not a verb of any kind, so it is left out rather + * than listed and greyed: there is no macro that would make it one. + * + * It takes the SLOTTED verbs and only those. `Unload` addresses the head and has nothing + * to do with which bay was asked about - and the measured machine reaches exactly that + * state, with `head_source[3]` naming a bay off head 3's own lane, so a blanket refusal + * here emptied the toolhead menu on the one head that had filament in it. + */ + const lanes = ace.mode !== 'head'; + const offLane = lanes && slot != null && h.lane != null && slot !== h.lane; + /* + * The background family is head mode only, and says so in its own words. + * + * `ace_bg_swap` refuses outside head mode with "v0 requires head mode (1:1 ACE per + * head)" - the gate is the MODE, not the per-head declaration, and `ACE_BG_SET_HEAD` + * would not lift it. A refusal a verb row cannot lift is not a verb row, so in multi + * these are absent rather than greyed. + */ + const bgOk = !lanes && (ace.bgHeads || []).indexOf(head) >= 0; + const gate = { macro: ACE.BG_SET_HEAD, args: { HEAD: head, ENABLE: 1 } }; + + if (empty && !offLane) { + list.push({ name: 'Load', macro: ACE.LOAD_HEAD, slotted: true, + args: { HEAD: head, ACE: unit.index, SLOT: slot }, + off: there ? null : 'that bay is empty' }); + } else if (!empty && !same && !offLane) { + /* + * A swap is an UNLOAD and then a LOAD, and deliberately NOT `ACE_SWAP_HEAD`. + * + * That macro is the PRINT's swap. It opens with an unconditional `G91 / G1 Z2 F600 / + * G90` to lift the nozzle off the part before it parks and picks the head, and + * Klipper refuses a Z move on an unhomed Z - so on a machine sitting at + * `homed_axes: "xy"` it printed `!! Must home Z axis first` and did nothing, while + * the RPC answered `ok`. multiACE guards that same hop with a homed_axes check in + * the two paths that can run idle (`_discard_wipe`, `_bg_pick_flow_check`); the swap + * path is the one place it does not, because a print is homed by definition. + * + * Neither ACE_UNLOAD_HEAD nor ACE_LOAD_HEAD moves Z at all, so the pair needs no + * homing - and the pair is what BOTH other UIs on this machine send for a swap + * someone asked for: multiACE's own dashboard (`app.js`, loadSlot, whose comment + * calls the other one "ACE_SWAP_HEAD from the gcode file") and HelixScreen's + * AmsBackendMultiAce, which says outright that it does not use it. + * + * The unload has to come first and cannot be folded in: ACE_LOAD_HEAD's own guard + * refuses a head that already holds filament rather than swapping for you. + * + * What goes with the print's macro is its mid-print machinery - the Z hop, the XYZ/E + * 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 + * of the other two passes a temperature either, and picking one here would be + * inventing a number. + */ + list.push({ name: 'Swap', macro: ACE.LOAD_HEAD, slotted: true, + args: { HEAD: head, ACE: unit.index, SLOT: slot }, + steps: [{ macro: ACE.UNLOAD_HEAD, args: { HEAD: head } }, + { macro: ACE.LOAD_HEAD, + args: { HEAD: head, ACE: unit.index, SLOT: slot } }], + off: there ? null : 'that bay is empty' }); + if (!lanes) { + list.push({ name: 'Background swap', macro: ACE.BG_SWAP, slotted: true, bg: true, + args: { HEAD: head, SLOT: slot, ACE: unit.index }, + off: !there ? 'that bay is empty' : bgOk ? null : NOT_DECLARED, + gate: bgOk ? null : gate }); + } + } + if (!empty) { + // An ACE pulls the filament back into its bay, which a stock feeder cannot - that is + // what RETRACT_LENGTH is for, and why this verb is not called `Unload` here. + list.push({ name: 'Unload and retract', macro: ACE.UNLOAD_HEAD, slotted: false, + args: { HEAD: head, RETRACT_LENGTH: null } }); + if (!lanes) { + list.push({ name: 'Background unload', macro: ACE.BG_UNLOAD, slotted: false, bg: true, + args: { HEAD: head }, + off: bgOk ? null : NOT_DECLARED, gate: bgOk ? null : gate }); + } + } + return list.map(withCmd); +} + +/* + * A verb carries the line it would send, and an unavailable one carries the line that + * would make it available instead - so what a control says and what it does cannot + * disagree. A null argument renders as a placeholder rather than being dropped: the sheet + * is showing the shape of the command, not sending it yet. + */ +function withCmd(v) { + const shown = (a) => { + const out = {}; + Object.entries(a || {}).forEach(([k, x]) => { + out[k] = x === null ? (k === 'RETRACT_LENGTH' ? '' : '') : x; + }); + return out; + }; + const gate = v.gate ? aceLine(v.gate.macro, v.gate.args) : null; + /* + * A verb is one line unless the machine needs more than one, and two of them do: a + * feeder verb is the same macro twice with a different STAGE, and a swap is an unload + * followed by a load. `sw_SendGCodes` takes a newline-separated script. + */ + const steps = v.steps ? v.steps + : v.stages ? v.stages.map((st) => ({ macro: v.macro, + args: Object.assign({}, v.args, { STAGE: st }) })) + : [{ macro: v.macro, args: v.args }]; + // Every macro this verb would actually send. The caller's allow-list is about what + // goes on the wire, and `macro` alone stopped being that the moment a verb had two. + return Object.assign({}, v, { + cmd: gate || steps.map((s) => aceLine(s.macro, shown(s.args))).join('\n'), + macros: steps.map((s) => s.macro), + }); +} + +/** + * What the printer is doing, in its own words. + * + * NOT `swap_phase`, which is multiACE's and has never been captured on hardware. The U1's + * own `channel_state` names the step - `filament_feed left|right` -> `extruder`, + * already on this page's subscription and already parsed by `feedChannels()`. The table + * below is HelixScreen's classification of that field; its Snapmaker backend drives a U1 + * touchscreen from it, and taking it rather than re-deriving it is what stops the two + * disagreeing about one machine. + */ +export const ACE_STEPS = { + unload: ['Home', 'Select', 'Heat nozzle', 'Retract filament'], + load: ['Home', 'Select', 'Heat nozzle', 'Feed filament', 'Purge'], + /* + * A swap on an ACE-fed head is ONE bar with two halves. The load half's own Home / + * Select / Heat are deliberately absent: the head is mounted and already hot by then, + * so they pass straight through. + * + * And there is no step for the ACE fetching the new bay. Measured on a live U1 that is + * a ~4 s blip in a ~100 s operation, because the bay is already staged at its gate - a + * row complete before it is read is worse than no row, so the bar holds on `Retract + * filament` across it. + */ + swap: ['Home', 'Select', 'Heat nozzle', 'Retract filament', 'Feed filament', 'Purge'], +}; + +/** Where each direction's step lands on the swap bar. `null` holds rather than jumps. */ +const SWAP_AT = { unload: [0, 1, 2, 3], load: [null, null, null, 4, 5] }; + +const CHANNEL_STATES = { + /* + * The idle words, and an idle machine does not say `none`. + * + * Read off 811002511261022618B3 with everything settled: two heads said `load_finish` + * and two said `wait_insert`. So a terminal state IS the resting state - the field holds + * the last operation's ending rather than returning to a neutral word - and both draw + * nothing, which is what makes an idle panel quiet. + * + * `wait_insert` is not a reliable "this head is empty", either: the head reading it was + * fed from bay 2. The step bar does not care, because terminal states draw nothing - + * and nothing else should read this field for occupancy. `headOccupied()` below is + * where that question goes, and `channel_action_state` is what answers it. + */ + none: null, inited: null, wait_insert: null, test: null, + unload_prepare: ['unload', 0], unload_homing: ['unload', 0], + unload_picking: ['unload', 1], + unload_heating: ['unload', 2], unload_heat_finish: ['unload', 2], + unload_doing: ['unload', 3], + load_prepare: ['load', 0], load_homing: ['load', 0], + load_picking: ['load', 1], + load_heating: ['load', 2], + load_feeding: ['load', 3], load_extruding: ['load', 3], + load_flushing: ['load', 4], + preload_prepare: ['load', 0], preload_feeding: ['load', 3], +}; + +/** + * One `channel_state` string, classified against the bar being drawn. + * + * Unknown states fall through to a prefix heuristic rather than being dropped: this is a + * firmware that has grown states before, and a future `unload_something` should hold the + * bar in the right half rather than reset it. + * + * @param kind 'swap' | 'load' | 'unload' - which bar is on screen + */ +/** + * Whether a head is HOLDING FILAMENT - and every sensor on the channel answers something + * else. + * + * Read off 811002511261022618B3 with toolhead 1 unloaded by hand a moment before, which + * is the one head in the row whose true state was known: + * + * 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 none of them is: + * + * `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. It tracks the PATH having + * filament available, not the head having it, and it does not + * go false when a head is emptied. + * `filament_in_toolhead` true on the head just emptied and false on the loaded one, + * which is not a sensor reading anyone should build on. + * `channel_state` `wait_insert` on an empty head AND on a loaded one - already + * recorded above, and the reason this needed a second look. + * + * `channel_action_state` separates them, because it is not a sensor: it is the last + * operation the channel FINISHED. `unload_finish` on the two heads that had been + * unloaded, `none` on the two that had not been touched since boot. + * + * So: the sticky field first, then the live one, then the topology - multiACE names the + * bay it fed a head from, and a stock feeder has only the job record. + * + * That order is by what each field is FOR, not by which is fresher, and the two differ. + * `channel_state` is the more recent value, but it is the one that goes to a word + * carrying no occupancy at all; `channel_action_state` is the one that exists to record + * the answer, and on the machine it never lags - it held `unload_finish` on both heads + * that had been unloaded, one of them still reading it live and one already settled. So + * it is asked first, and `channel_state` is read only where nothing has finished since + * boot and it is still holding a terminal word of its own. + * + * `*_fail` deliberately decides nothing: a load that failed may have got anywhere, and + * the topology is a better guess than either half of a guess. + * + * This is the third field this question has been asked of and the second that was wrong. + * It is asked in ONE place for that reason. + */ +export function headOccupied(feed, head, jobLoaded) { + for (const st of [feed && feed.actionState, feed && feed.channelState]) { + const s = String(st || '').toLowerCase(); + if (s === 'load_finish' || s === 'preload_finish') return true; + if (s === 'unload_finish') return false; + } + if (head && head.source === 'ace') return !!(head.loaded || head.bay != null); + return !!jobLoaded; +} + +/** + * The resting and terminal channel words, in words. + * + * `channelStep()` names the steps INSIDE an operation and answers null for everything + * else, which left the page with nothing to say for the states a channel actually sits in + * - so it said `wait_insert`, `unload_fail`, `channel_state load_flushing` in hovers and + * dialogs. A field name says where the page read something, which is the page's business + * and not the reader's. `activity.js` does exactly this for `action_code`; this is the + * same table for the other field. + * + * Anything not here answers null, and a caller with nothing to say says nothing. + */ +const CHANNEL_WORDS = { + none: 'Idle', inited: 'Idle', test: 'Self-test', + wait_insert: 'Waiting for filament', + load_finish: 'Loaded', preload_finish: 'Loaded', unload_finish: 'Unloaded', + load_fail: 'Load failed', preload_fail: 'Load failed', unload_fail: 'Unload failed', +}; + +export function channelWord(state) { + const s = String(state || '').toLowerCase(); + return CHANNEL_WORDS[s] || null; +} + +export function channelStep(state, kind) { + const s = String(state || '').toLowerCase(); + if (!s) return null; + if (/_fail$/.test(s)) return { state: s, failed: true, at: null }; + if (/_finish$/.test(s)) return { state: s, done: true, at: null }; + // `in`, not truthiness: the table maps the idle words to null ON PURPOSE, and `||` + // sent them to the heuristic below - which happens to answer null for the ones this + // firmware uses, and would not for a future idle word beginning `load`. + const hit = s in CHANNEL_STATES ? CHANNEL_STATES[s] + : /^unload/.test(s) ? ['unload', 0] + : /^(load|preload|manual)/.test(s) ? ['load', 0] : null; + if (!hit) return null; + const [dir, at] = hit; + const steps = ACE_STEPS[kind] || ACE_STEPS[dir]; + // Resolved THROUGH the list, never used as a position: on a swap the two halves carry + // different ids, and a step this bar does not declare must hold rather than jump back. + const idx = kind === 'swap' ? SWAP_AT[dir][at] : at; + return { state: s, dir, at: idx, total: steps.length, + label: idx == null ? null : steps[idx], + heat: idx != null && steps[idx] === 'Heat nozzle' }; +} + +/** + * Units are addressed by index on the wire and by letter on screen, the way Bambu + * addresses an AMS - so a bay is A1..D4 and the address fits inside its own disc. + */ +export const ACE_UNIT_IDS = ['A', 'B', 'C', 'D']; +export const aceUnitId = (i) => ACE_UNIT_IDS[i] || String((i | 0) + 1); +export const aceBayAddr = (unit, slot) => `${aceUnitId(unit)}${(slot | 0) + 1}`; + +/** SET_ACE_MODE's three, in the order the macro help lists them. */ +export const ACE_MODES = ['normal', 'multi', 'head']; +export const ACE_MODE_LABELS = { normal: 'Normal', multi: 'Multi', head: 'Head' }; + +/** + * What the dryer dialog offers, and what the macro will take. + * + * The presets are the common answers; the limits are there because ACE_DRY takes a + * number and not a menu, and a spool whose tag says 65 C for 8 h should not have to be + * rounded to the nearest chip. + */ +export const DRY_TEMPS = [45, 55, 65, 70]; +export const DRY_HOURS = [2, 4, 6, 12]; +export const DRY_LIMITS = { temp: [35, 80], hours: [1, 24] }; +export const AUTO_DRY_THRESHOLDS = [45, 55, 65]; + +/** + * ACE_DRY's DURATION is in MINUTES, and the panel offers hours. + * + * Measured, because it had to be: `DURATION=3` came back as `duration: 180` and + * `DURATION=240` as `14400`, both in seconds. Sending the panel's `4` unconverted asked + * for four MINUTES of drying and the machine answered `ok`, which is the whole reason + * this constant exists rather than a bare multiplication somewhere. + */ +export const DRY_MINUTES_PER_HOUR = 60; + +/** + * Where multiACE keeps what is in each bay, and why it is a file. + * + * The `ace` Klipper object carries NO per-bay identity: every raw slot reads + * `{material:"", brand:"", rfid:0}` because these spools have no tags. multiACE keeps + * the names its own web UI shows in an override store and merges them in `_parse_state()` + * — which is why `/multiace/api/state` shows four named bays where the Klipper object + * shows four blanks, and why Orca's own AceMmuProvider (which polls that endpoint from + * C++) sees filament this page could not. + * + * That endpoint is not reachable from a browser: nginx serves `/multiace/` with **no + * CORS header at all**, measured. What IS reachable is the store itself — Moonraker's + * file server on :7125 reflects the Origin, the same server this page already fetches + * camera frames and job thumbnails from, and the override store lives under its `config` + * root. So this needs no proxy, no new bridge command and no C++. + * + * Keyed `"_"` -> `{ace, slot, material, brand, subtype, color}`. + */ +export const ACE_OVERRIDES_FILE = 'config/extended/multiace/slot_overrides.json'; + +/** + * Moonraker's console history, which is where the printer says what went wrong. + * + * `sw_SendGCodes` answers `ok` for a macro that failed: multiACE printed + * `!! Must home Z axis first` and set `last_swap_result.status` to `error`, and the reply + * carried neither. The `!!` line is Klipper's error channel and it is the only place the + * REASON exists - so the panel reads it rather than inventing a sentence about what + * probably happened. + * + * Same host and same port as the override store, and Moonraker reflects the Origin here + * too (checked: `Access-Control-Allow-Origin: http://127.0.0.1:13619`). + */ +export function gcodeStoreUrl(device, count = 20) { + const ip = device && device[DEVICE.IP]; + if (!ip) return null; + return `http://${ip}:${MOONRAKER_HTTP_PORT}/server/gcode_store?count=${count | 0}`; +} + +/** The most recent `!!` line in a gcode_store answer, without its marker. */ +export function lastPrinterError(raw) { + const list = raw && raw.result && Array.isArray(raw.result.gcode_store) + ? raw.result.gcode_store : []; + for (let i = list.length - 1; i >= 0; i--) { + const m = String((list[i] || {}).message || ''); + if (m.startsWith('!!')) return m.replace(/^!!\s*/, '').split('\n')[0].trim() || null; + } + return null; +} + +/** + * What multiACE said about a mode switch, which is the only place it says it. + * + * A `normal` transition with filament in any head answers `ok` and changes nothing: the + * `SET_ACE_MODE` macro's own guard prints the reason and returns. Those are `//` NOTES, not + * `!!` errors, so lastPrinterError() above cannot see them - it reads the error channel and + * this is on the note one. Captured verbatim on 2026-09-01: + * + * // Cannot switch mode! Filament still loaded in: E0, E1, E3 + * // Please unload all toolheads first, then try again. + * + * Both lines, in order, because the macro prints the reason and then what to do about it - + * and a dialog that quotes the first without the second answers half the question. + */ +export function aceModeRefusal(raw) { + const list = raw && raw.result && Array.isArray(raw.result.gcode_store) + ? raw.result.gcode_store : []; + const note = (e) => { + const m = String((e || {}).message || ''); + return /^\/\//.test(m) ? m.replace(/^\/\/\s*/, '').trim() : null; + }; + for (let i = list.length - 1; i >= 0; i--) { + const t = note(list[i]); + if (!t || !/^Cannot switch mode/i.test(t)) continue; + const out = [t]; + for (let k = i + 1; k < list.length; k += 1) { + const n = note(list[k]); + if (!n) break; + out.push(n); + } + return out; + } + return null; +} + +export function aceOverridesUrl(device) { + const ip = device && device[DEVICE.IP]; + if (!ip) return null; + return `http://${ip}:${MOONRAKER_HTTP_PORT}/server/files/${ACE_OVERRIDES_FILE}`; +} + +/** + * multiACE's own precedence for where a bay's identity came from, and its own words for + * it: `rfid | override | derived | empty`. Worth keeping verbatim - the panel draws the + * difference (an eye for what may only be read, a pencil for what may be edited) and + * inventing a second vocabulary for the same fact is how two of them drift apart. + */ +export const BAY_SOURCES = ['rfid', 'override', 'derived', 'unknown', 'empty']; + +/** + * Orca's own humidity buckets - `hum_level1..5` at <=20, <=35, <=50, <=65, >65. + * + * The same thresholds AMSinfo uses to pick its droplet, so a tinted pill on this page + * and the C++ widget cannot disagree about the same number. + */ +export function humidityLevel(h) { + const n = Number(h); + if (!Number.isFinite(n)) return 0; + return n <= 20 ? 1 : n <= 35 ? 2 : n <= 50 ? 3 : n <= 65 ? 4 : 5; +} + +/** + * One macro line: `ACE_DRY` + {ACE: 0, TEMP: 55} -> `ACE_DRY ACE=0 TEMP=55`. + * + * Here rather than in the panel's commands because the panel is not the only thing that + * will ever send one, and because the argument names are the half of this subsystem that + * has actually been wrong: `THRESHOLD=` was accepted, acked and ignored. + */ +export function aceLine(macro, args) { + return [macro].concat( + Object.entries(args || {}) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${k}=${v}`)).join(' '); +} + + +/* ------------------------------------------------------------------ * + * Drawing one + * + * The only part of this module that touches a document. It is here rather than in the + * panel because an ACE is drawn on more than one surface and had been drawn four + * different ways across the app - the standard exists to allow one, and one place to + * call is what makes that hold. Nothing below runs at module scope, so the pure half + * still evaluates in JavaScriptCore for unit_jsc.py. + * ------------------------------------------------------------------ */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; +function svgEl(tag, attrs) { + const n = document.createElementNS(SVG_NS, tag); + Object.entries(attrs || {}).forEach(([k, v]) => n.setAttribute(k, v)); + return n; +} +const r1 = (v) => Math.round(v * 10) / 10; + +/** + * What a badge is drawn at when nobody says. + * + * The forms are nominal - badge and glyph 44x26, square 24x24 - and the standard's own + * builders take a zoom, which is how one drawing serves a 26 px row and a 17 px one + * rather than two sets of proportions. 17 is what the Device page's unit row is, because + * that panel's body is 456: measured, and the one number on it that cannot move. A + * surface with room passes `1`. + */ +export const BADGE_ROW = 17; +const BADGE_Z = BADGE_ROW / 26; + +/* + * The three forms an ACE is drawn in, from ACE_ART - which is the visual standard the + * C++ Prepare page draws from. This panel had a badge of its own shape before, which + * made five drawings of one object across the app where the standard exists to allow + * one. + * + * `z` zooms the wide forms. That is the standard's own mechanism - its builders take a + * zoom and multiply the nominal 44x26 - and it is how a 26px-tall unit row gets the same + * drawing as a 44px one rather than a different one. + */ + +/** + * Badge: one body, a colour stop, and four spools drawn OVER both. + * + * Filled with that unit's own bays, so the thing in the header is a picture of the thing + * in the card. Colour and emptiness are all that survive at this size, which is why it + * carries no outline, no address, no state - and no level, even where Spoolman has bound + * one: at 6x16 a level is not readable. + * + * Draw order is the whole of the change from A: the spools go on LAST, so a spool is a + * whole spool rather than one with its lower 2.5 px painted over by the base. + */ +export function aceBadge(bays, z = BADGE_Z) { + const A = ACE_ART.badge; + // Sized here rather than in CSS: an SVG carrying only a viewBox has no intrinsic ratio + // to give `width: auto`, and it came out square - 17x17 for a 44x26 drawing. + const s = svgEl('svg', { width: r1(A.w * z), height: r1(A.h * z), + viewBox: `0 0 ${A.w} ${A.h}`, + 'aria-hidden': 'true', class: 'ace-badge' }); + s.appendChild(svgEl('rect', { x: A.body.x, y: A.body.y, width: A.body.w, + height: A.body.h, rx: A.body.rx, fill: ACE_ART.hood })); + s.appendChild(svgEl('path', { d: A.base, fill: ACE_ART.base })); + bays.forEach((b, i) => s.appendChild(svgEl('rect', { + x: ACE_ART.bayX[i], y: A.bay.y, width: A.bay.w, height: A.bay.h, rx: A.bay.rx, + fill: (b.occupied && cssColor(b.color)) || (b.occupied ? '#B7BDC6' : ACE_ART.emptyBay), + }))); + return s; +} + +/** + * Glyph: the badge's silhouette in line, bays filled. Takes the ink from `currentColor`. + * + * It used to be a stepped path - A's silhouette, hood shoulders on top and a base stepping + * out at the bottom. When the badge stopped stepping this had to stop too, or the pair say + * two different things about one object. + */ +export function aceGlyph(z = 1) { + const A = ACE_ART.glyph; + const s = svgEl('svg', { width: A.w * z, height: A.h * z, viewBox: `0 0 ${A.w} ${A.h}`, + fill: 'none', stroke: 'currentColor', 'stroke-width': A.stroke, + 'stroke-linejoin': 'round', 'stroke-linecap': 'round', + 'aria-hidden': 'true', class: 'ace-glyph-wide' }); + s.appendChild(svgEl('rect', { x: A.body.x, y: A.body.y, width: A.body.w, + height: A.body.h, rx: A.body.rx })); + ACE_ART.bayX.forEach((x) => s.appendChild(svgEl('rect', { + x, y: A.bay.y, width: A.bay.w, height: A.bay.h, rx: A.bay.rx, + fill: 'currentColor', stroke: 'none' }))); + return s; +} + +/** + * Glyph, square: body and four bays, no hood. + * + * For an icon slot the wide cabinet cannot go in — a menu row, a pill. Not the same + * silhouette on purpose: the family is carried by the bay treatment and the stroke, + * because a third of the width has to say the same thing. + */ +export function aceGlyphSquare(px = ACE_ART.square.w) { + const A = ACE_ART.square; + const s = svgEl('svg', { width: px, height: px, viewBox: `0 0 ${A.w} ${A.h}`, + fill: 'none', stroke: 'currentColor', 'stroke-width': A.stroke, + 'stroke-linejoin': 'round', 'stroke-linecap': 'round', + 'aria-hidden': 'true', class: 'ace-glyph-sq' }); + s.appendChild(svgEl('rect', { x: A.body.x, y: A.body.y, width: A.body.w, + height: A.body.h, rx: A.body.rx })); + for (let i = 0; i < 4; i += 1) { + s.appendChild(svgEl('rect', { x: A.bay.x0 + i * A.bay.dx, y: A.bay.y, width: A.bay.w, + height: A.bay.h, rx: A.bay.rx, + fill: 'currentColor', stroke: 'none' })); + } + return s; +} + +/** + * The `ace` Klipper object, unpacked. + * + * Measured on 811002511261022618B3 (2026-08-25, again 2026-08-26): one ACE 2 Pro, + * `protocol: "v2"`, `mode: "head"`, `device_count: 1`, 38 % RH at 30 C, feeding Toolhead 4 + * from bay 3, four PETG spools. `head_feeder {0,1,2 true, 3 false}`, + * `gate_status [1,1,1,1]`, and every raw slot `{material:"", brand:"", color:[0,0,0], + * rfid:0}`. + * + * Two things that only hardware said, and both change what this returns: + * + * `head_ace` is NOT the answer to "what feeds this head". It reads + * `{0:0, 1:1, 2:2, 3:0}` on a machine with `device_count: 1`, so heads 2 and 3 name + * units that do not exist. A head's source resolves `head_manual`, then `head_feeder`, + * and `head_ace` only for what is left - and only when the unit it names is actually + * there. 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. A bay carries a colour, not a + * gauge; anything drawing a fill height here would be drawing a number the machine did + * not give it. + */ +export function parseAce(objects) { + const o = (objects || {})['ace']; + const present = !!o && typeof o === 'object' && !Array.isArray(o); + if (!present) { + return { present: false, mode: null, savedMode: null, aceHeads: [], + unitCount: 0, activeUnit: 0, + swapping: false, swapPhase: null, lastSwap: null, bgHeads: [], bgVersion: null, + units: [], heads: TOOLHEADS.map((_k, i) => ({ + index: i, source: 'feeder', unitIndex: null, unitId: null, + bay: null, lane: null, loaded: null })), + apiVersion: null, + settings: { confirmCommands: false, spoolmanUrl: '', spoolmanAuto: false, + purgeMatrix: false, spoolMode: null, bound: 0 } }; + } + + const rawUnits = Array.isArray(o.aces) ? o.aces : []; + // `device_count` is the authority on how many units to draw - it is what ace.cfg + // declares - and `aces[]` may be longer or shorter than it. + const count = Number.isFinite(Number(o.device_count)) + ? Math.max(0, Math.min(ACE_UNIT_IDS.length, Number(o.device_count))) + : rawUnits.length; + + const units = []; + for (let i = 0; i < count; i += 1) units.push(aceUnit(rawUnits[i] || {}, i)); + + const activeUnit = Number(o.active_device) || 0; + const heads = TOOLHEADS.map((_k, i) => { + const src = headSource(o, i, units.length, activeUnit); + const from = mapAt(o.head_source, i); + const loaded = spoolOf(from); + if (src.source === 'ace' && loaded && units[src.unitIndex]) { + // The one identity the raw object does carry. Every unloaded bay reads + // `{material:"", brand:"", rfid:0}`, but `head_source[n]` names what is IN the + // head - which is also what is in the bay it came from, so the bay it came from + // stops being "occupied, not named". + const bay = units[src.unitIndex].bays[src.bay]; + if (bay && bay.occupied && !bay.material) { + // multiACE's own word for this: the identity was DERIVED from what is loaded, + // not read off a tag and not typed against the bay. It is the weakest of the + // three, so an override read off the printer replaces it - see the panel. + Object.assign(bay, loaded, { known: true, source: bay.rfid ? 'rfid' : 'derived' }); + } + } + return { index: i, ...src, loaded }; + }); + + return { + present: true, + /* + * Which multiACE this is talking to. + * + * The whole surface below belongs to a plugin someone chose to install, not to the + * U1's firmware, and it is pre-1.0: `api_version` is the contract the object + * claims, and `ace_bg_swap.version` its own. Carried so a mismatch is something + * the page can SAY rather than something a user discovers when a control does + * nothing - and so the version this was verified against is checkable rather than + * only written in a document. + */ + apiVersion: numOrNull(o.api_version), + mode: o.mode || null, + /* + * The mode the machine will be in after a restart, when that is not the one it is + * reporting. + * + * A normal transition swaps three Klipper extras on disk and then RAISES - so it + * writes the saved `ace__mode` immediately, fails the RPC on purpose, and goes on + * reporting the old `ace.mode` until the power cycle. The disagreement between the + * two IS the pending state, and there is nothing else that says a restart is owed. + * `save_variables` is a Klipper object like any other and rides in the same + * sw_GetMachineState call, so this costs no request. + */ + savedMode: savedAceMode(objects), + /* + * Which heads the ACE drives, straight from the machine. + * + * `[h for h in range(4) if self.head_uses_ace(h)]`, computed live from the mode. + * Carried whole as well as being resolved per head, because it is the one field that + * answers "what does this mode mean here" without re-deriving anything. + */ + aceHeads: Array.isArray(o.ace_heads) + ? o.ace_heads.map(Number).filter(Number.isInteger) : [], + unitCount: units.length, + activeUnit, + swapping: !!o.swap_in_progress || (o.swap_phase && o.swap_phase !== 'idle'), + swapPhase: nonEmpty(o.swap_phase), + lastSwap: o.last_swap_result != null ? o.last_swap_result : null, + /* + * Which heads a background swap may run on. + * + * NOT from the `ace` object - all 38 of its top-level keys were read on the machine + * and there is no `bg_swap` among them. `ace_bg_swap` is a Klipper object of its + * own, so it is a second key in the same sw_GetMachineState call rather than a + * second request. Measured 2026-08-26: `{"version":"v0.9","enabled_heads":[], ...}` + * - no head is declared on that machine, which is why the panel's first duty is to + * draw the refusal well. + */ + ...parseBgSwap(objects), + units, + heads, + /* + * The settings a person sets once, AS THE MACHINE REPORTS THEM. + * + * These were written as write-only - "the machine does not report this back, so + * this sets it rather than shows it" - and that was simply wrong: three of the four + * are right there in the object. A dialog that sets a value it could have shown is + * a dialog that asks you to remember what you chose. + * + * The flush LENGTH really is absent. `purge_matrix` is the boolean that says + * whether the per-pair stamps from the slicer's flush matrix are honoured; the + * length itself lives in the config and is not in this object. + */ + settings: { + confirmCommands: o.confirm_commands === true, + spoolmanUrl: nonEmpty(o.spoolman_url) || '', + spoolmanAuto: o.spoolman_auto === true, + purgeMatrix: o.purge_matrix === true, + spoolMode: nonEmpty(o.spool_mode), + // Empty on this machine, which is why no bay has a level. When it is not, the + // entry it points at carries `weight_g` - remaining grams, an estimate from + // extruded length. There is still no original weight, so a PERCENTAGE is not + // derivable and a disc stays a colour rather than a gauge. + bound: Object.keys(o.spool_binding || {}).length, + }, + }; +} + +/** + * The override store, as it comes off the printer's file server. + * + * The file is the map itself; multiACE's own API wraps it in `{overrides: ...}`. Both are + * accepted, because the two are the same data and a caller should not have to know which + * end it came from. + */ +/** + * `ace_bg_swap`, which is a different object from `ace` and answers a different question. + * + * A head is background-capable only when someone has DECLARED it with ACE_BG_SET_HEAD, + * and the help for that macro says what the declaration means physically: the dock below + * that head is open, and the cold-pull purges ~60 mm through it. So an undeclared head is + * not a control to grey out quietly - it is the one mistake on this panel that costs + * filament and a bed. + */ +function parseBgSwap(objects) { + const o = (objects || {})['ace_bg_swap']; + if (!o || typeof o !== 'object') return { bgHeads: [], bgVersion: null, bgBusy: [] }; + const ints = (v) => (Array.isArray(v) ? v.map(Number).filter(Number.isInteger) : []); + return { bgHeads: ints(o.enabled_heads), bgVersion: nonEmpty(o.version), + bgBusy: ints(o.busy) }; +} + +/** + * The saved mode, out of `save_variables` - a different object, answering a different + * question from `ace.mode`. + */ +function savedAceMode(objects) { + const sv = (objects || {})['save_variables']; + const vars = sv && typeof sv === 'object' ? (sv.variables || sv) : null; + return vars && typeof vars === 'object' ? nonEmpty(vars.ace__mode) : null; +} + +/** + * What sending `SET_ACE_MODE` actually does, which is three different things. + * + * Read out of `cmd_ACE_RUN_MODE_SWITCH` and the `SET_ACE_MODE` macro, and measured on + * 2026-09-01 by running the live pair on a machine with filament in three heads: + * + * multi <-> head **live.** Both are the ACE file variant, so the switch is a + * SAVE_VARIABLE and a re-read; the console says "No reboot needed." + * and `ace.mode` has already moved by the next read. Filament may + * stay loaded - the macro's `needs_unload` guard is only on the + * other transition. + * normal <-> either **a restart, reported as a failure.** It runs `ace_mode_switch.sh` + * to swap `filament_feed.py`, `extruder.py` and + * `filament_switch_sensor.py`, then raises + * `gcmd.error('... Please reboot the printer to activate!')` and a + * `RAISE_EXCEPTION ID=6666`. So a SUCCESSFUL mode change arrives at + * the page as a rejected `sw_SendGCodes`. It is also refused outright + * when any head holds filament, and says so only on the console. + * + * This is the one control on the panel whose success looks exactly like a failure, which + * is why what it needs is a value rather than a branch buried in the caller. + */ +export function aceModeChange(from, to) { + const restart = from !== to && (from === 'normal' || to === 'normal'); + return { live: !restart, needsRestart: restart, needsEmpty: restart }; +} + +/** + * The mode a restart would land in, when the machine is not in it yet. + * + * Null whenever the two agree, which is every ordinary moment - so a caller can ask this + * without first asking whether a switch has been attempted. + */ +export function acePendingMode(ace) { + const saved = ace && ace.savedMode; + return (saved && ace.mode && saved !== ace.mode) ? saved : null; +} + +/** + * What a head may be told to use, in the mode the machine is actually in. + * + * Not one list with everything greyed. `ACE_SET_HEAD_FEEDER` and `ACE_SET_HEAD_ACE` are + * documented "head mode only" and the plugin means it - `head_is_feeder()` returns False + * outright otherwise, so setting it changes nothing anyone can see. But + * `ACE_SET_HEAD_MANUAL` is legal in **every** mode, and the panel used to disable the whole + * control outside head mode, which took hand-feeding away in the two modes where it still + * works. + * + * In multi the lane is plumbing rather than a choice, so the list is the lane or the hand + * - and the lane option names every bay in it, because with a splitter on the head slot + * `i` of EVERY unit reaches it. "Bay A1" over a card drawing A1 *and* B1 named one bay of + * a set. + */ +export function aceSourceOptions(ace, head) { + const manual = { value: 'manual', label: 'Hand-fed' }; + const feeder = { value: 'feeder', label: 'Default feeder' }; + if (!ace || !ace.present) return [feeder, manual]; + if (ace.mode === 'head') { + return [feeder] + .concat((ace.units || []).map((u) => ({ value: `ace:${u.index}`, label: `ACE ${u.id}` }))) + .concat([manual]); + } + if (ace.mode === 'normal') return [feeder, manual]; + return [{ value: 'lane', label: aceLaneLabel(ace, head) }, manual]; +} + +/** Every bay plumbed to one head in multi: slot `head` of each unit. */ +export function aceLaneBays(ace, head) { + if (!ace || ace.mode === 'head') return []; + return (ace.units || []).map((u) => ({ unit: u, slot: head })); +} + +export function aceLaneLabel(ace, head) { + const ids = (ace.units || []).map((u) => aceBayAddr(u.index, head)); + if (!ids.length) return `Lane ${head + 1}`; + return ids.length > 2 ? `Bays ${ids[0]}…${ids[ids.length - 1]}` + : `Bay ${ids.join(' · ')}`; +} + +export function parseAceOverrides(raw) { + const map = (raw && typeof raw === 'object' && raw.overrides) ? raw.overrides : raw; + return (map && typeof map === 'object' && !Array.isArray(map)) ? map : null; +} + +/** + * What is actually in one unit's bays. + * + * multiACE's precedence, kept verbatim: **rfid, then the override, then derived.** A tag + * is the hardware's own answer and beats a name someone typed against the bay; both beat + * an identity inferred from what happens to be loaded in the head. Those are multiACE's + * own words (`rfid | override | derived`) and using the same ones is what stops the two + * vocabularies drifting apart. + * + * Pure, and returns a new array rather than touching the unit: with no store there is + * nothing to merge and the bays are handed straight back, which is the same answer as a + * printer that has no multiACE web service at all. + */ +export function mergeAceBays(unit, overrides) { + if (!unit || !overrides) return (unit && unit.bays) || []; + return unit.bays.map((b) => { + if (!b.occupied || b.source === 'rfid') return b; + const o = overrides[`${unit.index}_${b.index}`]; + const material = (o && typeof o === 'object' && typeof o.material === 'string') + ? o.material.trim() : ''; + if (!material) return b; + return Object.assign({}, b, { + known: true, + material, + subType: ((o.subtype || '').trim()) || b.subType, + vendor: ((o.brand || '').trim()) || b.vendor, + color: o.color || b.color, + source: 'override', + }); + }); +} + +function numOrNull(v) { const n = Number(v); return Number.isFinite(n) ? n : null; } + +/* ---- the `ace` object, unpacked ------------------------------------- */ + +/** + * `head_manual`, `head_feeder`, `head_ace` and `head_source` are maps keyed by head + * index. multiACE builds them as dicts with integer keys, which JSON carries as + * strings, and a Klipper object that happened to be a list would read the same way - + * so both are accepted rather than one assumed. + */ +function mapAt(m, i) { + if (!m) return undefined; + if (Array.isArray(m)) return m[i]; + if (typeof m !== 'object') return undefined; + return m[i] !== undefined ? m[i] : m[String(i)]; +} + +/** + * What feeds one head — and the resolution order is the MODE's, not one order for all + * three. + * + * **`ace_heads` is the machine's own answer to "is this head ACE-driven"**, computed live + * from the mode by `head_uses_ace()` and sitting in the object all along. This function + * used to rebuild it, and rebuilt it wrong: it tested `head_feeder` first in every mode, + * and the plugin consults that map ONLY in head mode — `head_is_feeder()` returns False + * outright otherwise. Measured 2026-09-01 by switching a live machine to `multi` and back: + * `head_feeder` reads `{0,1,2 true, 3 false}` in BOTH modes, left untouched by the switch, + * so in multi the panel drew three stock feeders on a machine whose every head is + * ACE-driven. Third time in five rounds the panel re-derived something the machine states. + * + * `ace_heads` says WHETHER, not WHICH, so the mode still decides the second half: + * + * head ONE head is on the cabinet and all four of its bays feed it — a hub. The + * wiring is `head_ace`, and `head_source[i].slot` is the bay doing it. + * multi EVERY head is on a cabinet and bay i is plumbed to head i — four parallel + * lanes. `cmd_ACE_LOAD_HEAD`'s non-head branch is `slot = gcmd.get_int('SLOT', + * head)` with `ace_index` defaulting to `self._active_device_index`, so the lane + * is the head index and the unit is the ACTIVE one until a load records + * otherwise. `head_ace` is not consulted at all — it reads `{0:0, 1:1, 2:2, 3:0}` + * on a one-unit machine, naming units that do not exist. + * normal no head is on a cabinet, `ace_heads` is empty, and every head is a feeder head. + * + * `head_manual` is still asked separately and first: a manual head is simply ABSENT from + * `ace_heads` (`head_uses_ace` returns False for one), which cannot be told apart from a + * feeder head without asking. + */ +function headSource(o, i, unitCount, activeUnit) { + const none = { source: 'feeder', unitIndex: null, unitId: null, bay: null, lane: null }; + if (mapAt(o.head_manual, i)) { + return { source: 'manual', unitIndex: null, unitId: null, bay: null, lane: null }; + } + const mode = nonEmpty(o.mode); + const listed = Array.isArray(o.ace_heads) ? o.ace_heads.map(Number) : null; + /* + * The machine's list where it publishes one - except in `normal`, where the mode is the + * outer authority and the list is not. + * + * `head_uses_ace()` has no branch for normal: it returns False for a manual head, tests + * `head_feeder` in head mode, and otherwise returns **True**. So a normal-mode machine + * publishes `ace_heads: [0,1,2,3]` - every head ACE-driven in the mode whose whole + * definition is that none of them is. multiACE's own config comment settles the meaning + * ("0 ACE heads = normal"), the mode is what the switch actually changes, and normal has + * never been observed on hardware - so it is read from the mode and the list is not + * trusted there. Where the plugin publishes no list at all, its own one-liner is used, so + * an older multiACE still resolves rather than reading as all-feeders. + */ + const onAce = mode === 'normal' ? false + : listed ? listed.indexOf(i) >= 0 + : mode === 'head' ? !mapAt(o.head_feeder, i) + : true; + if (!onAce || !unitCount) return none; + + const from = mapAt(o.head_source, i); + const slot = from && Number.isInteger(Number(from.slot)) ? Number(from.slot) : null; + const fromUnit = from && Number.isInteger(Number(from.ace_index)) ? Number(from.ace_index) + : null; + let u; + if (mode === 'head') { + u = Number(mapAt(o.head_ace, i)); + if (!Number.isInteger(u) || u < 0 || u >= unitCount) return none; + } else { + // The unit a load would actually go to. Where nothing has been recorded that is the + // active one, which is what the macro defaults `ACE=` to — and it is why + // `active_device` starts mattering outside head mode and does not in it. + u = fromUnit == null ? Number(activeUnit) : fromUnit; + if (!Number.isInteger(u) || u < 0 || u >= unitCount) u = 0; + } + // Which bay is PLUMBED to this head, as against which one is feeding it. Only multi has + // one: in head mode any of the unit's four may feed, so there is nothing to name. + const lane = mode === 'head' ? null : i; + return { source: 'ace', unitIndex: u, unitId: aceUnitId(u), bay: slot, lane }; +} + +/** + * ACE Pro and ACE 2 Pro are different machines. + * + * The unit names itself - `model: "ACE 2 Pro"`, measured 2026-08-26 - so that is what is + * used. `protocol` is the fallback for a firmware that does not, and it is the field the + * distinction actually rides on: v2 is the ACE 2 Pro, v1 the ACE Pro. + */ +function aceModel(u) { + const named = nonEmpty(u.model); + if (named) return named; + const p = String(u.protocol || '').toLowerCase(); + if (p === 'v2') return 'ACE 2 Pro'; + if (p === 'v1') return 'ACE Pro'; + return 'ACE'; +} + +/** + * A colour as the ACE reports it: `[r, g, b]` in the raw slots, `"632C2C"` in + * `head_source`. Both are handed on as something cssColor() will take. + */ +function aceColor(c) { + if (Array.isArray(c)) { + if (c.length < 3 || c.every((v) => !Number(v))) return null; // [0,0,0] is "none" + return '#' + c.slice(0, 3) + .map((v) => Math.max(0, Math.min(255, Number(v) | 0)).toString(16).padStart(2, '0')) + .join('').toUpperCase(); + } + if (typeof c === 'string' && c.trim()) return c.trim(); + return null; +} + +const nonEmpty = (v) => (typeof v === 'string' && v.trim() && v.trim() !== 'NONE' + ? v.trim() : null); + +/** What `head_source[n]` says is loaded at a head - or null when it says nothing. */ +function spoolOf(from) { + if (!from || typeof from !== 'object') return null; + const material = nonEmpty(from.type) || nonEmpty(from.material); + const color = aceColor(from.color); + if (!material && !color) return null; + return { + material, + subType: nonEmpty(from.subtype) || nonEmpty(from.sub_type), + vendor: nonEmpty(from.brand) || nonEmpty(from.vendor), + color, + rfid: Number(from.rfid) || 0, + }; +} + +/** + * One unit, its four bays, and its dryer. + * + * A bay is occupied when the hardware says so - `gate_status[k]` is the gate sensor and + * `slots[k].state` is multiACE's naming of the same thing - and *named* only when + * something carries an identity for it. Those are two different questions and the panel + * draws the difference, because on this machine the answer to the second is almost + * always no: the spools have no tags and multiACE keeps typed-in names in its own store, + * behind CORS. Occupied-and-unnamed is drawn as exactly that rather than as empty. + */ +function aceUnit(u, index) { + const gates = Array.isArray(u.gate_status) ? u.gate_status : []; + const rawSlots = Array.isArray(u.slots) ? u.slots : []; + const bays = []; + for (let k = 0; k < ACE_SLOTS; k += 1) { + const s = rawSlots[k] || {}; + const state = nonEmpty(s.state) || nonEmpty(s.status); + const gate = gates[k]; + const occupied = gate !== undefined ? Number(gate) !== 0 : (!!state && state !== 'empty'); + const material = nonEmpty(s.material) || nonEmpty(s.type); + const rfid = Number(s.rfid) || 0; + bays.push({ + index: k, + addr: aceBayAddr(index, k), + occupied, + known: !!material, + material, + subType: nonEmpty(s.subtype) || nonEmpty(s.sub_type), + vendor: nonEmpty(s.brand) || nonEmpty(s.vendor), + sku: nonEmpty(s.sku), + color: aceColor(s.color !== undefined ? s.color : s.color_rgb), + rfid, + // Where the name came from decides what may be done to it: a tag can be read and + // not written, so it gets an eye where a typed value gets a pencil. + source: rfid ? 'rfid' : (material ? 'typed' : (occupied ? 'unknown' : 'empty')), + }); + } + return { + index, + id: aceUnitId(index), + model: aceModel(u), + firmware: nonEmpty(u.firmware) || nonEmpty(u.fw) || nonEmpty(u.version), + connected: u.connected !== false, + humidity: numOrNull(u.humidity), + temperature: numOrNull(u.temp !== undefined ? u.temp : u.temperature), + dryer: aceDryer(u.dryer_status || u.dryer), + // Its own object on the unit, not part of dryer_status - measured + // `{enabled, rh_start, rh_end, temp, master, add_time}`. rh_start is the humidity it + // starts at and rh_end the one it stops at, so the panel's "above" is rh_start. + autoDry: { + enabled: !!(u.auto_dry || {}).enabled, + running: !!u.auto_dry_running, + rhStart: numOrNull((u.auto_dry || {}).rh_start), + rhEnd: numOrNull((u.auto_dry || {}).rh_end), + temp: numOrNull((u.auto_dry || {}).temp), + }, + bays, + }; +} + +/** + * The dryer. + * + * Every field below was measured on 811002511261022618B3 on 2026-08-26, by running the + * dryer at its mildest setting for ten seconds - which is the only way to see any of it, + * and the reason two of the three guesses this replaced were wrong: + * + * {"status": "stop", "target_temp": 0, "duration": 0, "remain_time": 0} + * {"status": "keeping", "target_temp": 45, "duration": 180, "remain_time": 179} + * + * `duration` and `remain_time` are **seconds**: `DURATION=3` gave 180 and `DURATION=240` + * gave 14400, which also settles that ACE_DRY's own argument is in **minutes**. The + * running word is `keeping`, not `running` or `drying`. + * + * The word sets below are still open at one end on purpose. `stop` and `keeping` are the + * two that have been seen; a firmware with a third word for "heating up to target" would + * otherwise read as idle, so anything that is neither empty nor a known stopped word + * counts as running. + */ +const DRYER_STOPPED = new Set(['', 'stop', 'stopped', 'idle', 'off', 'none']); + +function aceDryer(d) { + const off = { running: false, target: null, totalMin: null, remainingMin: null, + doneMin: null, pct: null, status: null }; + if (!d || typeof d !== 'object') return off; + const status = String(d.status || d.state || '').toLowerCase(); + const running = d.running === true || (!!status && !DRYER_STOPPED.has(status)); + const target = numOrNull(d.target_temp !== undefined ? d.target_temp + : (d.target !== undefined ? d.target : d.temp)); + const totalMin = secondsToMin(d.duration !== undefined ? d.duration : d.total); + const remainingMin = secondsToMin(d.remain_time !== undefined ? d.remain_time + : (d.remaining !== undefined ? d.remaining : d.left)); + const doneMin = (totalMin != null && remainingMin != null) + ? Math.max(0, totalMin - remainingMin) : null; + return { + running, + status: status || null, + target, + totalMin, + remainingMin, + doneMin, + pct: (totalMin && doneMin != null) ? Math.round((doneMin / totalMin) * 100) : null, + }; +} + +/** Seconds on the wire, minutes on the panel. A dash for anything unreadable. */ +function secondsToMin(v) { + const n = Number(v); + if (!Number.isFinite(n) || n < 0) return null; + return Math.round(n / 60); +} + diff --git a/resources/web/shared/js/protocol.js b/resources/web/shared/js/protocol.js new file mode 100644 index 00000000000..486c7da27bb --- /dev/null +++ b/resources/web/shared/js/protocol.js @@ -0,0 +1,725 @@ +/* + * protocol.js - constants recovered by reverse engineering the shipped Flutter + * bundle (orca 2.3.26, build 20260813142841). + * + * SHARED between both reconstructed surfaces: + * resources/web/device_page/ the Device tab (?path=2) + * resources/web/print_processing/ the print popup (?path=4 / ?path=5) + * + * Both surfaces are the same Flutter app at different routes, they reach Orca + * over the same bridge, and they read the same machine-state objects - so the + * command names, the state model and the fault decoder are common to both. + * Anything that is genuinely one-surface-only is marked as such below. + * + * Every value here is traceable to docs/u1-webui/. Nothing is invented. + */ +'use strict'; + +/* ------------------------------------------------------------------ * + * SSWCP bridge commands (docs: 02-bridge-sswcp/02-command-reference.md) + * ------------------------------------------------------------------ */ +export const CMD = { + // connection / identity + GET_CONNECTED_MACHINE: 'sw_GetConnectedMachine', // ONLY returns a device whose + // connected flag is true; empty + // otherwise. Use the list below + // to show saved-but-idle machines. + GET_LOCAL_DEVICES: 'sw_GetLocalDevices', // -> bare ARRAY of DeviceInfo + SUBSCRIBE_LOCAL_DEVICES: 'sw_SubscribeLocalDevices', // pushes the same bare array + + // ---- bringing a session up --------------------------------------------- + // The page drives the transport; Orca only owns the socket. See + // docs/u1-webui/02-device-page/06-connection.md + FILE_LOG: 'sw_FileLog', // { level, content } -> Orca's own log file + GET_USER_LOGIN_STATE: 'sw_GetUserLoginState', // -> { status, userid, nickname, ... } + GET_PINCODE: 'sw_GetPincode', // { ip, userid, nickname, port=1884 } + CREATE_MQTT_CLIENT: 'sw_create_mqtt_client', // { server_address, clientId, ca?, cert?, + // key?, username?, password?, + // clean_session? } -> { type, id } + MQTT_CONNECT: 'sw_mqtt_connect', // { id } + MQTT_DISCONNECT: 'sw_mqtt_disconnect', // { id } + MQTT_SUBSCRIBE: 'sw_mqtt_subscribe', // { id, topic, qos } + event_id; + // pushes { topic, data } where + // data is the raw payload string + MQTT_PUBLISH: 'sw_mqtt_publish', // { id, topic, qos, payload } + MQTT_SET_ENGINE: 'sw_mqtt_set_engine', // { engine_id, ip, port, sn, code?, ca?, + // cert?, key?, user?, password?, + // need_reload? } + GET_MACHINE_SYSTEM_INFO: 'sw_GetMachineSystemInfo', + GET_PRINTER_INFO: 'sw_GetPrinterInfo', + GET_SOFTWARE_INFO: 'sw_GetSoftwareInfo', + + // state + SET_SUBSCRIBE_FILTER: 'sw_SetSubscribeFilter', // params: { objects } + SUBSCRIBE_MACHINE_STATE: 'sw_SubscribeMachineState', // needs event_id, no params + GET_MACHINE_STATE: 'sw_GetMachineState', // params: { objects } + STOP_SUBSCRIPTION: 'sw_StopMachineStateSubscription', + EXCEPTION_QUERY: 'sw_exception_query', + + // print job + PRINT_PAUSE: 'sw_MachinePrintPause', + PRINT_RESUME: 'sw_MachinePrintResume', + PRINT_CANCEL: 'sw_MachinePrintCancel', + PRINT_START: 'sw_MachinePrintStart', + + // hardware control + CONTROL_BED_TEMP: 'sw_ControlBedTemp', // { temp } + CONTROL_EXTRUDER_TEMP: 'sw_ControlExtruderTemp', // { temp, index, map } + CONTROL_MAIN_FAN: 'sw_ControlMainFan', // { speed } + CONTROL_GENERIC_FAN: 'sw_ControlGenericFan', // { name, speed } + CONTROL_LED: 'sw_ControlLed', // { name, white } + CONTROL_PRINT_SPEED: 'sw_ControlPrintSpeed', // { percentage } + CONTROL_PURIFIER: 'sw_ControlPurifier', + SEND_GCODES: 'sw_SendGCodes', // { script } + + // files on the machine + FILES_ROOTS: 'sw_MachineFilesRoots', + FILE_LIST_PAGE: 'sw_GetFileListPage', // { root, page_number, files_per_page } + FILES_GET_DIRECTORY: 'sw_MachineFilesGetDirectory', // { path, extended } + FILES_METADATA: 'sw_MachineFilesMetadata', // { filename } + FILES_THUMBNAILS: 'sw_MachineFilesThumbnails', // { filename } + DELETE_MACHINE_FILE: 'sw_DeleteMachineFile', // { path } + + // camera + timelapse + CAMERA_START: 'sw_CameraStartMonitor', // { domain, interval, expect_pw } + // -> { state, url }; see CAMERA_* below + CAMERA_STOP: 'sw_CameraStopMonitor', // { domain } + TIMELAPSE_LIST: 'sw_GetCameraTimelapseInstance', // { page_index, page_rows, thumbnail_direct } + TIMELAPSE_DELETE: 'sw_DeleteCameraTimelapse', + + // completed jobs, from Moonraker's history store + PRINT_HISTORY: 'sw_GetPrintHistory', // { limit, start, order } + + // saved-device management + RENAME_DEVICE: 'sw_RenameDevice', // { dev_id, dev_name } + DELETE_DEVICES: 'sw_DeleteDevices', // { dev_ids } + SET_DEVICE_NAME: 'sw_SetDeviceName', // { name } - renames the machine itself + STORAGE_SPACE: 'sw_GetDeviceDataStorageSpace', + SYSTEM_DEVICE_INFO: 'sw_SystemGetDeviceInfo', + DEFECT_DETECTION: 'sw_PrinterDefectDetection', + DEFECT_CONFIG: 'sw_DefectDetactionConfig', // note the vendor's spelling + + // diagnostics / system + EXCEPTION_QUERY: 'sw_exception_query', // -> the active fault, if any + MACHINE_OBJECTS: 'sw_GetMachineObjects', // what the printer exposes + HEARTBEAT: 'sw_MachineHeartbeat', + BEDMESH_ABORT: 'sw_BedMesh_AbortProbeMesh', + + // file transfer + FILE_THUMBNAILS: 'sw_MachineFilesThumbnails', // { filename } + FILE_THUMBS_B64: 'sw_FilesThumbnailsBase64', // { path } + DOWNLOAD_MACHINE_FILE: 'sw_DownloadMachineFile', // { filename, url } + FILE_STATUS: 'sw_FileGetStatus', + + // discovery / adding a machine + FIND_START: 'sw_StartMachineFind', // { last_time } - a SUBSCRIPTION + FIND_STOP: 'sw_StopMachineFind', + ADD_DEVICE: 'sw_AddDevice', // opens Orca's own dialog + CONNECT_OTHER: 'sw_ConnectOtherMachine', // opens Orca's own dialog + + // ---- print-processing popup only (?path=4 / ?path=5) ------------------ + GET_ACTIVE_FILE: 'sw_GetActiveFile', + GET_PRINT_LEGAL: 'sw_GetPrintLegal', // { connected_model } -> { legal, preset_model } + GET_PRINT_ZIP: 'sw_GetPrintZip', // -> { name, content } + GET_FILE_FILAMENT_MAPPING: 'sw_GetFileFilamentMapping', // { filename } + UPDATE_MACHINE_FILAMENT_INFO: 'sw_UpdateMachineFilamentInfo', + SET_FILAMENT_MAPPING_COMPLETE: 'sw_SetFilamentMappingComplete', // { status } + FINISH_FILAMENT_MAPPING: 'sw_FinishFilamentMapping', + FINISH_PREPRINT: 'sw_FinishPreprint', // { status } + START_LOCAL_PRINT: 'sw_StartLocalPrint', + START_CLOUD_PRINT: 'sw_StartCloudPrint', +}; + +/** + * The two outcome strings sw_SetFilamentMappingComplete accepts. + * + * Anything else is treated by the host as an error and raises a native + * "setting failed" MessageDialog instead of closing the popup. + * See docs/u1-webui/03-print-processing/02-lifecycle.md + */ +export const MAPPING_STATUS = { SUCCESS: 'success', CANCELED: 'canceled' }; + +/* ------------------------------------------------------------------ * + * Machine state model (docs: 03-printer-protocol/03-state-model.md) + * Recovered verbatim from the bundle's subscription list + field filter. + * `null` means "every field of this object". + * ------------------------------------------------------------------ */ +export const EXTRUDER_FIELDS = [ + 'temperature', 'target', 'power', 'can_extrude', + 'pressure_advance', 'smooth_time', 'state', 'nozzle_diameter', +]; + +export const SUBSCRIBE_OBJECTS = { + 'extruder': EXTRUDER_FIELDS, + 'extruder1': EXTRUDER_FIELDS, + 'extruder2': EXTRUDER_FIELDS, + 'extruder3': EXTRUDER_FIELDS, + 'heater_bed': ['temperature', 'target'], + 'virtual_sdcard': ['progress', 'file_position', 'is_active', 'file_size', 'file_path'], + 'print_stats': ['filename', 'state', 'total_duration', 'print_duration', + 'filament_used', 'message', 'info'], + 'display_status': ['progress', 'message'], + 'gcode_move': ['speed_factor', 'speed', 'extrude_factor'], + 'webhooks': ['state', 'state_message'], + 'idle_timeout': null, + 'job': null, + 'file_metadata': null, + 'led cavity_led': ['color_data'], + 'fan': ['speed'], + 'fan_generic cavity_fan': ['speed'], + 'print_task_config': ['filament_vendor', 'filament_type', 'filament_sku', + 'filament_official', 'filament_sub_type', 'filament_color', + 'filament_color_rgba', 'extruder_map_table', 'extruders_used', + 'auto_bed_leveling', 'flow_calibrate', 'flow_calib_extruders', + 'shaper_calibrate', 'save', 'time_lapse_camera', + 'auto_replenish_filament', 'can_auto_replenish', + 'auto_replenish_index', 'filament_edit', 'filament_exist', + 'filament_soft', 'filament_color_multi', 'extruders_replenished'], + 'motion_report': null, + 'purifier': ['power_detected', 'power_det_value', 'mode', + 'exhaust_fan', 'inner_fan'], + 'filament_feed left': null, + 'filament_feed right': null, + 'machine_state_manager': ['main_state', 'action_code'], + 'filament_detect': null, + 'defect_detection': null, +}; + +/* + * DeviceInfo, as Orca stores it in app_config and hands back over the bridge. + * Field names are snake_case and come straight from the C++ struct + * (src/libslic3r/AppConfig.hpp) - NOT the camelCase a JS client might assume. + */ +export const DEVICE = { + NAME: 'dev_name', + MODEL: 'model_name', + SN: 'sn', + IP: 'ip', + CONNECTED: 'connected', + ID: 'dev_id', + PRESET: 'preset_name', + NOZZLES: 'nozzle_sizes', + // TLS material, empty until the machine has been paired + CA: 'ca', + CERT: 'cert', + KEY: 'key', + CLIENT_ID: 'clientId', + USER: 'user', + PASSWORD: 'password', + PORT: 'port', + LINK_MODE: 'link_mode', +}; + +/** The port the printer listens on for plain-MQTT pairing. */ +export const PAIR_PORT = 1884; + +/** + * The LAN authorisation code. + * + * NOT a user-entered PIN for an already-bound printer: it is a fixed literal in + * the shipped bundle (18 occurrences, alongside port 1884), and it is the value + * the real Device page uses. A captured connect from Orca subscribes + * `12345678/config/response` and `12345678/config/notification`, which are + * exactly the topics Moonraker_Mqtt::ask_for_tls_info() derives from + * `/config/...`. + * + * Using it is what lets a bound machine reconnect with no human in the loop. + * A PIN is only needed when the printer does not already trust our client id. + */ +export const LAN_AUTH_CODE = '12345678'; + +/** The shipped page sends this on every create; keep parity. */ +export const MQTT_KEEPALIVE = 30; + +/** + * The camera does not stream, and it does not push frames. + * + * Measured against a U1 (2026-08-24), not inferred: `camera.start_monitor` answers + * `{ state: 'success', url: '/files/camera/monitor.jpg' }` and then nothing else + * arrives over MQTT. The printer rewrites that one file at `interval` seconds - which + * is what the flood of `notify_filelist_changed` for `monitor.jpg` is - and the frame + * is fetched over HTTP. + * + * The `url` it hands back is relative to the printer's own web UI on :80, which serves + * that path as its single-page shell (2,934 bytes of text/html, not an image). The bytes + * live on Moonraker's file server instead, under the `timelapse` root that + * `notify_filelist_changed` names. Verified by fetching it: 96,001 bytes, JPEG SOI. + * + * See 05-printer-protocol/06-mqtt-topics.md. + */ +export const CAMERA_DOMAIN = 'lan'; // '' is refused: -32000 "Start monitor failed" +export const MOONRAKER_HTTP_PORT = 7125; + +/** + * Where a recording actually plays from. + * + * An instance carries `video_local_url_suffix` - `/files/camera/.mp4` - which is + * relative to Moonraker's file server, NOT to the printer's web UI. Measured on the + * machine: + * + * 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: port 80 answers any path with its + * own shell, so a player pointed there fails with nothing useful to say. + */ +export function timelapseUrl(device, instance) { + const ip = device && device[DEVICE.IP]; + const suffix = instance && (instance.video_local_url_suffix || instance.video_url); + if (!ip || !suffix) return null; + if (/^https?:/i.test(suffix)) return suffix; + return `http://${ip}:${MOONRAKER_HTTP_PORT}/server${suffix}`; +} + +/** + * A print job's own thumbnail, on the printer's file server. + * + * `server.history.list` carries `metadata.thumbnails` as + * `[{width, height, size, relative_path}]` - **paths, no bytes**, the same trap the + * file browser hit. The bytes are fetched over HTTP from Moonraker, the way the camera + * frame is. `relative_path` is relative to the gcode's own directory, so a job at the + * gcodes root resolves under `gcodes/`. + * + * Returns null when the file is gone (`exists: false`), because its .thumbs entry went + * with it and an pointed at a 404 renders as a broken image. + */ +export function jobThumbUrl(device, job, want = 300) { + const ip = device && device[DEVICE.IP]; + const thumbs = job && job.metadata && job.metadata.thumbnails; + if (!ip || job.exists === false || !Array.isArray(thumbs) || !thumbs.length) return null; + // The largest that is not larger than asked for, else the smallest there is. + const fit = thumbs.filter((t) => Number(t.width) <= want) + .sort((a, b) => Number(b.width) - Number(a.width))[0] + || thumbs.slice().sort((a, b) => Number(a.width) - Number(b.width))[0]; + const rel = fit && fit.relative_path; + if (!rel) return null; + const dir = String(job.filename || '').split('/').slice(0, -1).join('/'); + const path = [dir, rel].filter(Boolean).join('/'); + return `http://${ip}:${MOONRAKER_HTTP_PORT}/server/files/gcodes/` + + path.split('/').map(encodeURIComponent).join('/'); +} +export const CAMERA_FRAME_ROOT = 'timelapse'; +export const CAMERA_FRAME_FILE = 'monitor.jpg'; +export const CAMERA_INTERVAL = 2; // seconds; what the shipped page asks for + +/* ------------------------------------------------------------------ * + * Cameras, beyond the one monitor file + * + * The stock path above is half a frame per second: the printer rewrites ONE file every + * CAMERA_INTERVAL and the page fetches it. A U1 running paxx12's Extended Firmware + * serves the same sensor as fast as the client will take it, serves a second USB camera + * beside it, and offers two streaming transports on top. + * + * Measured on 811002511261022618B3, 2026-08-25, and none of this is inferred: + * + * GET :7125/server/webcams/list -> 4 entries, and only two are cameras: + * case webrtc-camerastreamer /webcam/snapshot.jpg 1920x1080 226 KB 81 ms + * usb webrtc-camerastreamer /webcam2/snapshot.jpg 1280x720 87 KB 44 ms + * gui iframe /screen/snapshot 480x320 710 B <- the + * printer's own touchscreen, not a camera + * multiACE iframe (no snapshot_url) <- a web panel entirely + * + * So the list is Moonraker's and anything may register in it. An entry is a camera when + * it has a snapshot_url; `gui` is offered last and labelled, and multiACE never appears. + * + * Chained in WebKitGTK - the engine Orca renders with - the case camera reached + * **14.0 fps**, which is 28x the monitor file. The webcam entries report + * `target_fps: 15`, so that IS the printer's cap and asking for 30 buys nothing. + * ------------------------------------------------------------------ */ + +/** Moonraker's own camera list. It sends CORS headers; nginx on :80 does not. */ +export const MOONRAKER_WEBCAMS = '/server/webcams/list'; + +/** + * How a frame gets here. Ordered best-first: `pickTransport` walks this and takes the + * first the engine can actually do. + * + * The two streaming transports are **dead in WebKitGTK** and it is not close - measured + * in the engine rather than assumed: + * + * canPlayType('video/mp4; codecs="avc1.42E01E") -> "" (empty = no) + * MediaSource.isTypeSupported(same) -> false (VP8 is true, so MSE + * is fine; the codec + * is what is missing) + * typeof RTCPeerConnection -> "undefined" + * + * Windows (WebView2) and macOS (WKWebView) ship both, so this is a per-platform question + * and never a global answer. It is why AUTO probes rather than picking. + */ +export const CAMERA_TRANSPORT = { + AUTO: 'auto', + WEBRTC: 'webrtc', // POST /webcam/webrtc -> RTCPeerConnection + H264: 'h264', // GET /webcam/stream.h264 -> MSE + SNAPSHOT: 'snapshot', // GET /webcam/snapshot.jpg, re-pointed on load + MONITOR: 'monitor', // camera.start_monitor -> timelapse/monitor.jpg +}; + +/** Best first. AUTO is not in it: AUTO is the act of walking it. */ +export const CAMERA_TRANSPORT_ORDER = [ + CAMERA_TRANSPORT.WEBRTC, CAMERA_TRANSPORT.H264, + CAMERA_TRANSPORT.SNAPSHOT, CAMERA_TRANSPORT.MONITOR, +]; + +/** + * What each one is called and why it might not be offered. + * + * The name IS the transport, deliberately. Naming these Smooth/Sharp/Saver reads better + * until one is greyed out, at which point "not on Linux" is a worse explanation than + * "this engine has no H.264 decoder" for someone who has to act on it. + */ +export const CAMERA_TRANSPORT_TEXT = { + [CAMERA_TRANSPORT.AUTO]: { name: 'Auto', hint: 'the best this computer can decode' }, + [CAMERA_TRANSPORT.WEBRTC]: { name: 'WebRTC', hint: '/webcam/webrtc \u00b7 30 fps', + absent: 'this engine has no WebRTC' }, + [CAMERA_TRANSPORT.H264]: { name: 'H.264 stream', hint: '/webcam/stream.h264 \u00b7 30 fps', + absent: 'this engine has no H.264 decoder' }, + [CAMERA_TRANSPORT.SNAPSHOT]: { name: 'Direct snapshot', hint: '/webcam/snapshot.jpg \u00b7 up to 15 fps', + absent: 'this printer has no camera service' }, + [CAMERA_TRANSPORT.MONITOR]: { name: 'Monitor file', hint: 'camera.start_monitor \u00b7 0.5 fps' }, +}; + +/** How many pictures at once. */ +export const CAMERA_VIEW = { SINGLE: 'single', SPLIT: 'split', GRID: 'grid', PIP: 'pip' }; + +/** How many tiles each view draws. */ +export const CAMERA_VIEW_TILES = { + [CAMERA_VIEW.SINGLE]: 1, [CAMERA_VIEW.SPLIT]: 2, + [CAMERA_VIEW.GRID]: 4, [CAMERA_VIEW.PIP]: 2, +}; + +/** + * Frames per second the page asks for. + * + * The scale stops at 15 because the printer does: every webcam entry reports + * `target_fps: 15`, and the 14.0 measured in the engine is that cap rather than a client + * limit. Offering 30 would offer nothing. + */ +export const CAMERA_FPS_CHOICES = [1, 5, 10, 15]; +export const CAMERA_FPS_DEFAULT = 15; + +/** + * What an unfocused tile drops to. + * + * A grid is one poll per tile - there is no multiplexed stream to share - so three tiles + * at 15 fps is 4.7 MB/s, dominated entirely by the case camera's 226 KB frame (the USB + * camera is 87 KB and the touchscreen is 710 bytes). Polling only the focused tile fast + * brings that to 3.5 MB/s, and it is one number rather than a policy. + */ +export const CAMERA_FPS_UNFOCUSED = 1; + +/** Moonraker registers non-cameras in the same list. A camera has a still to fetch. */ +export function isCamera(w) { + return !!(w && w.enabled !== false && w.snapshot_url); +} + +/** + * The touchscreen is in the webcam list and is not a camera. + * + * It is genuinely useful as a tile - it shows what the printer thinks it is doing - so + * it is offered, last, under its own name rather than sitting in the camera list + * unexplained. + */ +export const CAMERA_SCREEN_NAME = 'gui'; +export const CAMERA_LABELS = { case: 'Case', usb: 'USB', gui: 'Screen' }; +export function cameraLabel(w) { + const n = String((w && w.name) || ''); + return CAMERA_LABELS[n] || (n ? n.charAt(0).toUpperCase() + n.slice(1) : 'Camera'); +} + +/** + * A camera's still, absolute. + * + * `snapshot_url` is relative to the printer's web UI on **:80**, which is where it + * genuinely lives - unlike the monitor file, whose reply names a :80 path that answers + * with the SPA shell. nginx there sends no `Access-Control-Allow-Origin` at all, so an + * `` loads it (images are not CORS-restricted unless their pixels are read) and a + * `fetch()` of the identical URL fails "Load failed". Both measured. Display with + * ``; never fetch a frame. + */ +export function snapshotUrl(device, w) { + const ip = device && device[DEVICE.IP]; + const rel = w && w.snapshot_url; + if (!rel) return null; + // Absolute already, which is also how the simulator hands back a frame: a data: URI, + // the same shape the monitor reply uses. + if (/^(data:|blob:|https?:)/i.test(rel)) return rel; + if (!ip) return null; + return `http://${ip}${rel.startsWith('/') ? '' : '/'}${rel}`; +} + +/** Where the camera list is asked for. Moonraker, because :80 refuses cross-origin. */ +export function webcamListUrl(device) { + const ip = device && device[DEVICE.IP]; + return ip ? `http://${ip}:${MOONRAKER_HTTP_PORT}${MOONRAKER_WEBCAMS}` : null; +} + +/** + * What this engine can decode. Three one-line tests, run once. + * + * Deliberately not cached across reloads: it is a property of the running webview, and + * Orca's is WebKitGTK on Linux, WebView2 on Windows and WKWebView on macOS. A value + * remembered from one is wrong on the next. + */ +export function engineCaps() { + const mp4 = 'video/mp4; codecs="avc1.42E01E"'; + let h264 = false; + try { + h264 = (typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported(mp4)) + || document.createElement('video').canPlayType(mp4) !== ''; + } catch { h264 = false; } + return { h264, webrtc: typeof RTCPeerConnection !== 'undefined' }; +} + +/** + * The best transport that both ends can do. + * + * `direct` says the printer answered the webcam list with at least one camera, which is + * how the extended firmware is DETECTED rather than configured. Without it the only + * thing on offer is the monitor file, which is what every stock U1 has and what this + * page did before. + */ +export function pickTransport(caps, direct) { + return CAMERA_TRANSPORT_ORDER.find((t) => transportUsable(t, caps, direct)) + || CAMERA_TRANSPORT.MONITOR; +} + +export function transportUsable(t, caps, direct) { + if (t === CAMERA_TRANSPORT.MONITOR) return true; + if (!direct) return false; + if (t === CAMERA_TRANSPORT.WEBRTC) return !!(caps && caps.webrtc); + if (t === CAMERA_TRANSPORT.H264) return !!(caps && caps.h264); + return true; // SNAPSHOT, once there is a camera +} + +/** True when a saved device already carries the mTLS material to connect directly. */ +export function hasTlsMaterial(d) { + return !!(d && d[DEVICE.CA] && d[DEVICE.CERT] && d[DEVICE.KEY]); +} + +/** + * Normalise a device-list response. + * + * `sw_GetLocalDevices` replies with a bare array (`m_res_data = devices`), and so + * does the `sw_SubscribeLocalDevices` push. Accept the `{devices: [...]}` wrapper + * too, so a hand-written mock that guessed wrong still works. + */ +export function asDeviceList(data) { + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.devices)) return data.devices; + return []; +} + +/** A human label for a device, preferring the name the user gave it. */ +export function deviceLabel(d) { + if (!d) return 'Unconnected'; + return d[DEVICE.NAME] || d[DEVICE.MODEL] || d[DEVICE.SN] || 'Unknown device'; +} + +/** Klipper names the first toolhead `extruder`, not `extruder0`. */ +export const TOOLHEADS = ['extruder', 'extruder1', 'extruder2', 'extruder3']; + +/* + * `print_task_config` is the single biggest thing the two surfaces share. + * + * The Device tab READS it - to label each toolhead with the filament loaded. + * The print popup WRITES it - the filament mapping and the three preference + * toggles are edits to these same fields. Same object, different verb. + */ +/** + * Filament colour -> a CSS colour, from any of the three forms that reach us. + * + * Measured on a U1: `filament_color` is an **ARGB integer** (4294198070 = 0xFFF44336) + * and `filament_color_rgba` is hex **without a leading #** ("F44336FF", RRGGBBAA). The + * simulator used to send '#E03131FF', which is why assigning the value straight to + * `style.background` worked in tests and silently did nothing on hardware - an invalid + * CSS colour is dropped, not reported. + * + * Returns null when there is no usable colour, so callers can fall back deliberately + * rather than painting an empty string. + * + * **A fully transparent value is NOT black.** The alpha is carried in both forms and was + * being discarded, so `00000000` - which is the machine's way of saying *no colour* - + * came back `#000000` and got painted. multiACE writes exactly that: + * `_clear_filament_display()` sends + * `SET_PRINT_FILAMENT_CONFIG FILAMENT_TYPE="" FILAMENT_COLOR_RGBA=00000000 VENDOR=""` + * to every feeder head when the machine enters head mode, and the panel drew four black + * spools. Reported as "switching between ACE modes switches filaments, blacks them". + * + * Opaque black is `FF000000` as an ARGB integer and `000000FF` as RRGGBBAA, and both + * still come back `#000000` - it is only alpha ZERO that is an absence. + */ +export function cssColor(v) { + if (v == null) return null; + if (typeof v === 'number' && Number.isFinite(v)) { + // ARGB: the alpha rides in the top byte and the rest is plain RGB + const n = v >>> 0; + if ((n >>> 24) === 0) return null; // alpha 0 - an absence, not black + return '#' + (n & 0xFFFFFF).toString(16).padStart(6, '0').toUpperCase(); + } + const t = String(v).trim().replace(/^#/, ''); + if (/^[0-9a-f]{8}$/i.test(t)) { + return /^00$/i.test(t.slice(6)) ? null : '#' + t.slice(0, 6).toUpperCase(); + } + if (/^[0-9a-f]{6}$/i.test(t)) return '#' + t.slice(0, 6).toUpperCase(); + if (/^[0-9a-f]{3}$/i.test(t)) return '#' + t.replace(/./g, (c) => c + c).toUpperCase(); + return null; +} + +/** Is a CSS colour dark enough to need light text on top? */ +export function isDarkColor(v) { + const c = cssColor(v); + if (!c) return false; + const n = parseInt(c.slice(1), 16); + return (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) < 140; +} + +/* + * multiACE has a module of its own: shared/js/multiACE.js. + * + * Its macro names, unit letters, dryer presets, humidity buckets, override-store URL and + * state model were here and in state.js and in a view, which is three places to look for + * one subsystem - and is how the panel came to read bay identity from a source that does + * not carry it. Everything about the ACE is in that one file now, and it is what every + * part that touches an ACE calls. + */ + +/** + * purifier.mode is an INTEGER on the wire, not the 'inner'/'exhaust' strings the page + * used to send - which is why the status row showed a bare "0". + * + * Only `0` is measured (an idle U1 with no purifier attached: power_detected false). + * The names for 1 and 2 are inferred from the two modes the shipped page offers and are + * NOT verified - confirming them means changing the mode on a real machine, which is a + * physical action rather than a read. Treat the labels as provisional; the integer is + * what goes on the wire either way. + */ +export const PURIFIER_MODES = { 0: 'Off', 1: 'Recirculation Mode', 2: 'Exhaust Mode' }; + +/** + * The commands the PRINTER answers, as opposed to the ones Orca answers itself. + * + * It matters because their replies arrive shaped differently. A printer-backed handler + * routes the reply through `on_mqtt_msg_arrived`, which assigns the JSON-RPC envelope + * verbatim (`m_res_data = response`, SSWCP.cpp:1194) into `payload.data` (:946) - so the + * caller receives `{jsonrpc, result, id}` and the payload it wants is one level down. + * Everything else answers with its payload directly. + * + * Derived from SSWCP.cpp by finding every handler whose body reaches + * `on_mqtt_msg_arrived`, and re-derived by the conformance suite so it cannot drift. + */ +export const PRINTER_BACKED = new Set([ + 'sw_BedMesh_AbortProbeMesh', 'sw_CameraStartMonitor', 'sw_CameraStopMonitor', + 'sw_CancelPullCloudFile', 'sw_ControlBedTemp', 'sw_ControlExtruderTemp', + 'sw_ControlGenericFan', 'sw_ControlLed', 'sw_ControlMainFan', 'sw_ControlPrintSpeed', + 'sw_ControlPurifier', 'sw_DefectDetactionConfig', 'sw_DeleteCameraTimelapse', + 'sw_DeleteMachineFile', 'sw_FileGetStatus', 'sw_FilesThumbnailsBase64', + 'sw_GetCameraTimelapseInstance', 'sw_GetDeviceDataStorageSpace', 'sw_GetFileListPage', + 'sw_GetFileStream', 'sw_GetMachineObjects', 'sw_GetMachineState', 'sw_GetPrintHistory', 'sw_GetPrintInfo', + 'sw_GetSystemInfo', 'sw_MachineFilesGetDirectory', 'sw_MachineFilesMetadata', + 'sw_MachineFilesRoots', 'sw_MachineFilesThumbnails', 'sw_MachineHeartbeat', + 'sw_MachinePrintCancel', 'sw_MachinePrintPause', 'sw_MachinePrintResume', + 'sw_MachinePrintStart', 'sw_PrinterDefectDetection', 'sw_PullCloudFile', 'sw_SendGCodes', + 'sw_ServerClientManagerSetUserinfo', 'sw_SetDeviceName', 'sw_SetMachineSubscribeFilter', + 'sw_StartCloudPrint', 'sw_StartLocalPrint', 'sw_SystemGetDeviceInfo', + 'sw_UnSubscribeMachineState', 'sw_UnsubscribeCacheKeys', + 'sw_UploadAsyncTimelapseInstance', 'sw_UploadCameraTimelapse', 'sw_exception_query' +]); + +export const TASK_CONFIG = { + // filament, per toolhead slot (parallel arrays) + VENDOR: 'filament_vendor', + TYPE: 'filament_type', + COLOR: 'filament_color', + COLOR_RGBA: 'filament_color_rgba', + EXISTS: 'filament_exist', + // mapping + MAP_TABLE: 'extruder_map_table', + USED: 'extruders_used', + // the popup's three Print Preferences toggles + FLOW_CALIBRATE: 'flow_calibrate', + TIME_LAPSE: 'time_lapse_camera', + AUTO_LEVEL: 'auto_bed_leveling', +}; + +/** The popup's three toggles, in the order the shipped UI lists them. */ +export const PRINT_PREFERENCES = [ + { key: TASK_CONFIG.FLOW_CALIBRATE, label: 'Extrusion Flow Calibration' }, + { key: TASK_CONFIG.TIME_LAPSE, label: 'Time-lapse Camera' }, + { key: TASK_CONFIG.AUTO_LEVEL, label: 'Auto Leveling' }, +]; + +/* ------------------------------------------------------------------ * + * Control limits - DEVICE TAB ONLY. + * (docs: 02-device-page/03-strings-and-controls.md) + * Taken from the shipped validation strings, not guessed. + * ------------------------------------------------------------------ */ +export const LIMITS = { + // "Heated bed temperature must be set between 0°C and 100°C" + bedTemp: { min: 0, max: 100, unit: '°C' }, + // "Print speed must be set between 50% and 150%" + printSpeed: { min: 50, max: 150, unit: '%' }, + // not stated in the bundle's validation strings; Klipper's own range + fanSpeed: { min: 0, max: 100, unit: '%' }, + nozzleTemp: { min: 0, max: 350, unit: '°C' }, +}; + +export const NAMED = { + cavityLed: 'cavity_led', + cavityFan: 'cavity_fan', +}; + +/* ------------------------------------------------------------------ * + * Error codes (docs: 05-errors/01-error-codes.md) + * 16 hex digits: SSSS MMMM UUUU EEEE + * ------------------------------------------------------------------ */ +export const SUBSYSTEMS = { + '0522': 'system / motion', + '0523': 'toolhead', + '0525': 'filament', + '0526': 'heated bed', + '0527': 'chamber', + '0528': 'homing / axis', + '0530': 'calibration / detection', + '0531': 'job / storage', + '0532': 'defect detection (vision)', + '0533': 'top cover / chamber', +}; + +/** Decode a 16-hex-digit U1 error code into its four groups. */ +export function decodeErrorCode(code) { + if (typeof code !== 'string') return null; + const c = code.trim(); + if (!/^[0-9A-Fa-f]{16}$/.test(c)) return null; + const subsystem = c.slice(4, 8); + return { + code: c.toUpperCase(), + errorClass: c.slice(0, 4), + subsystem, + subsystemName: SUBSYSTEMS[subsystem] || 'unknown', + unitIndex: parseInt(c.slice(8, 12), 16), + specific: c.slice(12, 16), + // per-head faults number their toolhead from the unit group + toolhead: (subsystem === '0523' || subsystem === '0525') + ? parseInt(c.slice(8, 12), 16) + 1 + : null, + }; +} + +/* + * print_stats.state values. + * + * Recovered from the PrintState enum's ordinal->wire-string getter in the + * bundle, which is the authority here. Note the trap: the Dart enum *member* + * for ordinal 7 is named `completed`, but the value it puts on the wire is + * `complete` (Klipper's own spelling). Matching on `completed` never fires. + */ +export const PRINT_STATE = { + UNKNOWN: 'unknown', // 0 + READY: 'ready', // 1 + STANDBY: 'standby', // 2 + PRINTING: 'printing', // 3 + PAUSED: 'paused', // 4 + CANCELLED: 'cancelled', // 5 + ERROR: 'error', // 6 + COMPLETE: 'complete', // 7 - enum member is `completed`, wire value is `complete` +}; diff --git a/resources/web/shared/js/sswcp.js b/resources/web/shared/js/sswcp.js new file mode 100644 index 00000000000..c700c925b4c --- /dev/null +++ b/resources/web/shared/js/sswcp.js @@ -0,0 +1,232 @@ +/* + * sswcp.js - client for Orca's SSWCP bridge. + * + * Wire format (docs: 02-bridge-sswcp/01-wire-protocol.md), recovered from the + * Dart classes WcpPacket / RequestPayload / ResponsePayload: + * + * request { header:{seqid:"N"}, payload:{cmd, event_id, params, metadata:null} } + * response { header:{seqid:"N"} | {event_id:"E"}, payload:{code, message, data} } + * + * SUCCESS IS code 200, NOT 0. SSWCP.hpp defaults `m_status = 200` and the shipped + * bundle compares payload.code against the literal 200 in eleven places and against + * no other value. A client that accepts 0 rejects every real response from Orca. + * See docs/u1-webui/04-bridge-wcp/01-envelope.md. + * + * A request carrying a non-null event_id becomes a subscription: Orca replies + * once with the original seqid (the ack), then pushes N further messages keyed + * by event_id only. + */ +'use strict'; + +const TIMEOUT_MS = 15000; + +/** Orca's success code. 200, not 0 - see the note above. */ +export const OK_CODE = 200; + +/** + * True when a response payload.code means success. + * + * `200` is the real contract. `0` and absent are accepted too, but only so a + * hand-written or older mock host does not silently fail; Orca itself never + * sends them on a success path. + */ +export function isOk(code) { + return code === OK_CODE || code === 0 || code === undefined || code === null; +} + +/** + * Strip the printer's JSON-RPC envelope, if there is one. + * + * Orca hands a printer reply to the page verbatim: `on_mqtt_msg_arrived` sets + * `m_res_data = response` (SSWCP.cpp:1194) and `send_to_js` puts that straight into + * `payload.data` (SSWCP.cpp:946). So a command answered by the printer resolves to + * `{jsonrpc, result, id, cli_time, dev_time}` and the payload the caller wants is one + * level down - while a command answered inside Orca resolves to the payload itself. + * + * Callers were reading fields off the envelope and getting `undefined` on hardware; the + * simulator hid it by replying with the unwrapped result. Unwrapping once here is the + * only place that knows about the transport, so every caller can read its own fields. + * + * Guarded on `jsonrpc` so a C++-answered payload that merely happens to carry a `result` + * key is left alone. An `error` envelope is passed through untouched: the bridge has + * already decided the command succeeded, and the caller is owed the detail. + */ +export function unwrapRpc(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + // The shape Orca ACTUALLY delivers for a printer command: + // Moonraker_Mqtt::on_response_arrived reshapes the reply to + // `{data: , method: }` for every target that is not + // `passthrough`, which is all of the ordinary ones. Guarded on the pair, because a + // payload that merely happens to carry a `data` key is not this. + if (typeof data.method === 'string' && ('data' in data || 'error' in data)) { + return 'data' in data ? data.data : data; + } + // The raw JSON-RPC envelope, which is what a `passthrough` target hands over. + if (typeof data.jsonrpc !== 'string') return data; + if ('result' in data) return data.result; + return data; +} + +/** + * True when a rejection means "still working", not "refused". + * + * Two clocks produce one, and they are different questions: + * + * the client's this file, 15s - the page giving up on the BRIDGE + * Orca's 80s waiting on the PRINTER (MoonRaker.hpp:344), which fails the + * request as code -2, message "time out" + * + * Note the space: matching only /timed out/ - the client's wording - misses Orca's + * own. It stayed hidden because the client's clock is the shorter of the two and + * normally fires first, so Orca's -2 arrives after the request has been forgotten. + * A host that answers sooner brings it straight out, and a working 31s toolchange + * gets reported as a refusal. + * + * Either way the machine is still moving. Telling the user to retry would be wrong. + */ +export function isTimeout(e) { + if (!e) return false; + return e.code === -2 || /tim(?:e|ed)\s?out/i.test(String(e.message || '')); +} + +export class SswcpError extends Error { + constructor(code, message, cmd) { + super(`${cmd} failed (code ${code})${message ? ': ' + message : ''}`); + this.name = 'SswcpError'; + this.code = code; + this.cmd = cmd; + } +} + +export class Sswcp { + constructor(options = {}) { + this._seq = Math.floor(Date.now() % 100000); + this._eventSeq = 0; + this._pending = new Map(); // seqid -> {resolve, reject, timer, cmd} + this._subs = new Map(); // event_id -> {onPush, cmd} + this._log = options.log || (() => {}); + this._onMessage = this._onMessage.bind(this); + window.addEventListener('message', this._onMessage); + } + + /** True when running inside Orca's webview (or a mock that installs window.wx). */ + static hasHost() { + return !!(window.wx && typeof window.wx.postMessage === 'function'); + } + + dispose() { + window.removeEventListener('message', this._onMessage); + for (const p of this._pending.values()) { + clearTimeout(p.timer); + p.reject(new Error('bridge disposed')); + } + this._pending.clear(); + this._subs.clear(); + } + + _post(packet) { + const text = JSON.stringify(packet); + this._log('tx', packet); + if (!Sswcp.hasHost()) throw new Error('window.wx bridge not available'); + window.wx.postMessage(text); + } + + /** Fire a one-shot command. Resolves with payload.data. */ + request(cmd, params = {}) { + const seqid = String(this._seq++); + const packet = { + header: { seqid }, + payload: { cmd, event_id: null, params, metadata: null }, + }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pending.delete(seqid); + reject(new Error(`${cmd} timed out after ${TIMEOUT_MS}ms`)); + }, TIMEOUT_MS); + this._pending.set(seqid, { resolve, reject, timer, cmd }); + try { + this._post(packet); + } catch (e) { + clearTimeout(timer); + this._pending.delete(seqid); + reject(e); + } + }); + } + + /** + * Start a subscription. `onPush(data, payload)` fires for every later message. + * Resolves with { eventId, ack, cancel }. + */ + subscribe(cmd, params = {}, onPush = () => {}) { + const eventId = `dp_${Date.now()}_${this._eventSeq++}`; + const seqid = String(this._seq++); + const packet = { + header: { seqid }, + payload: { cmd, event_id: eventId, params, metadata: null }, + }; + this._subs.set(eventId, { onPush, cmd }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pending.delete(seqid); + // The ack is best-effort: some builds push before acking. Keep the + // subscription alive rather than tearing it down on a slow ack. + resolve({ eventId, ack: null, cancel: () => this.cancel(eventId) }); + }, TIMEOUT_MS); + this._pending.set(seqid, { + resolve: (data) => resolve({ eventId, ack: data, cancel: () => this.cancel(eventId) }), + reject, + timer, + cmd, + }); + try { + this._post(packet); + } catch (e) { + clearTimeout(timer); + this._pending.delete(seqid); + this._subs.delete(eventId); + reject(e); + } + }); + } + + cancel(eventId) { + this._subs.delete(eventId); + } + + _onMessage(ev) { + let msg = ev.data; + if (typeof msg === 'string') { + try { msg = JSON.parse(msg); } catch { return; } + } + if (!msg || typeof msg !== 'object') return; + const header = msg.header; + const payload = msg.payload; + if (!header || !payload) return; // not ours + + this._log('rx', msg); + + // subscription push - keyed by event_id only + if (header.event_id != null && !header.seqid) { + const sub = this._subs.get(header.event_id); + if (sub) { + try { sub.onPush(unwrapRpc(payload.data), payload); } + catch (e) { console.error('[sswcp] push handler threw', e); } + } + return; + } + + // reply to a request (or a subscription ack) + const seqid = header.seqid != null ? String(header.seqid) : null; + if (seqid == null) return; + const p = this._pending.get(seqid); + if (!p) return; + clearTimeout(p.timer); + this._pending.delete(seqid); + + const code = payload.code; + if (isOk(code)) p.resolve(unwrapRpc(payload.data)); + else p.reject(new SswcpError(code, payload.message, p.cmd)); + } +} diff --git a/resources/web/shared/js/state.js b/resources/web/shared/js/state.js new file mode 100644 index 00000000000..ff2b5b50aad --- /dev/null +++ b/resources/web/shared/js/state.js @@ -0,0 +1,549 @@ +/* + * state.js - machine state store. + * + * Orca normalises every status message before it reaches the page + * (Moonraker_Mqtt::on_status_arrived): + * + * payload.data = { data: , method: "..." } + * + * where is keyed by Klipper object name. Pushes are PARTIAL - only + * changed objects, and only their filtered fields - so the store merges rather + * than replaces. See docs/u1-webui/05-printer-protocol/04-state-model.md + */ +'use strict'; + +import { TOOLHEADS, PRINT_STATE, PURIFIER_MODES } from './protocol.js'; +import { parseAce, headOccupied } from './multiACE.js'; + +/** Pull the object map out of whatever shape the transport handed us. */ +export function unwrapStatus(data) { + if (!data || typeof data !== 'object') return null; + let inner = ('data' in data) ? data.data : data; + // Moonraker's notify_status_update sends params as [ {objects}, eventtime ] + if (Array.isArray(inner)) inner = inner[0]; + if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return null; + // a query result may still be wrapped as { status: {...} } + if (inner.status && typeof inner.status === 'object' && !Array.isArray(inner.status)) { + return inner.status; + } + return inner; +} + +export class MachineState { + constructor() { + this.objects = Object.create(null); + this.lastUpdate = 0; + this._listeners = new Set(); + } + + onChange(fn) { + this._listeners.add(fn); + return () => this._listeners.delete(fn); + } + + _emit(changed) { + this.lastUpdate = Date.now(); + for (const fn of this._listeners) { + try { fn(this, changed); } catch (e) { console.error('[state] listener threw', e); } + } + } + + /** Merge a (partial) object map. Returns the set of object names touched. */ + apply(objectMap) { + if (!objectMap) return []; + const changed = []; + for (const [name, fields] of Object.entries(objectMap)) { + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) { + this.objects[name] = fields; + changed.push(name); + continue; + } + const cur = this.objects[name]; + this.objects[name] = (cur && typeof cur === 'object' && !Array.isArray(cur)) + ? Object.assign({}, cur, fields) // shallow merge is correct: filters are flat + : Object.assign({}, fields); + changed.push(name); + } + if (changed.length) this._emit(changed); + return changed; + } + + /** Apply a raw SSWCP payload.data from a state push or query. */ + applyPayload(data) { + return this.apply(unwrapStatus(data)); + } + + get(name) { return this.objects[name] || null; } + + // ---- typed accessors ------------------------------------------------- + + /** Four toolheads; Klipper names the first `extruder`, not `extruder0`. */ + toolheads() { + return TOOLHEADS.map((key, i) => { + const o = this.objects[key] || {}; + return { + key, + index: i, + label: `Toolhead ${i + 1}`, + temperature: num(o.temperature), + target: num(o.target), + power: num(o.power), + canExtrude: !!o.can_extrude, + state: o.state ?? null, + nozzleDiameter: o.nozzle_diameter ?? null, + present: Object.keys(o).length > 0, + }; + }); + } + + /** + * The `toolhead` object: which tool is live, where the gantry is, what is homed. + * + * Measured on a U1 - `{extruder:"extruder3", position:[x,y,z,e], homed_axes:""}`. + * Without it the Tool buttons were a local variable with nothing behind them and the + * axis readouts could only ever show their placeholder. `homed_axes` matters just as + * much: Klipper refuses a G0 on an unhomed axis, so a jog looks like it did nothing. + */ + toolhead() { + // Deliberately NOT from a `toolhead` subscription: the shipped page does not + // subscribe that object, and the conformance suite holds the list to the bundle's. + // Everything needed is already in the subscribed set - the active tool is the + // extruder reporting state ACTIVATE, and the live position is on motion_report. + // `homed_axes` exists only on `toolhead`, so it arrives from an explicit one-shot + // query (see refreshToolhead in app.js) rather than from the stream. + const o = this.objects['toolhead'] || {}; + const mr = this.objects['motion_report'] || {}; + const pos = Array.isArray(mr.live_position) ? mr.live_position + : (Array.isArray(o.position) ? o.position : []); + const hasHomed = Object.prototype.hasOwnProperty.call(o, 'homed_axes'); + const homed = String(o.homed_axes || ''); + + // Which head is ENGAGED is answered only by `extruder*.state === 'ACTIVATE'`. + // + // `toolhead.extruder` is NOT a second opinion on that and must not be used as one. + // It is Klipper's current-extruder pointer for G-code purposes, and it goes on + // naming the last head used after that head has been parked - measured on this + // machine reading "extruder" while all four reported PARKED. Trusting it let the + // panel believe a head was live when none was: the button offered to park it, + // PARK_EXTRUDER returned an instant no-op "ok" with nothing moving, and the wait + // then sat out its full timeout for an activeIndex that could never change. + // + // Nothing reporting ACTIVATE means nothing is engaged. That is an answer, not a gap + // to be filled from elsewhere. + const activeKey = TOOLHEADS.find( + (k) => (this.objects[k] || {}).state === 'ACTIVATE') || null; + + return { + activeKey, + activeIndex: TOOLHEADS.indexOf(activeKey) >= 0 ? TOOLHEADS.indexOf(activeKey) : null, + x: numOrNull(pos[0]), y: numOrNull(pos[1]), z: numOrNull(pos[2]), e: numOrNull(pos[3]), + homedAxes: homed, + // Unknown and "nothing is homed" look alike in the value and are not the same + // thing, so the distinction is drawn on whether the field is THERE. An absent + // toolhead snapshot is unknown; `homed_axes: ""` is the machine saying plainly + // that no axis is homed - which is what it reports from a cold boot. Reading the + // empty string as "unknown" let every Z and XY jog through to a printer that + // refuses them, so the buttons looked dead. + isHomed: (a) => (hasHomed ? homed.toLowerCase().includes(String(a).toLowerCase()) : true), + allHomed: hasHomed ? ['x', 'y', 'z'].every((a) => homed.toLowerCase().includes(a)) : null, + present: Object.keys(o).length > 0, + }; + } + + /** + * Filament slots, normalised. + * + * `filament_exist` is the authority on whether a slot is loaded - a slot can carry a + * type of "NONE" and still be reported present, so both are checked. + */ + filaments() { + const c = this.objects['print_task_config'] || {}; + const arr = (k) => (Array.isArray(c[k]) ? c[k] : []); + const types = arr('filament_type'), vendors = arr('filament_vendor'); + const subs = arr('filament_sub_type'); + const rgba = arr('filament_color_rgba'), argb = arr('filament_color'); + const exists = arr('filament_exist'); + /* + * Whether the machine considers this slot's identity ITS OWN, and whether it will + * let it be edited. Both are the printer's, per slot, and both were on the + * subscription and unread while the panel decided the same question from the RFID + * tag - which is not the same question. + * + * Measured on 811002511261022618B3, 2026-08-28: + * + * head 0 1 2 3 + * vendor Jayo Forshape NONE Kingroon + * sub_type Marble "" NONE Basic + * filament_detect: a decodable tag? yes yes no no + * filament_official true FALSE false false + * filament_edit false TRUE false TRUE + * + * Head 1 is the case that settles it: it carries a physical tag (`filament_detect` + * reads Forshape PLA Silk, OFFICIAL, with a CARD_UID) and the machine still says + * `filament_edit: true`, because the record in use has been overridden - the + * sub-type there is `""` where the tag says `Silk`. **A tag present is not the same + * question as read-only**, and the panel was asking the wrong one. + */ + const official = arr('filament_official'); + const editable = arr('filament_edit'); + + // The RFID tag, when there is one. `filament_detect.info` is one entry per slot in + // slot order - TRAY is 0 on every entry, so it is the position that identifies the + // slot, not that field. A manually-set filament has no tag and reads NONE here even + // though print_task_config has a type for it, so the two sources are kept separate + // rather than one overwriting the other. + const det = this.objects['filament_detect'] || {}; + const tags = Array.isArray(det.info) ? det.info : []; + const feed = this.feedChannels(); + + const named = (v) => (v && v !== 'NONE' ? v : null); + + return TOOLHEADS.map((key, i) => { + const type = types[i]; + const tag = tags[i] || {}; + const tagged = tag.MAIN_TYPE && tag.MAIN_TYPE !== 'NONE'; + const ext = this.objects[key] || {}; + /* + * The spool's own record, when the machine's working copy has none. + * + * `print_task_config` and `filament_detect` are two records of one slot and the + * first is not always filled: multiACE wipes it on every stock-feeder head when the + * machine enters head mode (`_clear_filament_display`: empty type, empty vendor, + * `FILAMENT_COLOR_RGBA=00000000`) and does NOT touch the tag. Measured on + * 811002511261022618B3 after cycling normal → multi → head: + * + * head 0 ptc '' '' '00000000' exist true TAG Jayo PLA Marble, OFFICIAL + * head 1 ptc '' '' '00000000' exist true TAG Forshape PLA Silk, OFFICIAL + * + * The panel drew both as "occupied, not named" while the identity was sitting in + * the other object, which is what was reported: two toolheads that had shown their + * filament went blank after a mode switch. + * + * This is multiACE's own precedence one level up - a tag beats a typed value - + * applied where it had not been: the tag is only a FALLBACK here, so a record + * someone set deliberately still wins while it exists. + */ + const ptcType = named(type); + const fromTag = !ptcType && tagged; + return { + index: i, + type: ptcType || (fromTag ? named(tag.MAIN_TYPE) : null), + subType: named(subs[i]) || (fromTag ? named(tag.SUB_TYPE) : null), + vendor: named(vendors[i]) || (fromTag ? named(tag.VENDOR) : null), + color: fromTag ? tag.ARGB_COLOR + : (rgba[i] != null ? rgba[i] : argb[i]), + // Where the identity on screen came from, so a caller need not re-derive it. + fromTag: !!fromTag, + // The machine says a slot HAS filament in `filament_exist`; the identity is a + // separate question and a wiped one does not make the slot empty. This read + // `!!type`, so a head the mode switch had blanked reported itself unloaded. + loaded: exists[i] !== false && !!(ptcType || tagged), + // The machine's own answer to "is this identity the spool's own record". + official: official[i] === true, + /* + * The machine's own PERMISSION, verbatim. Its firmware computes it as: + * + * allowed_edit = False + * if filament_exist[ch] and filament_official[ch] == False: + * allowed_edit = True + * + * and enforces the other half in `SET_PRINT_FILAMENT_CONFIG`, which raises + * "official filament, not configurable!" without `FORCE=1`. So this is a real + * gate and not a hint - but it is a LATCH, see `editable` below. + * + * The fallback is for a firmware that does not report the field; on this one it + * is always there. + */ + allowedEdit: editable[i] != null ? editable[i] === true + : (exists[i] !== false && !!type && type !== 'NONE' + && !(tag.MAIN_TYPE && tag.MAIN_TYPE !== 'NONE')), + // from the tag - null when the spool carries none + tag: tagged ? { + vendor: tag.VENDOR !== 'NONE' ? tag.VENDOR : null, + type: tag.MAIN_TYPE, + subType: tag.SUB_TYPE !== 'NONE' ? tag.SUB_TYPE : null, + color: tag.ARGB_COLOR, + nozzleMin: numOrNull(tag.HOTEND_MIN_TEMP), + nozzleMax: numOrNull(tag.HOTEND_MAX_TEMP), + bedTemp: numOrNull(tag.BED_TEMP), + dryingTemp: numOrNull(tag.DRYING_TEMP), + dryingTime: numOrNull(tag.DRYING_TIME), + sku: tag.SKU || null, + } : null, + // Klipper's pressure advance is this machine's flow-dynamics factor - the + // same role Bambu's "Factor K" plays. + pressureAdvance: numOrNull(ext.pressure_advance), + smoothTime: numOrNull(ext.smooth_time), + feed: feed[i] || null, + }; + }).map((f) => Object.assign(f, { + /* + * What this PAGE will offer, which is deliberately stricter than the machine's + * permission: a spool that carries a tag is not edited from here, whatever the + * latch says. + * + * `allowedEdit` above is a latch, not a statement about the spool. Writing a slot + * sets `filament_official[ch] = False` in the same firmware function, so ONE edit + * unlocks a tagged spool permanently - until the tag is read again, which happens + * when the filament is next loaded. Head 2 of 811002511261022618B3 is sitting in + * exactly that state: an NTAG reading Forshape PLA **Silk** is physically there, + * `print_task_config` says PLA with sub-type `""`, and the machine will cheerfully + * take another write. + * + * Following the latch alone means the panel offers to type over a spool that will + * revert on its next load, and the record drifts further from the tag each time. + * So both must hold. A page may be more restrictive than a permission; it must + * never be more permissive, and this is never that - `allowedEdit` is still + * required, so an official or empty slot stays closed for the machine's own reason. + * + * Reported from the panel, on the real machine: "it has an rfid icon and is still + * editable, which it should not be." + */ + editable: f.allowedEdit && !f.tag, + })); + } + + /** + * Per-extruder ACE feed state, from `filament_feed left` / `filament_feed right`. + * + * Each object is keyed by extruder name - and the keys are `extruder0`..`extruder3`, + * which is NOT how Klipper names the first one anywhere else (`extruder`, no zero). + */ + feedChannels() { + const out = []; + for (const side of ['filament_feed left', 'filament_feed right']) { + const o = this.objects[side]; + if (!o || typeof o !== 'object') continue; + for (const [k, v] of Object.entries(o)) { + const m = /^extruder(\d+)$/.exec(k); + if (!m || !v || typeof v !== 'object') continue; + out[Number(m[1])] = { + detected: !!v.filament_detected, + inAce: !!v.filament_in_ace, + inToolhead: !!v.filament_in_toolhead, + atExtruder: !!v.filament_at_extruder, + channelState: v.channel_state || null, + // The LAST OPERATION THAT FINISHED on this channel, which is a different + // question from what it is doing now and outlives the answer to it: + // `channel_state` settles back to `wait_insert`, this one stays. It is what + // says whether a head is holding filament - see multiACE.js's headOccupied(). + actionState: v.channel_action_state || null, + error: v.channel_error && v.channel_error !== 'ok' ? v.channel_error : null, + }; + } + } + return out; + } + + /** + * Whether toolhead `i` is holding filament, for anything that has not already got the + * three sources in hand. The rule - and the measurement behind it - is + * multiACE.js's headOccupied(); the filament panel calls that directly because it is + * already holding all three and this would re-parse them per card. + */ + headLoaded(i) { + return headOccupied(this.feedChannels()[i], this.ace().heads[i], + !!(this.filaments()[i] || {}).loaded); + } + + /** + * The multiACE topology. The model is `multiACE.js`, which also owns the override + * merge - + * two sources that do not carry the same thing, and one place that knows how they fit + * together. + */ + ace() { + return parseAce(this.objects); + } + + bed() { + const o = this.objects['heater_bed'] || {}; + return { temperature: num(o.temperature), target: num(o.target) }; + } + + job() { + const ps = this.objects['print_stats'] || {}; + const vs = this.objects['virtual_sdcard'] || {}; + const ds = this.objects['display_status'] || {}; + const progress = num(ds.progress ?? vs.progress); // 0..1 + return { + filename: ps.filename || vs.file_path || '', + state: ps.state || PRINT_STATE.STANDBY, + message: ps.message || ds.message || '', + printDuration: num(ps.print_duration), + totalDuration: num(ps.total_duration), + filamentUsed: num(ps.filament_used), + progress: clamp(progress, 0, 1), + // Klipper carries the layer counters on print_stats.info, which IS subscribed. + // numOrNull, not num: "no layer information" and "layer zero" are different + // answers, and the card shows a dash for the first. + layer: numOrNull((ps.info || {}).current_layer), + totalLayer: numOrNull((ps.info || {}).total_layer), + isActive: !!vs.is_active, + fileSize: num(vs.file_size), + filePosition: num(vs.file_position), + }; + } + + fans() { + const main = this.objects['fan'] || {}; + const cavity = this.objects['fan_generic cavity_fan'] || {}; + return { + main: pct(main.speed), + cavity: pct(cavity.speed), + }; + } + + led() { + const o = this.objects['led cavity_led'] || {}; + // color_data is Klipper's [[r,g,b,w]] form; the U1 drives the white channel + let white = 0; + const cd = o.color_data; + if (Array.isArray(cd) && Array.isArray(cd[0])) white = num(cd[0][3] ?? cd[0][0]); + else if (typeof cd === 'number') white = cd; + return { on: white > 0, white }; + } + + speed() { + const g = this.objects['gcode_move'] || {}; + return { + factorPct: g.speed_factor != null ? Math.round(num(g.speed_factor) * 100) : null, + extrudeFactor: num(g.extrude_factor), + }; + } + + purifier() { + const o = this.objects['purifier'] || {}; + // Measured: mode is an INTEGER (0), and exhaust_fan / inner_fan are objects + // ({speed, delay, speed_threshold}) rather than scalars. The page used to print + // the raw mode, which showed a bare "0", and treat the fans as numbers. + const fanSpeed = (f) => (f && typeof f === 'object' ? pct(f.speed) : pct(f)); + return { + powerDetected: !!o.power_detected, + powerDetValue: num(o.power_det_value), + mode: o.mode ?? null, + modeName: PURIFIER_MODES[o.mode] ?? (o.mode == null ? null : String(o.mode)), + exhaustFan: fanSpeed(o.exhaust_fan), + innerFan: fanSpeed(o.inner_fan), + present: Object.keys(o).length > 0, + }; + } + + /** + * Why the machine is busy, from the sources that actually answer. + * + * Measured, not assumed. A real `T0 A0` on an unhomed U1 was watched end to end + * (2026-08-24, 31 s) and this is what moved: + * + * 0.7s homed_axes "z" idle_timeout "Printing" + * 2.7s homed_axes "" + * 4.7s homed_axes "y" + * 14.7s homed_axes "xy" <- the "XY calibration" a user sees + * 28.7s extruder.activating_move true + * 30.7s extruder.state "ACTIVATE" + * 32.7s idle_timeout "Ready" + * + * Two things did NOT move at any point: `machine_state_manager`, which stayed + * {main_state: 0, action_code: 0}, and `extruder_offset_calibration.calibration_step`, + * which stayed "idle". Both were previously trusted here and both are silent for a + * manual toolchange, which is why the dialog had nothing to say. + * + * `idle_timeout` is now treated as busy after all. It was excluded for reading + * "Printing" on an apparently idle machine - but that is Klipper's timeout not having + * elapsed yet, not a lie, and across the capture it bracketed the operation exactly. + * Lingering is harmless because every wait also has its own done() and a hard cap. + */ + busyReason() { + const act = this.activity(); + const th = this.objects['toolhead'] || {}; + const cal = this.objects['extruder_offset_calibration'] || {}; + const disp = this.objects['display_status'] || {}; + const mr = this.objects['motion_report'] || {}; + const idle = this.objects['idle_timeout'] || {}; + + const running = idle.state === 'Printing'; + const step = typeof cal.calibration_step === 'string' ? cal.calibration_step : ''; + const calibrating = !!step && step !== 'idle'; + const moving = Math.abs(num(mr.live_velocity)) > 0.001; + const msg = typeof disp.message === 'string' && disp.message.trim() + ? disp.message.trim() : null; + + const hasHomed = Object.prototype.hasOwnProperty.call(th, 'homed_axes'); + const homed = String(th.homed_axes || '').toLowerCase(); + // Homing while the machine is running is the long phase of a toolchange. + // + // The label used to name the axes already done - "Homing - Z done", then "Y done", + // then "X, Y done". Two things were wrong with that. Klipper CLEARS homed_axes as + // it re-homes, so Z vanishes from the list after being reported done, which reads + // as the machine going backwards. And a person waiting for a toolhead does not want + // an axis-by-axis account; they want to know it is working and will take a moment. + // One step, named for what is happening. + // + // The clearing is this path's, NOT a general fact about G28: it is the toolchange's + // `G28 X Y` under a homing_override. A plain G28 on this firmware leaves the field + // alone - measured reading "xyz" unchanged across all 42 seconds of one - which is + // why nothing may treat it as a progress signal. See `home` in control-commands.js. + const homing = running && hasHomed && homed !== 'xyz'; + const engaging = TOOLHEADS.findIndex( + (k) => (this.objects[k] || {}).activating_move === true); + + let label = null; + if (calibrating) label = `Calibrating \u2014 ${prettyStep(step)}`; + else if (engaging >= 0) label = `Engaging toolhead ${engaging + 1}\u2026`; + else if (homing) label = 'Homing axes\u2026'; + else if (msg) label = msg; + else if (moving) label = 'Moving\u2026'; + else if (running) label = 'Working\u2026'; + + return { + label, + busy: calibrating || moving || running || engaging >= 0, + // Whether `label` NAMES what the machine is doing, or only reports that it is + // doing something. "Calibrating - probe xy offset" is worth putting in front of + // someone; "Moving" is not, when the dialog it would replace already says + // "Homing all axes". And motion starts and stops: across the measured 42s G28 + // (see `home` in control-commands.js) the two generic labels alternated eight + // times, which reads as a machine changing its mind rather than one working. + vague: !(calibrating || engaging >= 0 || homing || msg), + homing, + homedAxes: hasHomed ? homed : null, + calibrationStep: calibrating ? step : null, + }; + } + + /** machine_state_manager drives the activity banner and the error code. */ + activity() { + const m = this.objects['machine_state_manager'] || {}; + return { mainState: m.main_state ?? null, actionCode: m.action_code ?? null }; + } + + /** + * Milliseconds since anything last arrived; Infinity if nothing ever has. + * + * `lastUpdate > 0` answers a different question - whether the machine has EVER + * spoken - and using it as a liveness test left a rebooting printer showing as + * connected, with its last snapshot presented as current. + */ + age(now = Date.now()) { + return this.lastUpdate ? now - this.lastUpdate : Infinity; + } + + connection() { + const w = this.objects['webhooks'] || {}; + return { state: w.state ?? null, message: w.state_message ?? '' }; + } + + taskConfig() { + return this.objects['print_task_config'] || {}; + } +} + +/** `probe_xy_offset` -> `probe xy offset`: the machine's own word, made readable. */ +function prettyStep(s) { + return String(s).replace(/[_-]+/g, ' ').trim(); +} + +function numOrNull(v) { const n = Number(v); return Number.isFinite(n) ? n : null; } +function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; } +function pct(v) { const n = Number(v); return Number.isFinite(n) ? Math.round(n * 100) : 0; } +function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); } diff --git a/resources/web/shared/stamp_build.py b/resources/web/shared/stamp_build.py new file mode 100644 index 00000000000..6a0f686ccae --- /dev/null +++ b/resources/web/shared/stamp_build.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Write resources/web/shared/build-stamp.json — the commit the surfaces were built from. + +The reconstructed surfaces show a badge naming the surface, the Flutter bundle's +build number, and the git commit. The first two come from the bundle itself; the +commit can only come from the repo, so it is stamped into a small JSON file the +badge fetches at runtime. + +The stamp is optional: the badge degrades to bundle version + build number if the +file is missing or stale, so a checkout that never runs this still works. + +Run it after checking out or committing: + python3 resources/web/shared/stamp_build.py +""" +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..", "..")) +OUT = os.path.join(HERE, "build-stamp.json") + + +def git(*args, default=""): + try: + return subprocess.run(["git", "-C", REPO, *args], + capture_output=True, text=True, timeout=10, + check=True).stdout.strip() + except Exception: + return default + + +def main(): + commit = git("rev-parse", "--short", "HEAD", default="unknown") + dirty = bool(git("status", "--porcelain")) + stamp = { + "commit": commit, + "commit_full": git("rev-parse", "HEAD", default=""), + "branch": git("rev-parse", "--abbrev-ref", "HEAD", default=""), + "dirty": dirty, + "subject": git("log", "-1", "--pretty=%s", default=""), + "committed": git("log", "-1", "--pretty=%cI", default=""), + } + with open(OUT, "w", encoding="utf-8") as f: + json.dump(stamp, f, indent=1) + f.write("\n") + mark = "+dirty" if dirty else "" + print(f"{os.path.relpath(OUT, REPO)}: {stamp['branch']} @ {commit}{mark}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/web/shared/tests/check_app_serve.py b/resources/web/shared/tests/check_app_serve.py new file mode 100644 index 00000000000..dedd7931a38 --- /dev/null +++ b/resources/web/shared/tests/check_app_serve.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Load a surface from ORCA'S OWN page server and say whether the document ran. + + python3 resources/web/shared/tests/check_app_serve.py # Orca must be running + python3 resources/web/shared/tests/check_app_serve.py --port 13619 + python3 resources/web/shared/tests/check_app_serve.py --url http://127.0.0.1:13619/web/... + +`run_webkit.py` serves the tree from Python, so it proves the page agrees with the +printer - not that it agrees with the host that actually serves it. On 2026-08-26 those +were different answers: Orca's `HttpServer` was losing one request per page load, the +Device tab was blank in the app, and all three suites were green. The module graph has no +partial success, so a single module that never arrives is a document that never runs - +and it says nothing about it. `readyState` stays at "interactive", the console is empty, +and the page shows its static shell with an empty `.content`. + +Two numbers answer it, and they are the two this prints: + + readyState must reach "complete" + pending resources must be 0 + +Round seven of docs/u1-webui/02-device-page/08-function-gap-analysis.md is the whole +story. Exit code is 0 when the document completed with nothing outstanding. +""" +import argparse +import os +import sys + +os.environ.setdefault("WEBKIT_DISABLE_COMPOSITING_MODE", "1") +os.environ.setdefault("GDK_BACKEND", "x11") + +import gi # noqa: E402 +gi.require_version("Gtk", "3.0") +gi.require_version("WebKit2", "4.1") +from gi.repository import Gtk, WebKit2, GLib # noqa: E402 + +# Orca's own page server. PAGE_HTTP_PORT in src/slic3r/GUI/HttpServer.hpp; it moves only +# when the port is taken, and the app logs the one it settled on. +PAGE_HTTP_PORT = 13619 +# The query Orca itself appends. Kept because the request LENGTH is what decides whether +# the server's header parse desynchronises, so a check that drops it is checking a +# different request than the app makes. +SURFACES = { + "device": "/web/device_page/index.html?locale=en-US&dark_mode=0", + "print": "/web/print_processing/index.html?mode=print", + "upload": "/web/print_processing/index.html?mode=upload", +} + +REPORT = r""" +(function () { + const c = document.querySelector('.content, .app, body'); + return JSON.stringify({ + ready: document.readyState, + nodes: document.querySelectorAll('*').length, + content: (document.querySelector('.content') || {}).childElementCount ?? -1, + booted: typeof window.__devicePage, + status: (document.querySelector('#status') || {}).textContent || null, + }); +})() +""" + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--surface", choices=sorted(SURFACES), default="device") + ap.add_argument("--port", type=int, default=PAGE_HTTP_PORT) + ap.add_argument("--url", help="a full URL, instead of --surface/--port") + ap.add_argument("--wait", type=float, default=12.0, help="seconds to let it settle") + ap.add_argument("--runs", type=int, default=1, + help="load it this many times; the fault was one load in one") + args = ap.parse_args() + + url = args.url or f"http://127.0.0.1:{args.port}{SURFACES[args.surface]}" + print(f"{url}\n") + + # A page that will not load because nothing is serving it is not the fault this + # checks for, and saying so would send the next person after the wrong thing. + import socket + host, port = "127.0.0.1", args.port + if args.url: + from urllib.parse import urlparse + u = urlparse(args.url) + host, port = u.hostname or host, u.port or 80 + with socket.socket() as probe: + probe.settimeout(2) + if probe.connect_ex((host, port)) != 0: + print(f"nothing is listening on {host}:{port} - start Orca first.") + return 2 + + failures = 0 + for run in range(1, args.runs + 1): + ok, line = load_once(url, args.wait) + print(f" run {run}: {line}") + if not ok: + failures += 1 + + print() + if failures: + print(f"FAIL {failures}/{args.runs} loads did not complete.\n" + " A pending resource with a silent console is the app's HTTP server " + "losing a request,\n not a fault in the page - see round seven of " + "docs/u1-webui/02-device-page/08-function-gap-analysis.md.") + return 1 + print(f"PASS {args.runs}/{args.runs} loads completed with nothing outstanding.") + return 0 + + +def load_once(url, wait): + """One load in WebKitGTK. Returns (ok, one line saying what happened).""" + win = Gtk.OffscreenWindow() + win.set_default_size(1920, 1080) + view = WebKit2.WebView() + win.add(view) + win.show_all() + + started, done, failed = [], set(), [] + + def on_resource(_v, res, req): + started.append(req.get_uri()) + res.connect("finished", lambda r: done.add(r.get_uri())) + res.connect("failed", lambda r, e: (done.add(r.get_uri()), + failed.append((r.get_uri(), e.message)))) + + view.connect("resource-load-started", on_resource) + view.load_uri(url) + + out = {} + + def finish(): + def got(_v, res, *_): + # Anything thrown in here would skip the quit below and leave the run to the + # watchdog, which reads as a page that never loaded. It is not. + try: + val = view.evaluate_javascript_finish(res) + out["dom"] = val.to_string() if val else "" + except Exception as e: # noqa: BLE001 + out["dom"] = "" + out["err"] = str(e) + Gtk.main_quit() + view.evaluate_javascript(REPORT, -1, None, None, None, got, None) + return False + + # Both are removed below. A watchdog left armed from an earlier run fires inside the + # NEXT run's main loop and quits it early, which reads as a page that never loaded - + # `readyState` unread with every resource finished, which is not a state the page can + # actually be in. + settle = GLib.timeout_add(int(wait * 1000), finish) + guard = GLib.timeout_add(int((wait + 20) * 1000), lambda: (Gtk.main_quit(), False)[1]) + Gtk.main() + for src in (settle, guard): + GLib.source_remove(src) if GLib.MainContext.default().find_source_by_id(src) else None + win.destroy() + + import json + try: + dom = json.loads(out.get("dom") or "{}") + except ValueError: + dom = {} + pending = [u for u in started if u not in done] + ok = dom.get("ready") == "complete" and not pending and not failed + + if "err" in out: + bits_err = f" (probe: {out['err']})" + else: + bits_err = "" + bits = [f"readyState={dom.get('ready')!r}", + f"resources {len(done)}/{len(started)}", + f"pending {len(pending)}", + f"nodes {dom.get('nodes')}"] + line = " ".join(bits) + bits_err + for u in pending: + line += f"\n PENDING {u}" + for u, msg in failed: + line += f"\n FAILED {u}: {msg}" + return ok, line + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/web/shared/tests/conformance_test.py b/resources/web/shared/tests/conformance_test.py new file mode 100644 index 00000000000..edcc0792e6b --- /dev/null +++ b/resources/web/shared/tests/conformance_test.py @@ -0,0 +1,1175 @@ +#!/usr/bin/env python3 +"""Conformance test: do the shared constants match the reverse-engineered evidence? + +The highest-risk part of both reconstructions is the hand-transcribed constant +tables in resources/web/shared/js/protocol.js. This test re-derives them from the +extraction output under docs/u1-webui/data/ and asserts they agree exactly. + +It guards BOTH surfaces, because both import the same shared module. + +Run: python3 resources/web/shared/tests/conformance_test.py +Exit: 0 on success, 1 on any mismatch. +""" +import json, os, re, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +SHARED = os.path.dirname(HERE) # resources/web/shared +WEB = os.path.dirname(SHARED) # resources/web +ROOT = os.path.dirname(os.path.dirname(WEB)) # repo root +DATA = os.path.join(ROOT, "docs", "u1-webui", "data") +PROTO = os.path.join(SHARED, "js", "protocol.js") +SSWCP = os.path.join(SHARED, "js", "sswcp.js") +MOCK = os.path.join(SHARED, "js", "mockhost.js") + +fails, checks = [], 0 +def check(name, cond, detail=""): + global checks + checks += 1 + if cond: + print(f" PASS {name}") + else: + print(f" FAIL {name}" + (f"\n {detail}" if detail else "")) + fails.append(name) + +# ---------------------------------------------------------------- helpers +def js_block(src, name): + """Extract `export const = { ... };` and return the brace body.""" + m = re.search(r'export const ' + re.escape(name) + r'\s*=\s*\{', src) + if not m: + return None + i = src.index('{', m.start()) + d = 0 + for k in range(i, len(src)): + if src[k] == '{': d += 1 + elif src[k] == '}': + d -= 1 + if d == 0: + return src[i:k+1] + return None + +def js_array(src, name): + m = re.search(r'export const ' + re.escape(name) + r'\s*=\s*\[(.*?)\];', src, re.S) + return re.findall(r"'([^']*)'|\"([^\"]*)\"", m.group(1)) if m else None + +def to_pairs(body, extruder_fields): + """Parse the SUBSCRIBE_OBJECTS object literal into {name: [fields]|None}.""" + out = {} + # entries look like: 'name': [ 'a','b' ], or 'name': null, or 'name': EXTRUDER_FIELDS, + for m in re.finditer(r"'([^']+)'\s*:\s*(EXTRUDER_FIELDS|null|\[)", body): + key, kind = m.group(1), m.group(2) + if kind == 'EXTRUDER_FIELDS': + out[key] = list(extruder_fields) + elif kind == 'null': + out[key] = None + else: + j = body.index('[', m.end() - 1) + d = 0 + for k in range(j, len(body)): + if body[k] == '[': d += 1 + elif body[k] == ']': + d -= 1 + if d == 0: + seg = body[j:k+1]; break + out[key] = [a or b for a, b in re.findall(r"'([^']*)'|\"([^\"]*)\"", seg)] + return out + +# ---------------------------------------------------------------- load +if not os.path.exists(PROTO): + sys.exit(f"missing {PROTO}") +src = open(PROTO, encoding='utf-8').read() +model = json.load(open(os.path.join(DATA, "state-model.json"), encoding='utf-8')) + +print("\n== state model ==") +ex = [a or b for a, b in (js_array(src, "EXTRUDER_FIELDS") or [])] +check("EXTRUDER_FIELDS matches bundle", + ex == model["extruder_default_fields"], + f"page={ex}\n bundle={model['extruder_default_fields']}") + +body = js_block(src, "SUBSCRIBE_OBJECTS") +check("SUBSCRIBE_OBJECTS block found", body is not None) +ours = to_pairs(body or "", ex) + +want_names = set(model["subscription_list"]) +have_names = set(ours) +check("subscription list has all 24 bundle objects", + want_names <= have_names, f"missing={sorted(want_names - have_names)}") +check("subscription list adds nothing extra", + have_names <= want_names, f"extra={sorted(have_names - want_names)}") + +ff = model["field_filter"] +mismatch = [] +for name, fields in ours.items(): + if name not in ff: + continue # falls through the bundle's default arm + want = ff[name] + if want is None: + if fields is not None: + mismatch.append(f"{name}: page={fields} bundle=null(all)") + elif fields != want: + mismatch.append(f"{name}: page={fields} bundle={want}") +check("per-object field filters match the bundle", + not mismatch, "\n ".join(mismatch)) + +# extruders use the substring rule, so they must carry EXTRUDER_FIELDS +check("all four toolheads carry the extruder field list", + all(ours.get(k) == ex for k in ["extruder", "extruder1", "extruder2", "extruder3"])) + +print("\n== bridge commands ==") +cmds = json.load(open(os.path.join(DATA, "sswcp-commands.json"), encoding='utf-8')) +# PRINTER_BACKED classifies REPLY SHAPES; it is not a list of commands we send, and it +# is derived from handler definitions in SSWCP.cpp - which include the ~15 dispatched +# through header macros that map_sswcp.py cannot see. Scanning it here would report +# those as "unknown commands" when the client never sends them. +src_cmds = re.sub(r"export const PRINTER_BACKED = new Set\(\[.*?\]\);", "", src, flags=re.S) +used = set(re.findall(r"'(sw_[A-Za-z0-9_]+)'", src_cmds)) +unknown = sorted(c for c in used if c not in cmds) +check("every command we send exists in Orca's dispatch", + not unknown, f"unknown={unknown}") + +# params we send must be accepted by the C++ handler. +# Both surfaces are scanned, since both issue control/state commands. +app = "" +for surface in ("device_page", "print_processing"): + f = os.path.join(WEB, surface, "js", "app.js") + if os.path.exists(f): + app += open(f, encoding='utf-8').read() +PARAM_EXPECT = { + "sw_ControlBedTemp": {"temp"}, + "sw_ControlExtruderTemp": {"temp", "index", "map"}, + "sw_ControlMainFan": {"speed"}, + "sw_ControlGenericFan": {"name", "speed"}, + "sw_ControlLed": {"name", "white"}, + "sw_ControlPrintSpeed": {"percentage"}, + "sw_GetMachineState": {"objects"}, + "sw_SetSubscribeFilter": {"objects"}, +} +bad = [] +for cmd, expect in PARAM_EXPECT.items(): + known = set(cmds.get(cmd, {}).get("params", [])) + if known and not expect <= known: + bad.append(f"{cmd}: we send {sorted(expect)}, handler reads {sorted(known)}") +check("control params are the ones the C++ handlers read", + not bad, "\n ".join(bad)) + +print("\n== success code ==") +# The bridge's success code is 200, not 0. A client that accepts only 0 rejects +# every real response from Orca, so pin it here in the guard that ties code to +# evidence. See docs/u1-webui/04-bridge-wcp/01-envelope.md +sswcp_src = open(SSWCP, encoding='utf-8').read() +mock_src = open(MOCK, encoding='utf-8').read() +check("OK_CODE is 200", + re.search(r"export const OK_CODE\s*=\s*200\b", sswcp_src) is not None) +check("isOk() accepts 200", + re.search(r"function isOk\([^)]*\)\s*\{[^}]*OK_CODE", sswcp_src) is not None) +check("the mock host replies with OK_CODE, not a literal 0", + "OK_CODE" in mock_src and not re.search(r"payload:\s*\{\s*code:\s*0\b", mock_src), + "a mock that replies 0 passes its own tests and fails against real Orca") + +print("\n== device record ==") +# DeviceInfo's wire field names come from the C++ struct's serialiser, not from +# anything a JS client would guess. sw_GetLocalDevices returns a BARE ARRAY of +# these, and sw_GetConnectedMachine returns one (or {} when nothing is connected). +APPCFG = os.path.join(ROOT, "src", "libslic3r", "AppConfig.hpp") +dev_fields = set() +if os.path.exists(APPCFG): + m = re.search(r"NLOHMANN_DEFINE_TYPE_INTRUSIVE\(DeviceInfo,(.*?)\)", + open(APPCFG, encoding='utf-8').read(), re.S) + if m: + dev_fields = {f.strip() for f in m.group(1).split(",") if f.strip()} +check("DeviceInfo field list recovered from AppConfig.hpp", bool(dev_fields), + "could not parse NLOHMANN_DEFINE_TYPE_INTRUSIVE(DeviceInfo, ...)") + +declared = dict(re.findall(r"^\s*(NAME|MODEL|SN|IP|CONNECTED|ID|PRESET|NOZZLES):\s*'([^']+)'", + src, re.M)) +unknown = sorted(v for v in declared.values() if dev_fields and v not in dev_fields) +check("every DEVICE.* name is a real DeviceInfo field", + not unknown, f"not in the C++ struct: {unknown}") +check("DEVICE.NAME is dev_name, not a camelCase guess", + declared.get("NAME") == "dev_name", f"got {declared.get('NAME')!r}") +check("the mock replies to sw_GetLocalDevices with a bare array", + re.search(r"case 'sw_GetLocalDevices':\s*\n(?:\s*//[^\n]*\n)*\s*return ok\(printer\.devices\)", + mock_src) is not None, + "real Orca sends `m_res_data = devices` - an array, not {devices: [...]}") + +print("\n== command coverage ==") +# Every command the host dispatches and the bundle references must be either +# implemented or explicitly excluded with a reason. This is the guard that stops +# a gap being found by a person instead of by the build. +import subprocess +cov = subprocess.run([sys.executable, + os.path.join(ROOT, "docs", "u1-webui", "tools", "check_coverage.py"), + "--quiet"], capture_output=True, text=True) +check("every bridge command is classified (implemented or excluded with a reason)", + cov.returncode == 0, cov.stdout.strip() or cov.stderr.strip()) + +print("\n== connection sequence ==") +# The connect path is only correct if the params match what the C++ handlers +# actually read. Each handler rejects a missing/mistyped param explicitly, so the +# required set can be recovered from the guards themselves. +SSWCP_CPP = os.path.join(ROOT, "src", "slic3r", "GUI", "SSWCP.cpp") +cpp = open(SSWCP_CPP, encoding='utf-8').read() if os.path.exists(SSWCP_CPP) else "" +check("SSWCP.cpp readable", bool(cpp)) + +conn_src = "" +JS = os.path.join(WEB, "device_page", "js") +VIEWS = os.path.join(JS, "views") +PANEL_NAMES = ["camera", "control", "task", "filament", "storage", "fault"] + + +def panel_dir(panel): + """Where one panel lives: js/views///, or views//.""" + for dest in sorted(os.listdir(VIEWS)): + d = os.path.join(VIEWS, dest, panel) + if os.path.isdir(d): + return d + if dest == panel and os.path.isdir(os.path.join(VIEWS, dest)): + return os.path.join(VIEWS, dest) + raise KeyError(panel) + + +def view_src(panel): + """One panel's three files, concatenated. + + Everything about a panel lives in one directory now - its declaration, its DOM and + its commands - so a check about a panel asks for the panel rather than for a file. + """ + d = panel_dir(panel) + return "\n".join(open(os.path.join(d, f), encoding="utf-8").read() + for f in sorted(os.listdir(d)) if f.endswith(".js")) + + +def part_src(panel, part): + """One of the three: 'panel', 'view' or 'commands'.""" + return open(os.path.join(panel_dir(panel), f"{panel}-{part}.js"), + encoding="utf-8").read() + + +def all_views(): + """Every view file on the page, for checks that do not care which panel.""" + out = [] + for root, _d, fs in os.walk(VIEWS): + out += [open(os.path.join(root, f), encoding="utf-8").read() + for f in sorted(fs) if f.endswith("-view.js")] + return "\n".join(out) + + +cfile = os.path.join(JS, "core", "connection.js") +if os.path.exists(cfile): + conn_src = open(cfile, encoding='utf-8').read() +check("connection.js exists", bool(conn_src)) + +def required_params(handler): + """Params the C++ handler refuses to run without.""" + m = re.search(r"void SSWCP_\w+::" + handler + r"\(\)\s*\{(.*?)\n\}", cpp, re.S) + if not m: + return set() + return set(re.findall(r'm_param_data\.count\("(\w+)"\)[^;]*?handle_general_fail', m.group(1))) + +for handler, sends in ( + ("sw_create_mqtt_client", {"server_address", "clientId"}), + ("sw_mqtt_connect", {"id"}), + ("sw_mqtt_publish", {"id", "topic", "qos", "payload"}), +): + need = required_params(handler) + missing = sorted(need - sends) + check(f"{handler}: we send every param it requires", + not missing, f"handler also requires {missing}") + +check("create_mqtt_client is given server_address and clientId", + "server_address:" in conn_src and "clientId" in conn_src) +check("set_engine is given engine_id, ip, port and sn", + all(k in conn_src for k in ("engine_id:", "ip,", "port,", "sn,"))) +check("pairing uses port 1884, not the TLS port", + re.search(r"PAIR_PORT\s*=\s*1884", src) is not None) +check("set_engine passes need_reload:false", + "need_reload: false" in conn_src, + "otherwise the host reloads the Device webview mid-sequence") + +print("\n== limits ==") +check("bed temp limit is 0..100 (from dialog_..._heated_bed_temperature_tips)", + re.search(r"bedTemp:\s*\{\s*min:\s*0,\s*max:\s*100", src) is not None) +check("print speed limit is 50..150 (from dialog_..._print_speed_tips)", + re.search(r"printSpeed:\s*\{\s*min:\s*50,\s*max:\s*150", src) is not None) + +print("\n== enum wire values ==") +we = json.load(open(os.path.join(DATA, "wire-enums.json"), encoding='utf-8')) +want_ps = set(we["PrintState"]["values"]) +body_ps = js_block(src, "PRINT_STATE") or "" +have_ps = set(re.findall(r":\s*'([a-z]+)'", body_ps)) +check("PRINT_STATE matches the bundle's wire values", + have_ps == want_ps, + f"page={sorted(have_ps)}\n bundle={sorted(want_ps)}") +check("PRINT_STATE uses 'complete', not 'completed' (the enum-member trap)", + "complete" in have_ps and "completed" not in have_ps) + +print("\n== error catalogue ==") +cat = json.load(open(os.path.join(DATA, "error-catalog.json"), encoding='utf-8')) +subs_js = dict(re.findall(r"'(\d{4})':\s*'([^']*)'", js_block(src, "SUBSYSTEMS") or "")) +check("subsystem table matches the generated catalogue", + subs_js == cat["subsystems"], + f"page={sorted(subs_js)}\n data={sorted(cat['subsystems'])}") + +# spot-check the decoder against real catalogue entries +sample = [c for c in cat["codes"] if c.startswith("0002052300")][:4] +ok = True +for code in sample: + rec = cat["codes"][code] + if rec["subsystem"] != "0523" or rec["unit_index"] != int(code[8:12], 16): + ok = False +check("catalogue codes decode consistently with our scheme", ok, + f"sampled {sample}") + +print("\n== hardware-measured shapes ==") +# These come from a real printer (docs/u1-webui/tools/u1_probe.py), because they are +# pass-throughs whose field names appear in no bundle and no C++ literal. The file is +# the evidence; the point of these checks is that the client cannot drift away from it +# without going red. +HW = os.path.join(DATA, "hardware-shapes.json") +hw = json.load(open(HW, encoding="utf-8")) +app_js = open(os.path.join(JS, "app.js"), encoding="utf-8").read() +# The handlers are one module per panel now, so a check about what a command DOES looks +# in the module that issues it rather than in app.js. +cmd_src = {n: part_src(n, "commands") for n in PANEL_NAMES} +cmd_src["page"] = open(os.path.join(JS, "page-commands.js"), encoding="utf-8").read() +cmd_src["device"] = open(os.path.join(JS, "widgets", "rail-commands.js"), + encoding="utf-8").read() +cmd_src["util"] = open(os.path.join(JS, "core", "thumbs.js"), encoding="utf-8").read() +# Where a check does not care WHICH module, only that some reachable command does it. +cmds_all = "\n".join(cmd_src.values()) +ui_js = all_views() + +def js_const(name, source=src): + m = re.search(r"export const " + re.escape(name) + r"\s*=\s*([^;]+);", source) + return m.group(1).strip().strip("'\"") if m else None + +cam = hw["camera"] +check("CAMERA_DOMAIN is the value the printer accepted", + js_const("CAMERA_DOMAIN") == cam["domain_accepted"], + f"page={js_const('CAMERA_DOMAIN')!r} printer accepted={cam['domain_accepted']!r}") +check("CAMERA_DOMAIN is not the value the printer refused", + js_const("CAMERA_DOMAIN") != cam["domain_rejected"]["value"]) +check("the frame is addressed on Moonraker's port, not the web UI's", + js_const("MOONRAKER_HTTP_PORT") == str(cam["frame_http"]["port"]), + f"page={js_const('MOONRAKER_HTTP_PORT')} verified={cam['frame_http']['port']}") +check("frame root and filename match the verified URL", + js_const("CAMERA_FRAME_ROOT") == cam["frame_http"]["root"] + and js_const("CAMERA_FRAME_FILE") == cam["frame_http"]["file"]) +check("the client does not wait for an MQTT frame push", + not cam["frames_pushed_over_mqtt"] and "pickFrame" not in ui_js, + "pickFrame() sniffed base64 out of a push that never arrives") +check("cameraFrameUrl builds the HTTP URL instead", + "cameraFrameUrl" in ui_js and "/server/files/" in ui_js) + +th = hw["thumbnails"] +b64_cmd, path_cmd = "FILE_THUMBS_B64", "FILE_THUMBNAILS" +_thumbs = cmd_src["storage"] +i_b64, i_path = _thumbs.find("CMD." + b64_cmd), _thumbs.find("CMD." + path_cmd) +check("the thumbnail command that returns bytes is asked first", + 0 <= i_b64 < i_path, + "server.files.thumbnails succeeds while returning only paths, so asking it " + "first means the catch-fallback never fires and no image is ever shown") +check("pickThumb looks for the field the printer actually fills", + th["server.files.thumbnails_base64"]["image_field"] in + re.search(r"for \(const k of \[([^\]]*)\]", cmd_src["util"]).group(1)) +check("pickThumb knows the timelapse spelling too", + hw["timelapse"]["thumbnail_direct_adds"] in cmd_src["util"]) +check("the timelapse listing is read by its real field name", + "instances" in cmd_src["storage"] + and hw["timelapse"]["thumbnail_direct_adds"] in ui_js) + +check("the simulator refuses the domain the printer refuses", + str(cam["domain_rejected"]["code"]) in open(MOCK, encoding="utf-8").read(), + "the mock accepted anything, so it could not have caught this") + +print("\n== response shapes vs the wire ==") +# The class of bug this section exists for: the simulator used to answer with what the +# CLIENT expected instead of what the PRINTER sends, so every mismatch passed the browser +# suite and failed on hardware. These re-derive the wire contract from SSWCP.cpp and from +# the captured payloads, so a client that drifts back goes red. +SRC_CPP = os.path.join(ROOT, "src", "slic3r", "GUI", "SSWCP.cpp") +cpp = open(SRC_CPP, encoding="utf-8", errors="replace").read() + +starts = [(m.group(1), m.end()) for m in + re.finditer(r"void\s+SSWCP_\w+::(sw_\w+)\s*\([^)]*\)\s*\{", cpp)] +derived = set() +for i, (name, pos) in enumerate(starts): + end = starts[i + 1][1] if i + 1 < len(starts) else len(cpp) + if "on_mqtt_msg_arrived" in cpp[pos:end]: + derived.add(name) + +listed = set(re.findall(r"'(sw_\w+)'", + re.search(r"export const PRINTER_BACKED = new Set\(\[(.*?)\]\);", + src, re.S).group(1))) +check("PRINTER_BACKED matches the handlers that pass a printer reply through", + listed == derived, + f"only in page={sorted(listed - derived)}\n only in C++={sorted(derived - listed)}") + +check("the client unwraps the JSON-RPC envelope", + "unwrapRpc" in open(SSWCP, encoding="utf-8").read(), + "payload.data is the whole {jsonrpc,result,id} for a printer-backed command") + +mock_src = open(MOCK, encoding="utf-8").read() +check("the simulator wraps printer replies the way Orca does", + "PRINTER_BACKED.has(cmd)" in mock_src and "jsonrpc: '2.0'" in mock_src, + "an unwrapping simulator turns a hardware bug into a passing test") + +# colours: the two real forms, cross-checked against each other in the capture +hw_colors = hw.get("_filament_colors") or {} +# The two forms used to be parallel literals. They are one now - the rgba strings are +# the simulator's state (so a drive script can wipe a head the way multiACE does) and the +# ARGB integer is derived from them - which is a stronger guarantee than two literals +# agreeing, because they cannot drift apart. What still has to hold is the SHAPE: bare +# RRGGBBAA with no '#', and an integer with the alpha in the top byte. +check("the simulator sends filament colour in the printer's forms, not CSS", + re.search(r"filamentColorRgba:\s*\[([^\]]*)\]", mock_src) is not None + and "#" not in re.search(r"filamentColorRgba:\s*\[([^\]]*)\]", mock_src).group(1) + and "E03131FF" in mock_src and "<< 24 >>> 0" in mock_src, + "'#RRGGBBAA' is not what the wire carries, and filament_color is an ARGB int") +check("a colour normaliser exists and is used", + "export function cssColor" in src + and "cssColor" in part_src("filament", "view"), + "assigning an ARGB int to style.background is silently dropped") + +# The two dryer arguments the machine disagreed with, and both answered `ok` first. +# +# These used to be checked by reading the macro line off the dryer dialog against a real +# printer. The copy pass replaced that line with prose - a dialog says what a control is, +# not what it sends - and took the only check of either fact with it, silently, because +# the assertion that broke was in a hardware suite nobody had re-run. They live here now: +# it is the call site that has to be right, and this is the suite that reads call sites. +fil_cmds = part_src("filament", "commands") +check("the dryer sends MINUTES, which is what ACE_DRY reads", + re.search(r"DURATION:\s*Math\.round\(hours\s*\*\s*DRY_MINUTES_PER_HOUR\)", fil_cmds) + is not None and "DRY_MINUTES_PER_HOUR = 60" in + open(os.path.join(SHARED, "js", "multiACE.js"), encoding="utf-8").read(), + "measured: DURATION=3 came back as 180 and DURATION=240 as 14400. The dialog " + "offers HOURS, so sending its number unconverted asks for four minutes of drying " + "and the machine answers `ok`") +# `THRESHOLD:` as a KEY, because that is the only way one gets sent - aceLine() renders +# an object's keys as `KEY=value`. The word itself is in the file, in the comment that +# says why it is not used, and a check that cannot tell those apart is a check that +# punishes writing the reason down. +check("automatic drying uses ENABLE and RH_START, never THRESHOLD", + "RH_START" in fil_cmds and "ENABLE: 1" in fil_cmds and "THRESHOLD:" not in fil_cmds, + "measured: ACE_SET_AUTO_DRY THRESHOLD=52 returns `ok` and changes nothing") + +# the control panel needs `toolhead`, which was never subscribed +sub_block = js_block(src, "SUBSCRIBE_OBJECTS") or "" +state_src = open(os.path.join(SHARED, "js", "state.js"), encoding="utf-8").read() +check("the subscription list is still the bundle's - `toolhead` is NOT added", + "'toolhead'" not in sub_block, + "the shipped page does not subscribe it; adding it drifts from the original") +check("the active tool is derived from the extruder that reports ACTIVATE", + "'ACTIVATE'" in state_src, + "the Tool buttons must follow the machine, and this is the subscribed source") +check("the axis readout uses motion_report.live_position", + "live_position" in state_src, + "positions are available without subscribing `toolhead`") + +ui_src = all_views() +shell_src = open(os.path.join(JS, "shell.js"), encoding="utf-8").read() +panel_src = {n: part_src(n, "panel") for n in PANEL_NAMES} +# Every panel's declaration, concatenated: several checks below ask "does any control +# on the page do X", and a panel module is where that is now written down. +panels_all = "\n".join(panel_src.values()) +pending_src = open(os.path.join(JS, "core", "pending.js"), encoding="utf-8").read() +render_src = open(os.path.join(JS, "core", "render.js"), encoding="utf-8").read() +# Having a printer on the other end of the bridge - the connect path, staleness, retry, +# the heartbeat and the state stream - left app.js for its own module. +session_src = open(os.path.join(JS, "core", "session.js"), encoding="utf-8").read() +app_src = open(os.path.join(JS, "app.js"), encoding="utf-8").read() +proto_src = open(PROTO, encoding="utf-8").read() +# Was: `handlers.selectTool` must not appear in ui.js - which was true while the +# selection was assigned inline there. It is a page-state handler now, so the check +# asks what it always meant: that choosing a head sends the machine nothing. +_sel = re.search(r"selectTool:.*?\n", cmd_src["control"], re.S) +check("choosing which toolhead to jog does NOT move the machine", + _sel is not None and "send(" not in _sel.group(0) and "CMD." not in _sel.group(0), + "selection is a UI choice; a toolchange must be deliberate, not a side effect") +check("changing the live toolhead is its own action", + "handlers.pickTool" in ui_src and "pickTool:" in cmd_src["control"], + "there must be a dedicated control, not an overloaded segmented button") +check("a toolchange blocks the surface while the gantry moves", + "openBlockingDialog" in app_src, + "a second command sent mid-change is not what the user meant") +check("the toolchange is confirmed by the machine, not by the ack", + "activeIndex === idx" in cmd_src["control"], + "the G-code ack only says the command was queued") + +print("\n== panel invariants ==") +css = open(os.path.join(WEB, "device_page", "css", "device.css"), encoding="utf-8").read() + +check("a running camera can be stopped", + "cam-play" in ui_src and "handlers.stopCamera()" in ui_src + and "data-on" in css, + "one control inside the viewport, for both states - a live-view branch that " + "returned early once left a running camera with no way to stop it") +check("the camera body is the viewport, not an illustration", + ".cam-view" in css and "background: #000" in css + and "CAMERA_TEXT" in ui_src, + "the shipped page shows a dark viewport with a round button in it, and says " + "'Camera not on'; the illustration answers 'there is no printer'") +# Header geometry, measured off the shipped page in pixels: title at 28, separator at +# 99, selected pill at 117. A 20px separator margin put the Camera tabs 13px right. +check("the header separator is spaced as measured, not rounded to 20", + re.search(r"\.panel-head \.sep\s*\{[^}]*margin:\s*0 13px 0 14px", css) is not None, + "title 28 + width 57 + 14 -> separator 99, + 5 + 13 -> pill 117") +check("a tab meets the card and a refresh button fills the header", + "align-self: flex-end" in css and "border-radius: 6px 6px 0 0" in css, + "the shipped pill is inset at the TOP only, so its white runs into the body; " + "centring 36px in a 40px header leaves grey underneath and it reads as a button") + +# Driven on the shipped page by clicking its own refresh pill: it re-subscribes, +# re-declares the filter, re-snapshots, and re-reads system info, file status, the +# exception state and the file roots. Both of its pills send the identical set. +check("refresh re-reads the machine, not Orca's device book", + "async function refreshAll" in cmd_src["page"] + and "refreshAll()" in cmd_src["page"] + and "startStateStream('refresh')" in cmd_src["page"], + "the buttons called refresh(), which asks for the device list - two commands " + "that say nothing about the printer, so pressing refresh changed nothing visible") + +# A person waiting on a toolhead wants to know it is working, not an axis-by-axis +# account - and Klipper clears homed_axes as it re-homes, so the account went backwards. +check("storage is one scrolling grid with previews, for every kind", + "stor-grid" in ui_src and "jobThumbUrl" in ui_src + and "'Reprint'" in ui_src and "grid-auto-rows: max-content" in css, + "recordings, prints and two file roots were four shapes behind three tabs on two " + "panels; they are all things on the machine you might want to look at") +# Four disciplines answered "the state changed, what do I do with the DOM", and every +# focus/scroll/anchor bug in the log came from the differences between them. +check("there is one rebuild guard, not one attribute name per panel", + "export function rebuildOn" in render_src + and "dataset.sig" not in ui_src and "dataset.state ===" not in ui_src + and "dataset.code ===" not in ui_src, + "data-built, data-sig, data-state and data-code were four spellings of one idea") +check("a list is reconciled by key rather than thrown away and rebuilt", + "export function keyedList" in render_src and "keyedList(" in ui_src + and "scrollTop = at" not in ui_src, + "the container is never replaced, so its scroll position needs no saving") +check("a recurring fault shows again", + "delete root.dataset.built" in ui_src, + "the old guard compared a code on the way in and never reset it on the way out, " + "so a fault that cleared and came back matched its own stale key and stayed hidden") + +check("a print whose file is gone cannot be reprinted", + "it.exists === false" in ui_src, + "history keeps the record after the file is deleted; Reprint would send a " + "filename the machine cannot open") +check("storage is its own destination, not a panel inside device control", + "view: 'storage'" in panel_src["storage"] and "view-${v.id}" in shell_src + and "#view-control[hidden]" in css, + "and #view-control must opt back into [hidden] explicitly: display:contents " + "beats the UA rule, so the panels went on rendering underneath") +check("history thumbnails come over HTTP, because the metadata carries paths", + "export function jobThumbUrl" in proto_src + and "server/files/gcodes/" in proto_src, + "metadata.thumbnails is [{width, height, size, relative_path}] - paths, no bytes, " + "the same trap the file browser hit") +check("machine files can be viewed and printed, not deleted", + "handlers.deleteFile" not in ui_src and "deleteFile:" not in app_src, + "Delete sat a few pixels from a one-click Print and only one of them is reversible") +# A `display` rule outranks the UA stylesheet's [hidden]{display:none}, so an element +# styled that way and hidden from JS goes on showing. This was two named cases - .fault +# and .stor-foot - and naming them is exactly what let a third through: #view-storage +# kept the Storage panel's header bar on the Device control page. +# +# Derived now, not named: every simple id/class selector in the stylesheet that sets +# `display` must either have a [hidden] rule of its own, or never be hidden from JS. +# run_webkit.py asks the same question of the running page, which is the stronger half - +# this one just fails without a browser. +_hides = set(re.findall(r"([A-Za-z_$][\w$]*)\.hidden\s*=", ui_src + app_src + shell_src)) \ + | set(re.findall(r"hiddenAtRest", panels_all)) +_display = set(re.findall(r"^(#[\w-]+|\.[\w-]+)\s*\{[^}]*\bdisplay:", css, re.M)) +_missing = sorted(sel for sel in _display + if f"{sel}[hidden]" not in css + and (sel.lstrip("#.").replace("-", "") in + {h.lower() for h in _hides} | {"viewstorage", "viewcontrol"})) +check("anything hidden from JS opts out of its own display rule", + not _missing, + f"missing a [hidden] rule: {_missing} - setting .hidden on these does nothing") +# Two handlers had been sitting in the bag with no caller - abortBedMesh, which cannot +# have one, and showFiles, whose own comment called it "the one useful action from an +# idle job card". check_coverage found the first because it named a command; the second +# names none, so nothing could. This asks the question directly instead. +_uncalled = [] +# session.js counts: it is where cross-cutting reads are started - queryException() on +# connect, and syncBays() when the state stream comes up - and leaving it out reported a +# command that IS reached as unreachable. +_callers = ui_src + shell_src + app_src + panels_all + cmds_all + session_src +for _mod, _src in sorted(cmd_src.items()): + if _mod == "util": + continue + _ret = re.search(r"\n return \{\n(.*)\n \};\n\}", _src, re.S) + if not _ret: + _uncalled.append(f"{_mod}: no return block found") + continue + for _h in re.findall(r"^ ([a-zA-Z_$][\w$]*)[:,]", _ret.group(1), re.M): + # a handler is reached as handlers.X (a panel or a renderer) or cmd.X (another + # module); its own definition is not a call site + n = len(re.findall(rf"(?:handlers|cmd)\.{re.escape(_h)}\b", _callers)) + if n == 0: + _uncalled.append(f"{_mod}.{_h}") +check("every command a panel is handed can actually be reached", + not _uncalled, + f"nothing calls: {', '.join(_uncalled)} - either wire a control to it or delete it") + +# A popover lives on document.body, outside every panel paint() walks, so it draws the +# state it was opened with and then goes stale. Invisible for a slider, whose thumb +# carries its own position - and plainly broken for anything with a selected-state +# marker, which is how the camera's settings shipped: every control worked and none of +# them moved its own tick. The mechanism is what is checked, because the instance was +# only found by someone using it. +overlay_src = open(os.path.join(JS, "core", "overlay.js"), encoding="utf-8").read() +check("an open popover is repainted from state, and only when it changes", + "export function repaintPopover" in overlay_src + and "repaintPopover()" in shell_src + and "openPopSeen" in overlay_src, + "a popover outside the panel tree draws the state it was opened with for ever") +# The guard is the other half: rebuilding every frame takes focus, hover and a slider +# mid-drag with it, which is the bug render.js exists to prevent. +check("and a popover that declares no signature is never rebuilt", + re.search(r"if \(!openPopEl \|\| !openPopSig \|\| !openPopBuild\) return;", overlay_src) + is not None, + "the Control panel's four sliders pass no sig and must keep behaving as they did") + +check("the storage refresh is an action, not another picker", + "'icon-btn'" in panel_src["storage"] and ".icon-btn" in css, + "sitting in the row of kind buttons it read as another one of them") + +# The reachability half of check_coverage.py found `handlers.showFiles` defined and +# called by nothing - its own comment calls it "the one useful action from an idle job +# card". The card had a disabled play button titled "Nothing to start here" instead. +check("an idle job card is not a dead end", + "handlers.showFiles()" in ui_src and "showFiles:" in cmd_src["task"] + and "main_btn.disabled" not in ui_src, + "with nothing running, the one useful action is to go and find something to print") + +# `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 line above does exactly +# that - it opens Storage on gcodes while the header went on highlighting time-lapses. +check("a header picker follows the state, not the click that changed it", + "headerSync.push(sync)" in shell_src and "headerSync.forEach" in shell_src + and "b.onclick = () => spec.on(ctx, it.value);" in shell_src, + "a selection can change without a click, and the header has no renderer of its " + "own to correct it afterwards") + +# `job-badge` is declared in task-panel.js now rather than emitted by the view: the +# status word moved to the panel header, where a panel says what it is and what it is +# doing on one line. The check follows it - what it holds is that there is ONE card, +# zeroed, and not a second card for an idle machine. +check("the job card is one card at every state, not two", + "job-badge" in ui_src + panels_all and "job-bar" in ui_src + and "'No active print'" not in ui_src, # the emitted literal, not the comment + "an idle machine and a printing one differ in the numbers, not the furniture") +# The card was a stack in a panel that is a band: 306px on a 152px thumbnail, a 40px +# grey percentage, a row repeating the machine name the rail already shows, and a row of +# its own for two buttons. 128 now. The saving that mattered most is the last one. +check("the job card's buttons ride the progress bar's line", + "job-actions" in ui_src and "job-row" in ui_src + and re.search(r"\.job-row\s*\{[^}]*display:\s*flex", css) is not None, + "a 6px bar and a 30px button cost 70 stacked and 30 side by side") +app_src = open(os.path.join(JS, "app.js"), encoding="utf-8").read() + +# `.fault{display:flex}` is a class rule and beats the UA stylesheet's +# [hidden]{display:none}, so setting .hidden in JS did nothing and an empty banner +# sat above the panels permanently. Any element the page hides by attribute needs this. +check("the fault banner honours [hidden]", + re.search(r"\.fault\[hidden\]\s*\{[^}]*display:\s*none", css) is not None, + "display:flex on .fault overrides the UA [hidden] rule") + +# The quick settings must not share a glyph - sharing one made the purifier read as a +# second fan. The shipped bundle has a distinct icon for each, so use them. +# any icon name the module names, however it is passed - tile(), icon(), sliderRow() +icons = set(re.findall(r"'(icon[A-Z]\w*)'", ui_src)) +wanted = {"iconSpeed", "iconMainCooling", "iconAuxiliaryCooling", "iconPurifier", "iconLed"} +check("each quick setting uses its own shipped icon", + wanted <= icons, + f"missing {sorted(wanted - icons)}") +for name in sorted(wanted | {f"iconExtruder{i}" for i in range(1, 5)} | {"iconHotBedTemperature"}): + check(f"icon asset exists: {name}.svg", + os.path.exists(os.path.join(WEB, "device_page", "icons", f"{name}.svg"))) + +check("quick settings open an anchored popover, not a centred sheet", + "openPopover" in ui_src and ui_src.count("openPopover(anchor") >= 3, + "a modal hides the readings the user is acting on") + +# temperature targets edit in place, and the row is the hover target +check("temperature targets are real number inputs with the machine's limits", + "tgt.type = 'number'" in ui_src and "tgt.min" in ui_src and "tgt.max" in ui_src, + "an input brings the I-beam, keyboard and validation; a styled span brings none") +check("the whole row reveals the editable target, not just the input", + ".status-row:hover .tgt" in css, + "a 30px number is not a target you find by accident") +check("hover paint cannot mask the editing or invalid state", + ":not(:focus):not(:invalid)" in css, + "a descendant selector outranks .tgt:focus and .tgt:invalid") + +# the wheel: the ring is the step, so there is no separate step selector +check("the jog wheel carries three step bands", + re.search(r"JOG_STEPS\s*=\s*\[10,\s*1,\s*0\.1\]", ui_src) is not None, + "the ring is the step size; a separate selector would reintroduce hidden state") +check("a sector is a real annular path, not a box", + "A${r2" in ui_src or "sector(" in ui_src, + "rectangles would put the corners in the wrong quadrant") +check("an unhomed axis blocks the jog and says so", + "data-blocked" in ui_src and "allHomed === false" in ui_src, + "Klipper refuses the move silently, which looks like a dead button") + +# absent hardware +check("an absent purifier disables its own modes", + "purAbsent" in ui_src and 'aria-disabled' in ui_src, + "a control for hardware that is not attached is a claim the machine would refuse") + +# A class the JS emits and the CSS never styles is silent: the element renders, just +# unstyled. `.status-row` shipped as `trow` for a commit and the temperature rows lost +# their layout and grew native number spinners, with nothing failing anywhere. +js_classes = set() +for m in re.finditer(r"el\('\w+',\s*'([a-z0-9 \-]+)'", ui_src): + js_classes |= set(m.group(1).split()) +for m in re.finditer(r"\.className\s*=\s*'([a-z0-9 \-]+)'", ui_src): + js_classes |= set(m.group(1).split()) +css_classes = set(re.findall(r"\.([a-z][a-z0-9\-]+)", css)) +unstyled = sorted(c for c in js_classes if c not in css_classes) +check("every class the Device page emits is styled somewhere", + not unstyled, f"unstyled: {unstyled}") + +# The page can be run against a real printer with no Orca (run_webkit.py --real), +# which only works while every command it issues is either mapped to a printer method +# or answered locally. A new command silently falls through as "not dispatched". +TOOLS = os.path.join(ROOT, "docs", "u1-webui", "tools") +bridge_table = json.load(open(os.path.join(DATA, "bridge-methods.json"), encoding="utf-8")) +bridge_src = open(os.path.join(TOOLS, "u1_bridge.py"), encoding="utf-8").read() +local_cmds = set(re.findall(r'"(sw_\w+)":\s*(?:self\.|lambda)', bridge_src)) +refused = set(re.findall(r'"(sw_\w+)":\s*"', bridge_src)) +wire = dict(re.findall(r"(\w+):\s*'(sw_\w+)'", + open(PROTO, encoding="utf-8").read())) +page_cmds = {wire[m] for m in re.findall(r"CMD\.(\w+)", app_src + conn_src) if m in wire} +unanswered = sorted(c for c in page_cmds + if c not in local_cmds and c not in refused + and not bridge_table.get(c, {}).get("method")) +check("every command the page issues is answered or refused in as many words", + not unanswered, + f"no printer method, no local handler, no written reason: {unanswered}") +check("the bridge table is generated from the C++, not written down", + "extract_bridge_methods.py" in open(os.path.join(TOOLS, "run_all.py"), + encoding="utf-8").read(), + "a hand-kept mapping drifts from the source it claims to describe") +check("the transport commands are NOT mapped to a printer method", + not any(bridge_table.get(c, {}).get("method") + for c in ("sw_mqtt_publish", "sw_mqtt_subscribe", "sw_create_mqtt_client")), + "the page drives the socket itself; these are answered by the host, and a " + "mis-slice once mapped sw_mqtt_publish to machine.system_info") + +# A printer that has gone away stops sending, so nothing repaints to say so. +check("connected is a question about now, not about ever", + "function isLive" in session_src and "state.age(" in session_src + and "state.lastUpdate > 0 &&" not in session_src, + "lastUpdate > 0 means the machine spoke once; it left a rebooting printer " + "showing as connected with its last snapshot presented as current") +check("and something asks it on a clock of its own", + "function superviseConnection" in session_src and "setInterval" in session_src, + "every other repaint is triggered by something arriving, which is exactly what " + "stops") +check("the heartbeat's answer is used rather than discarded", + "lastPong" in session_src, + "it is the only evidence a machine with nothing to report is still there") + +check("a printer that is not there yet is retried, not given up on", + "RETRY_MS" in session_src and "function reconnect" in session_src + and "retryAt" in session_src, + "one attempt at boot meant starting the app before the printer left it dark " + "until a reload, and a rebooted printer never came back") +check("the retry backs off instead of polling", + re.search(r"RETRY_MS\s*=\s*\[[^\]]+\]", session_src) is not None + and "Math.min(retryStep + 1" in session_src) +check("a dead engine is dropped before a new one is made", + re.search(r"function reconnect\(\)[^}]*disconnectDevice", session_src, re.S) is not None, + "the old socket outlives the session that owned it") +check("Connect is offered on live evidence, not on the persisted flag", + "session.engineId && session.isLive()" in app_src, + "DeviceInfo.connected is force-cleared on every config save, so it answers " + "neither 'is it there' nor 'do we have a session'") + +check("the control row is capped so the gaps cannot drift with panel width", + re.search(r"\.control-grid\s*\{[^}]*max-width:", css) is not None, + "space-between hands every extra pixel to the gaps") + +# The status card is repainted on every state push - about once a second. Rebuilding it +# destroyed whatever was being typed, which made entering a temperature a race. +check("the status card is not rebuilt on every state push", + "root.dataset.built" in ui_src, + "innerHTML='' once a second takes the focused input with it") +check("a focused target is never overwritten by an incoming reading", + "document.activeElement !== tgt" in ui_src, + "the machine's value must not land in a field being typed into") +check("the row's target is read from the DOM, not from a closure", + "tgt.dataset.target" in ui_src, + "a captured value goes stale as soon as the printer changes it") + +# Two faults found on hardware, both about the field saying something the machine did +# not: a 0 sitting where nothing was set, and a value written back over one just sent. +check("a heater that is off shows a dash, not a zero", + "tgt.placeholder" in ui_src and "function showTarget" in ui_src, + "an idle row carried a 0 that had to be deleted before anything could be typed") +check("clicking the target selects it, so a set value is typed over rather than edited", + "tgt.select()" in ui_src and "claiming" in ui_src, + "focus alone does not survive the mouseup that follows it") +check("a sent target is held on screen until the machine echoes it back", + "pending.track(" in ui_src and "CONFIRM_MS" in pending_src, + "the next state push lands before the printer has reported the change") +check("a setpoint the machine never confirms is given up on and said so", + "e.state = 'lost'" in pending_src and '[data-pend="lost"]' in css, + "an accepted command that changes nothing is exactly what a silent refusal " + "looks like, and it must not sit there looking applied") +check("a failed command stops the row waiting immediately", + "ok === false" in pending_src and "async function setpoint" in app_src, + "the row cannot see a command that never left") + +# Found by asking the structural question rather than by using the page: the request +# and the mirror had been confused in three separate controls, each fixed its own way. +check("request-against-mirror is one mechanism, not one per control", + "class Pending" in pending_src + and "pending.track(" in ui_src and "pending.track('led'" in cmd_src["control"], + "tool selection, a temperature and the chamber light all had this bug; the " + "first two were fixed separately and the third was not fixed at all") +check("the chamber light shows what was asked for until the printer echoes it", + "ctx.pending.resolve('led'" in ui_src + and '.qtile[data-pend="1"] .switch' in css, + "measured against the printer: the echo took 236ms, 2029ms and 620ms over three " + "runs, and the switch reverted under the mirror in two of them") +check("a control reads what is shown, not what the machine says, when it acts", + "const shown = ctx.pending.resolve('led'" in ui_src, + "clicking twice inside the echo window must undo the first click, not re-send it") +check("the reading reserves three digits so the row cannot shift under it", + re.search(r"\.status-row \.cur\s*\{[^}]*min-width:\s*27px[^}]*text-align:\s*right", + css, re.S) is not None, + "99 -> 100 moved the slash, the target and the unit one digit to the right") +check("the pending mark outranks the row's own hover paint", + '.status-row[data-pend="1"] .tgt:not(:focus):not(:invalid)' in css, + "a descendant hover rule at the same weight wins on source order") + +# There was no sign at all that a heater was working: the reading climbs a degree a +# second, which reads as nothing happening. +check("the row shows whether the heater is working", + "function heatState(" in ui_src and "row.dataset.heat" in ui_src + and '[data-heat="heating"]' in css and '[data-heat="cooling"]' in css, + "a temperature that was accepted and one that was ignored looked identical") +check("the heat bar measures the ramp from where it started", + "function rampProgress(" in ui_src and "dataset.start" in ui_src, + "progress against 0 would jump about whenever the target changed") +check("the heat states are named in the palette, not hard-coded at the rule", + "--heat:" in css and "--cool:" in css, + "this surface must define every token it styles against") + +# Granularity and labelling are different questions: a step-1 fan labelled at every +# step printed 101 ticks across the panel. +check("slider ticks are chosen separately from the step", + re.search(r"function sliderRow\([^)]*ticks\)", ui_src) is not None + and "FAN_TICKS" in ui_src, + "labelling every step is only right when the steps are few") +check("the fan is labelled at quarters, not at every percent", + re.search(r"FAN_TICKS\s*=\s*\[\s*0\s*,\s*25\s*,\s*50\s*,\s*75\s*,\s*100\s*\]", ui_src) + is not None) + +check("the Control panel sizes to its content", + re.search(r"\.control-body\s*\{[^}]*aspect-ratio:\s*auto", css) is not None, + "inheriting .panel-body's ratio only bought empty space below the wheel") + +# Both commands were recovered from the shipped Flutter bundle, which is the printer's +# own UI. Neither appears in printer.gcode.help - they register without help strings, as +# T0..T3 do - so there is nothing else to check them against. An earlier guess of `T-1` +# for park did nothing at all. +check("picking a toolhead sends T A0, as the printer's own UI does", + re.search(r"`T\$\{idx\} A0`", cmd_src["control"]) is not None, + "a bare T is not what the firmware's own macro sends either") +check("parking uses PARK_EXTRUDER, numbered like Klipper's extruders", + "PARK_EXTRUDER" in cmd_src["control"] and "idx === 0 ? '' : idx" in cmd_src["control"], + "PARK_EXTRUDER for 0, PARK_EXTRUDER1..3 above it") +check("one button, and its meaning follows the selected toolhead's state", + "isLive ? 'Park extruder' : 'Pick extruder'" in ui_src + and "handlers.parkTool(activeTool)" in ui_src + and "handlers.pickTool(activeTool)" in ui_src + and "pickBtn" not in ui_src and "parkBtn" not in ui_src, + "the shipped page carries one button per toolhead, reading Park when that head " + "is ACTIVATE and Pick when it is not") +check("park still checks the machine before acting", + "state.toolhead().activeIndex" in cmd_src["control"], + "only a live head can be parked, whatever the caller passes") +check("picking no longer opens a dialog to re-ask the same question", + "pickExtruder(" not in ui_src, + "the selection above is already a deliberate click") + +# A constant used but never declared is a ReferenceError at the moment the user opens +# the panel, and nothing before that notices: it is valid syntax, so check_syntax passes, +# and it only runs on a click. FAN_TICKS shipped that way for one commit, because the +# edit meant to declare it silently matched nothing. +def _strip_js(src): + """Remove comments and string/template literals so identifiers can be counted.""" + out, i, n = [], 0, len(src) + while i < n: + c = src[i] + if c == "/" and i + 1 < n and src[i + 1] == "/": + i = src.find("\n", i) + if i == -1: break + elif c == "/" and i + 1 < n and src[i + 1] == "*": + i = src.find("*/", i) + if i == -1: break + i += 2 + elif c in "\"'`": + q, i = c, i + 1 + while i < n and src[i] != q: + i += 2 if src[i] == "\\" else 1 + i += 1 + else: + out.append(c); i += 1 + return "".join(out) + +for label, path in (("device_page/js/app.js", os.path.join(JS, "app.js")), + ("device_page/js/shell.js", os.path.join(JS, "shell.js"))): + src_js = open(path, encoding="utf-8").read() + code = _strip_js(src_js) + declared = set(re.findall(r"(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)", code)) + for m in re.finditer(r"import\s*\{([^}]*)\}", src_js, re.S): + declared |= {x.strip().split(" as ")[-1].strip() + for x in m.group(1).split(",") if x.strip()} + # not preceded by a dot: CMD.SEND_GCODES is a property of an import, + # not an identifier this module has to declare + used = set(re.findall(r"(? state.toolhead().allHomed === true" not in cmd_src["control"] + and "if (done && done())" in cmd_src["control"] + and "else if (sawBusy)" in cmd_src["control"], + "homed_axes read 'xyz' for all 42s of a measured G28: as a done() it is true " + "before the machine has moved, and it wins the race against every other test") +check("and a toolchange still ends on its own goal, which it really has", + 'done: () => state.toolhead().activeIndex === idx' in cmd_src["control"] + and 'done: () => state.toolhead().activeIndex == null' in cmd_src["control"], + "extruder*.state going ACTIVATE is set by the change completing; dropping done() " + "for these would make a 31s toolchange wait on idle_timeout for no reason") +check("a wait shows its own line rather than 'Moving' and 'Working'", + "reason.vague" in cmd_src["control"] and "vague:" in state_src, + "motion starts and stops - the two generic labels alternated eight times across " + "the measured G28, which reads as a machine changing its mind") +check("homing is what the wait reports during a toolchange", + "Homing axes" in state_src and "homed_axes" in state_src, + "homed_axes walking '' -> z -> y -> xy is the only thing that reports the long " + "phase of a toolchange; both previously-trusted sources are silent for it") +check("but not axis by axis", + "done.join" not in state_src, + "Klipper CLEARS homed_axes as it re-homes, so naming the axes done reported Z " + "and then dropped it - and nobody waiting on a toolhead wants the account") +check("idle_timeout counts as busy, having been measured bracketing the operation", + "idle.state === 'Printing'" in state_src, + "excluded before on a lingering read; the capture shows it Printing at 0.7s and " + "Ready at 32.7s, and every wait has its own done() besides") +check("the wait polls the fields that actually move", + "activating_move" in session_src and "homed_axes" in session_src, + "neither is on the subscribed stream with the fields the page asks for") + +# The bed is the Z axis and Z measures the nozzle-to-bed gap, so raising the bed is a +# NEGATIVE Z move. The machine's own config settles it: stepper_z homes positive to +# position_endstop 275, and PRINT_END drives to Z200 for clearance - both only make +# sense if a larger Z is a wider gap. An up arrow sending Z+ moved the bed away. +check("the bed's up arrow sends a negative Z", + "const dz = -dir * v;" in ui_src, + "Z+ widens the gap, which lowers the bed on this machine") +check("the bed buttons say which way the bed goes and what Z is sent", + "toward the nozzle" in ui_src and "away from the nozzle" in ui_src, + "an arrow alone is ambiguous when the moving part is the bed, not the head") + +# --------------------------------------------------------------------------- +# No hover on this page carries the machine's schema. +# +# CLAUDE.md: UI copy says what a control is or what state it is in, in words - not in +# the machine's schema. A field name (`print_stats.state`, `hum_level2`, `channel_state`) +# says where the page READ something, which is the page's business; a macro name says +# what will be sent, which is the trace pane's. Both have been trimmed out of hovers by +# hand at least twice - 073d67f2c and a946c250b - and the second one left `job-band.js` +# asserting the removed tooltip for four commits, red, with nothing to force a look. +# +# So it is derived. Every hover site is found, its value's STRING LITERALS are read, and +# a schema-shaped token in one is a failure. Only literals: a title assembled from an +# expression (`it.filename`, `spec.title(ctx)`) cannot be judged from the source, and +# job-band.js asks the same question of the running DOM, where it can. +# +# The known limit: `ace` and `job` are indistinguishable from English, so a hover naming +# a bare lowercase object still gets through. Underscores, dots and SHOUTING do not. +def _no_comments(src): + """Source with comments removed - prose ABOUT a field is not copy carrying one.""" + out, i, n = [], 0, len(src) + while i < n: + c = src[i] + if c in "'\"`": + j = i + 1 + while j < n and src[j] != c: + j += 2 if src[j] == "\\" else 1 + out.append(src[i:j + 1]); i = j + 1; continue + if src.startswith("//", i): + j = src.find("\n", i); j = n if j < 0 else j + out.append("\n"); i = j + 1; continue + if src.startswith("/*", i): + j = src.find("*/", i); j = n if j < 0 else j + 2 + out.append(" "); i = j; continue + out.append(c); i += 1 + return "".join(out) + + +def _value(src, i): + """The property value at i: to a , ; or closing bracket at depth zero.""" + depth, j, n = 0, i, len(src) + while j < n: + c = src[j] + if c in "'\"`": + j += 1 + while j < n and src[j] != c: + j += 2 if src[j] == "\\" else 1 + j += 1; continue + if c in "([{": + depth += 1 + elif c in ")]}": + if depth == 0: + return src[i:j] + depth -= 1 + elif c in ",;" and depth == 0: + return src[i:j] + j += 1 + return src[i:j] + + +def _literals(span): + """Static text of each string/template literal; a template's ${...} is not text.""" + out, i, n = [], 0, len(span) + while i < n: + c = span[i] + if c in "'\"": + j, buf = i + 1, [] + while j < n and span[j] != c: + if span[j] == "\\": + buf.append(span[j + 1:j + 2]); j += 2 + else: + buf.append(span[j]); j += 1 + out.append("".join(buf)); i = j + 1; continue + if c == "`": + j, buf = i + 1, [] + while j < n and span[j] != "`": + if span[j] == "\\": + j += 2 + elif span.startswith("${", j): + d, k = 1, j + 2 + while k < n and d: + d += (span[k] == "{") - (span[k] == "}") + k += 1 + j = k + else: + buf.append(span[j]); j += 1 + out.append("".join(buf)); i = j + 1; continue + i += 1 + return out + + +_SCHEMA = [ + (re.compile(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.[a-z][a-z0-9_]*\b"), "a field path"), + (re.compile(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b"), "a field name"), + (re.compile(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b"), "a macro name"), +] +_HOVER = re.compile(r"""(?:\btitle\s*:|\.title\s*=(?!=)|setAttribute\(\s*['"](?:title|aria-label)['"]\s*,)""") + +_schema_hovers, _hover_sites = [], 0 +for _root, _dirs, _files in os.walk(JS): + for _f in sorted(_files): + if not _f.endswith(".js"): + continue + _rel = os.path.relpath(os.path.join(_root, _f), JS) + _src = _no_comments(open(os.path.join(_root, _f), encoding="utf-8").read()) + for _m in _HOVER.finditer(_src): + _hover_sites += 1 + for _lit in _literals(_value(_src, _m.end())): + for _rx, _what in _SCHEMA: + _g = _rx.search(_lit) + if _g: + _schema_hovers.append(f"{_rel}: {_what}, {_g.group(0)!r}") +check("no hover on the page carries the machine's schema", + not _schema_hovers and _hover_sites > 50, + f"{', '.join(sorted(set(_schema_hovers))) or 'found no hovers to scan'} - a field " + "name says where the page read something and a macro name says what it will send; " + "neither is what the control is or what state it is in. The wire has a home, and " + "it is the trace pane") + +print(f"\n{checks - len(fails)}/{checks} checks passed") +sys.exit(1 if fails else 0) diff --git a/resources/web/shared/tests/drive/README.md b/resources/web/shared/tests/drive/README.md new file mode 100644 index 00000000000..f1e2ed275e9 --- /dev/null +++ b/resources/web/shared/tests/drive/README.md @@ -0,0 +1,52 @@ +# Drive scripts + +Scripts for `run_webkit.py --drive`. Each runs inside the live page and reports by +setting `window.__report`; the Python side prints it and counts `PASS` / `FAIL`. + +They live here because the alternative is re-writing them, and this page has twice +shipped visibly broken with every suite green — a `ReferenceError` that removed a whole +column, and a rename that broke `boot()` on a branch the simulator never takes. What +catches that is driving the running page, and a script that is not committed is one +nobody runs. + +| | | +|---|---| +| `dom-dump.js` | Walks `.app` and prints every tag, id, class, `data-*` and box. **Run it before and after any structural change and diff the two** — it answers *what is on the page*, which is the question a targeted check cannot ask. | +| `camera.js` | The Camera panel against the simulator: discovery, the four view layouts, the focus rule, the transport list, the settings popover, and the monitor fallback. 50 checks. | +| `camera-real.js` | The same panel against a real printer, and the only place the frame rate is a **measurement** rather than a claim. Needs `--real`, and Orca closed. | +| `no-printer.js` | The not-connected branch, forced with `--device-ip 192.0.2.1`. Camera discovery reads the device record, so anything touching it belongs here. | +| `popover-live.js` | That an open popover follows the state it draws. The camera's settings shipped built-once: every control worked and none of them moved its own tick until the panel was reopened. Also checks the Control panel's sliders are *not* rebuilt, which is the other half. | +| `job-band.js` | The job card's shape: the thumbnail anchoring, the metadata on the name's line, the buttons riding the bar's line, and the status word living in the panel header and following the machine rather than a click. 27 checks. | +| `ace-panel.js` | The multiACE Filament card, through the states the synchronous checks cannot reach: no `ace` object at all, four units, one unit feeding every head, a source switched and held until the machine agrees, the dryer run and stopped, both menus and their two scopes, and a bay click that asks before it purges. 51 checks. | +| `ace-real.js` | The same panel against a **real printer**, and **read-only on purpose** — it dumps the raw `ace` object and checks the panel drew it, and sends nothing. `ace-panel.js` switches sources, loads bays and starts the dryer; a suite should not be able to purge a nozzle. Needs `--real`, and Orca closed. 26 checks. | +| `storage-paging.js` | Storage's three kinds, each paged. The simulator holds too few items to page, so the three reads are answered in the script: sixty rows, then a short page. Checks the cursor advances on the wire, the grid appends rather than rebuilding, and the spinner stands where the button was. | +| `homing.js` | The Homing dialog against a scripted G28. `homed_axes` reaching "xyz" is not the end of the procedure - the bed goes on moving - and the wait used to close on it. The simulator models neither `homed_axes` nor `motion_report`, so both are posed on `state`; the loop under test is the page's. | +| `storage-real.js` | Storage against a real printer, read-only. Where the paging was actually settled: the simulator holds too few items to page and none that collide, and this printer holds sixty recordings of which one file appears four times - which is what showed the grid drawing 72 cards for 60 items. Needs `--real`, and Orca closed. | +| `homing-real.js` | The same dialog against a **real G28**, and the only witness that counts - this one MOVES THE MACHINE. Two attempts at this wait passed every offline check and closed the dialog over a moving bed; what settled it was pressing the button with the printer in sight. Needs `--real`, and Orca closed. Takes a minute. | +| `task-card.js` | That the Printing Task panel is as tall as its card. It was pinned at 150 px against a 304 px card, and `.panel-body` hides its overflow — so the progress bar and both job buttons were cut off with nothing to say they had been. | + +```bash +R=resources/web/shared/tests +python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/camera.js +python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/homing.js +python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/storage-paging.js +python3 $R/run_webkit.py --real --sn --drive $R/drive/homing-real.js +python3 $R/run_webkit.py --real --sn --drive $R/drive/storage-real.js +python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/dom-dump.js > /tmp/before.txt +python3 $R/run_webkit.py --real --size 1920x1080 --drive $R/drive/camera-real.js +python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/ace-panel.js +python3 $R/run_webkit.py --real --size 1920x1080 --drive $R/drive/ace-real.js +python3 $R/run_webkit.py --real --device-ip 192.0.2.1 --drive $R/drive/no-printer.js +``` + +**`--size` matters.** The Device page's two-column layout only engages at 1600 and above; +below it the page is one centred column and half of what these check does not apply. +`run_webkit.py`'s own checks branch on the same number. + +**A drive script can pose a state for a picture.** `--shots` fires after the script +reports, so a script that ends in the state you want to look at gives you a screenshot of +it — and `--shots` produces real PNGs now, because an unattended run renders offscreen +through cairo rather than reading back a window WebKit never composited into. + +**`--real` needs Orca closed** — it authenticates with the same saved `clientId`, and a +broker evicts the older holder. diff --git a/resources/web/shared/tests/drive/ace-modes.js b/resources/web/shared/tests/drive/ace-modes.js new file mode 100644 index 00000000000..abff04e5ce2 --- /dev/null +++ b/resources/web/shared/tests/drive/ace-modes.js @@ -0,0 +1,239 @@ +/* The three modes, driven — because the panel had only ever been right in one of them. + * + * Every measurement behind this panel was taken on a machine in `head` mode. multiACE has + * three, and a mode is not a display preference: it is HOW MANY HEADS THE ACE DRIVES, and + * it changes what the same four bays physically are. The payloads below are the machine's + * own, captured on 811002511261022618B3 on 2026-09-01 by switching a live printer to + * `multi` and back — see docs/u1-webui/data/ace-mode-switch-20260901.json. `normal` has + * still never been observed and is marked where it is assumed. + * + * What this has to prove, and none of it was true before: + * + * the panel reads `ace_heads` rather than rebuilding it from `head_feeder`, which the + * machine consults only in head mode; + * a bay in multi belongs to ONE head — its lane — and the verbs say so; + * the background verbs are gone in multi, where the plugin refuses them outright; + * the mode switch's three outcomes are three different things on screen. + */ +(function () { + const out = []; const P = window.__devicePage; + const say = (n, g, w) => out.push(`${g === w ? 'PASS' : 'FAIL'} ${n}` + + (g === w ? '' : ` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s) => document.querySelector(s); + const $$ = (s) => [...document.querySelectorAll(s)]; + const body = () => $('#filament'); + const fits = () => body().scrollHeight <= body().clientHeight + 1; + const bodyH = () => Math.round(body().getBoundingClientRect().height); + const setAce = async (o) => { P.state.apply({ ace: null }); P.state.apply({ ace: o }); + await wait(90); }; + const opts = (n) => [...$$('#filament .ace-src')[n].options].map((o) => o.textContent); + const srcs = () => $$('#filament .ace-src').map((s) => s.value).join(','); + // One closer for every popup: the dialog's own X, then a body click for a menu. + const shut = () => { const x = $('.dialog-x'); if (x) x.click(); document.body.click(); }; + + (async () => { try { + for (let i = 0; i < 30 && !(P && P.bridge); i += 1) await wait(400); + await wait(2500); + const head = JSON.parse(JSON.stringify(P.state.get('ace'))); + + /* ---- head mode: the reference, unchanged ---------------------------- */ + say('head: one card is on the cabinet', $$('#filament .ace-cab').length, 1); + say('head: four bays on it', $$('#filament .ace-bay').length, 4 + 3); + say('head: the sources resolve as they always did', srcs(), 'feeder,feeder,feeder,ace:0'); + say('head: the selector can name WHICH unit', + opts(0).join(' · '), 'Default feeder · ACE A · Hand-fed'); + say('head: each card carries its own unit row', + $$('#filament .ace-card .ace-strip').length, 4); + say('head: no band, because the cabinet belongs to a card', $$('#filament .ace-band').length, 0); + say('head: the body fits', fits(), true); + const headH = bodyH(); + + /* ---- multi, exactly as the machine reported it ---------------------- */ + // The measured payload, field for field: only `mode` and `ace_heads` moved, and + // `head_feeder`, `head_ace` and `head_source` were left behind untouched. That is the + // whole trap - a panel reading head_feeder here draws three stock feeders. + const multi = JSON.parse(JSON.stringify(head)); + multi.mode = 'multi'; + multi.ace_heads = [0, 1, 2, 3]; + await setAce(multi); + + say('multi: every head is ACE-driven, whatever head_feeder still says', + srcs(), 'lane,lane,lane,lane'); + say('multi: and head_feeder really is unchanged underneath', + JSON.stringify(P.state.get('ace').head_feeder), + JSON.stringify(head.head_feeder)); + say('multi: no card offers a choice of unit — the lane is plumbing', + opts(0).join(' · '), 'Bay A1 · Hand-fed'); + say('multi: hand-feeding survives, because ACE_SET_HEAD_MANUAL does', + opts(2).indexOf('Hand-fed') >= 0, true); + say('multi: one bay per card, its own lane', $$('#filament .ace-bay').length, 4); + say('multi: and it is the lane, not the first bay', + $$('#filament .ace-bay').map((b) => b.querySelector('.ace-disc').textContent).join(','), + 'A1,A2,A3,A4'); + say('multi: the unit row leaves the cards for one band', + [$$('#filament .ace-band .ace-strip').length, + $$('#filament .ace-card .ace-strip').length].join('/'), '1/0'); + say('multi: the band still carries the dryer', !!$('#filament .ace-band .ace-dry'), true); + say('multi: the body fits', fits(), true); + say('multi: and is no taller than head mode', bodyH() <= headH, true); + + /* ---- the cross-lane feed the machine actually reported --------------- */ + // head_source survives a mode switch: this machine is plumbed for head mode, so in + // multi it reported bay A2 feeding Toolhead 4 — which is Toolhead 2's lane, and which + // multi's own rule says cannot happen. The mode is a claim about tubes and nothing + // verifies it, so the panel draws the record. + // The simulator carries the machine's own shape rather than its exact slot: what + // matters is that the recorded bay is NOT this head's lane, which is the state a + // head-mode-plumbed machine lands in the moment it is put into multi. + const h3 = P.state.ace().heads[3]; + say('the recorded feed is off-lane, as the machine reported after the switch', + [h3.lane, h3.bay !== h3.lane].join('/'), '3/true'); + const lent = $$('#filament .ace-lent'); + say('the bay it came from is marked on ITS OWN lane`s card, once', + lent.length, 1); + say('and names the head it is feeding', lent[0] && lent[0].textContent, '→ Toolhead 4'); + say('on the card of the lane it belongs to, not the one it feeds', + lent[0].closest('.ace-card').querySelector('.ace-name').textContent, + `Toolhead ${h3.bay + 1}`); + const from = $('#filament .ace-from'); + say('the fed head says where it came from instead of pretending it was its lane', + !!from && from.textContent.indexOf(`from A${h3.bay + 1}`) >= 0, true); + + /* ---- the verbs stop offering every bay to every head ----------------- */ + // The page's OWN function, not a copy of it: a check that re-implements the model + // inline is checking its own copy rather than the panel's. + const verbs = (h, slot, loaded, u) => + P.multiACE.aceVerbs(P.state.ace(), h, slot, loaded, u); + // Toolhead 1 is loaded; its lane is bay 0. + say('multi: a head`s own lane is a verb', + verbs(0, 0, true, 0).map((v) => v.name).join(','), 'Swap,Unload and retract'); + say('multi: somebody else`s lane is not — and not greyed either, because no macro ' + + 'would make it one', + verbs(0, 2, true, 0).filter((v) => v.slotted).length, 0); + say('multi: but the head`s own verbs survive an off-lane question', + verbs(3, 1, true, 0).map((v) => v.name).join(','), + 'Unload and retract'); + say('multi: no background verb anywhere — the plugin refuses them outside head mode', + [0, 1, 2, 3].map((h) => verbs(h, h, true, 0)).flat().filter((v) => v.bg).length, 0); + say('multi: a load names no SLOT it has not got', + verbs(2, 2, false, 0)[0].cmd, + 'ACE_LOAD_HEAD HEAD=2 ACE=0 SLOT=2'); + + /* ---- two units: the splitter, which is what multi is for ------------- */ + const two = JSON.parse(JSON.stringify(multi)); + two.device_count = 2; + two.aces = [multi.aces[0], JSON.parse(JSON.stringify(multi.aces[0]))]; + two.aces[1].idx = 1; + two.aces[1].model = 'ACE Pro'; + two.aces[1].humidity = 52; + await setAce(two); + say('2 units: each card holds its lane on BOTH cabinets', + $$('#filament .ace-bay').length, 8); + say('2 units: and the selector names the whole lane, not one bay of it', + opts(0).join(' · '), 'Bay A1 · B1 · Hand-fed'); + say('2 units: two bands, one per cabinet', $$('#filament .ace-band .ace-strip').length, 2); + say('2 units: the body still fits', fits(), true); + await setAce(multi); + + /* ---- normal: no verbs, one fact -------------------------------------- */ + // Assumed, not measured: normal needs a reboot each way and has never been observed. + // `ace_heads` is deliberately left saying all four, because that is what the plugin's + // own head_uses_ace() would answer there - and the panel must read the MODE instead. + const normal = JSON.parse(JSON.stringify(multi)); + normal.mode = 'normal'; + await setAce(normal); + say('normal: nothing is ACE-fed, whatever ace_heads claims', + srcs(), 'feeder,feeder,feeder,feeder'); + say('normal: and the claim really was all four', + JSON.stringify(P.state.get('ace').ace_heads), '[0,1,2,3]'); + say('normal: no cabinet is drawn', $$('#filament .ace-cab').length, 0); + say('normal: the selector is the feeder or the hand', + opts(0).join(' · '), 'Default feeder · Hand-fed'); + say('normal: the unit row goes BELOW the grid, so drying stays reachable', + !!$('#filament .ace-grid + .ace-band .ace-dry'), true); + say('normal: the cards drop the line that named a unit', + $$('#filament .ace-card .ace-strip').length, 0); + say('normal: the body fits', fits(), true); + + /* ---- the switch, and its three outcomes ------------------------------ */ + await setAce(JSON.parse(JSON.stringify(head))); + const pill = () => $('#filament-mode'); + pill().click(); await wait(60); + const items = $$('.menu .menu-item').map((e) => e.textContent.trim()); + say('the mode list offers three, and marks the one it is in', + items.join(' | '), 'Normal | Multi | Head ✓'); + // Head, which asks WHICH head - including from head mode, because that is how the + // coupled head is moved. The panel never sent HEAD= before. + $$('.menu .menu-item')[2].click(); await wait(90); + say('head asks which toolhead is on the cabinet', $$('.dialog .hrow').length, 4); + say('and marks the one that is', $$('.dialog .hrow')[3].getAttribute('aria-pressed'), 'true'); + say('naming the line it would send', $('.dialog .verb-cmd').textContent, + 'SET_ACE_MODE MODE=head HEAD=3'); + $$('.dialog .hrow')[1].click(); await wait(40); + say('picking another head moves the line', $('.dialog .verb-cmd').textContent, + 'SET_ACE_MODE MODE=head HEAD=1'); + shut(); await wait(60); + + /* ---- multi <-> head is live and needs no restart --------------------- */ + const before = P.mock.printer.gcodeLog.length; + pill().click(); await wait(50); + $$('.menu .menu-item')[1].click(); await wait(400); + say('multi is sent live, with no dialog in the way', + P.mock.printer.gcodeLog.slice(before).join('|'), 'SET_ACE_MODE MODE=multi'); + await P.session.refreshAce(); await wait(150); + say('and the machine is in it', P.state.ace().mode, 'multi'); + say('the pill says so', pill().textContent.trim(), 'ACE mode · Multi'); + say('the machine said it needed no reboot', + /No reboot needed/.test(P.mock.printer.console.map((c) => c.message).join('\n')), true); + // Measured: filament_type went from ["","","","PETG"] to four names and + // filament_exist from [T,T,F,T] to all true - including a head whose extruder is + // empty. In multi the U1's display model mirrors the BAYS, whatever the tubes do. + const ptc = P.state.get('print_task_config'); + say('entering multi pushed the bays onto their lanes, as measured', + [ptc.filament_type.filter(Boolean).length, + ptc.filament_exist.filter(Boolean).length].join('/'), '4/4'); + + /* ---- normal is refused while anything is loaded ---------------------- */ + pill().click(); await wait(50); + $$('.menu .menu-item')[0].click(); await wait(90); + say('normal warns that it needs a restart before it sends anything', + /restarted/.test($('.dialog').textContent), true); + $('.dialog .btn.primary').click(); + await wait(600); + say('and the printer refuses it, in its own words', + /Cannot switch mode! Filament still loaded in/.test(($('.dialog') || {}).textContent || ''), + true); + say('with what to do about it, which is the second line the macro prints', + /Please unload all toolheads first/.test(($('.dialog') || {}).textContent || ''), true); + say('the mode did not move', P.state.ace().mode, 'multi'); + shut(); await wait(60); + + /* ---- and with the heads empty, it is a restart --------------------- */ + P.mock.printer.filamentExist = [false, false, false, false]; + await P.session.refreshAce(); await wait(120); + pill().click(); await wait(50); + $$('.menu .menu-item')[0].click(); await wait(90); + $('.dialog .btn.primary').click(); + await wait(700); + say('a successful switch to normal arrives as a REFUSED command', + P.mock.printer.saveVars.ace__mode, 'normal'); + say('and the machine goes on reporting the old mode until it is restarted', + P.state.ace().mode, 'multi'); + say('so the pill reports both, and says what is owed', + pill().textContent.trim(), 'ACE mode · Multi → Normal · restart to finish'); + say('the panel keeps drawing the mode the machine is actually in', + $$('#filament .ace-src')[0].value, 'lane'); + + /* ---- the restart ---------------------------------------------------- */ + P.mock.printer.ace.mode = 'normal'; + P.mock.printer.ace.ace_heads = [0, 1, 2, 3]; + await P.session.refreshAce(); await wait(150); + say('after the restart the two agree and the pill is a mode again', + pill().textContent.trim(), 'ACE mode · Normal'); + say('and the panel is in normal', $$('#filament .ace-src')[0].value, 'feeder'); + say('the body fits in every mode this script has been through', fits(), true); + } catch (e) { out.push(`FAIL drive threw: ${e && e.message}`); } + window.__report = out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/ace-panel.js b/resources/web/shared/tests/drive/ace-panel.js new file mode 100644 index 00000000000..0058133ec48 --- /dev/null +++ b/resources/web/shared/tests/drive/ace-panel.js @@ -0,0 +1,602 @@ +/* The multiACE Filament panel, driven through states the synchronous checks cannot + reach: no ACE at all, four units, a source switched, the dryer run, and both menus. + + `ace` is not on the subscription, so nothing pushes it - which is exactly why this has + to drive rather than read. Every state below is reached the way the page reaches it: + the machine's mirror changes and the page is asked to look again. */ +(function () { + const out = []; const P = window.__devicePage; + const say = (n, g, w) => out.push(`${g === w ? 'PASS' : 'FAIL'} ${n}` + + (g === w ? '' : ` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s) => document.querySelector(s); + const $$ = (s) => [...document.querySelectorAll(s)]; + const body = () => $('#filament'); + const fits = () => body().scrollHeight <= body().clientHeight + 1; + /* Replace the object outright. MachineState merges per object - a partial push must + not drop fields - so writing a new `ace` over the old one would leave the old unit + list underneath it. */ + const setAce = async (o) => { P.state.apply({ ace: null }); P.state.apply({ ace: o }); + await wait(80); }; + const unit = (i, bays) => ({ + idx: i, connected: true, protocol: 'v2', firmware: 'V1.1.26', + temp: 30 + i, humidity: 38 + i * 6, gate_status: bays.map((b) => (b ? 1 : 0)), + dryer_status: { status: 'idle' }, + slots: bays.map((b, k) => ({ index: k, state: b ? 'ready' : 'empty', rfid: b ? b.rfid || 0 : 0, + material: b ? b.m : '', brand: b ? b.v : '', + color: b ? b.c : [0, 0, 0] })), + }); + + (async () => { try { + for (let i = 0; i < 30 && !(P && P.bridge); i += 1) await wait(400); + await wait(2500); + const measured = JSON.parse(JSON.stringify(P.state.get('ace'))); + + say('the page is up with no paint error', + $$('[data-paint-error]').map((e) => e.id).join(','), ''); + + /* ---- what the machine actually reports ---------------------------- */ + // The fourth card is the ACE one: heads 1-3 are on their stock feeders, which is + // what head_feeder says and what head_ace does not. + say('one unit is drawn, and it is an ACE 2 Pro', + $$('#filament .ace-unit')[3].textContent.trim(), 'ACE A · ACE 2 Pro'); + say('its humidity and temperature are the machine`s', + $$('#filament .ace-hum span').map((e) => e.textContent).filter(Boolean).join('/'), + '38 %/30 °C'); + /* ---- where a bay's identity comes from ------------------------------ */ + // Every raw slot reads {material:"", brand:"", rfid:0} - these spools have no tags - + // and multiACE keeps the names its own web UI shows in an override store. Orca's + // Prepare page polls the MERGED endpoint from C++ and saw filament this panel drew + // as `?`, which is what turned this up. + say('a bay is named from multiACE`s own store', + $$('#filament .ace-cab .ace-chip').map((e) => e.textContent).join(','), + 'PETG,PETG,PETG,PETG'); + say('and the mark says where the name came from', + $$('#filament .ace-cab .ace-prov') + .map((e) => (/multiACE/.test(e.title) ? 'o' : /read only/.test(e.title) ? 'r' : 'd')) + .join(''), 'oooo'); + + // Without the store there is nothing to merge, and the panel has to say so rather + // than invent. This is also every printer that has no multiACE web service. + const store = P.store.aceBays; + P.store.aceBays = null; + P.state.apply({ ace: JSON.parse(JSON.stringify(measured)) }); await wait(80); + say('with no override store an occupied bay says it is not named', + $$('#filament .ace-cab .ace-chip').map((e) => e.textContent).join(','), + '?,?,PETG,?'); + say('and the one that IS named got there from head_source, which is weaker', + $$('#filament .ace-cab .ace-prov') + .map((e) => (/loaded filament/.test(e.title) ? 'd' : '-')).join(''), '--d-'); + + // multiACE's own precedence, kept: a tag beats a name someone typed. + const tagged = JSON.parse(JSON.stringify(measured)); + tagged.aces[0].slots[0] = { index: 0, status: 'ready', rfid: 2, material: 'PLA', + brand: 'Jayo', subtype: '', sku: '', color: [244, 67, 54] }; + P.store.aceBays = store; + await setAce(tagged); + say('a tagged bay keeps the tag`s answer, not the override`s', + $$('#filament .ace-cab .ace-chip')[0].textContent, 'PLA'); + say('and it reads rather than edits', + /read only/.test($$('#filament .ace-cab .ace-prov')[0].title), true); + await setAce(JSON.parse(JSON.stringify(measured))); + say('the rest are the store`s again', + $$('#filament .ace-cab .ace-chip').map((e) => e.textContent).join(','), + 'PETG,PETG,PETG,PETG'); + say('and it is never drawn with the checkerboard, which is this page`s word for empty', + $$('#filament .ace-cab .ace-disc').filter((e) => !e.dataset.loaded).length, 0); + say('a named bay is drawn in its own colour rather than in the unknown grey', + $$('#filament .ace-cab .ace-disc') + .filter((e) => e.style.background === 'rgb(183, 189, 198)').length, 0); + say('the bay the head is loaded from is the marked one', + $$('#filament .ace-cab .ace-bay').findIndex((e) => e.classList.contains('is-fed')), 2); + + /* ---- no ACE at all ------------------------------------------------- */ + // The degradation path, and the one a stock U1 is always on: no `ace` object means + // no unit to describe and no macro to send, so the panel is the one that shipped. + P.state.apply({ ace: null }); await wait(80); + say('with no `ace` object the panel falls back to the four slots', + $$('#filament .slot').length + ':' + $$('#filament .ace-card').length, '4:0'); + say('and the mode pill says why it is dead', + document.getElementById('filament-mode').disabled, true); + say('the header stops claiming units', $('#filament-ace').textContent, 'no ACE'); + document.getElementById('filament-settings').click(); + say('the settings menu says there is nothing to configure', + $('.menu .menu-item').classList.contains('is-muted'), true); + document.body.click(); await wait(30); + + /* ---- four units, which is sixteen bays ----------------------------- */ + // Head-major never folds: a card shows only ITS own source, so four units is drawn in + // the same height as none. Unit-major has to collapse to a rack at three. + const four = JSON.parse(JSON.stringify(measured)); + four.device_count = 4; + four.head_feeder = { 0: false, 1: false, 2: false, 3: false }; + four.head_ace = { 0: 0, 1: 1, 2: 2, 3: 3 }; + // `ace_heads` moves with the wiring, because on the machine it is DERIVED from it - + // `[h for h in range(4) if head_uses_ace(h)]`, recomputed on every read. A payload + // that clears head_feeder and leaves ace_heads behind is a state the printer cannot be + // in, and the panel reads the machine's answer rather than rebuilding it. + four.ace_heads = [0, 1, 2, 3]; + four.aces = [ + unit(0, [{ m: 'PETG', v: 'Kingroon', c: [131, 175, 255], rfid: 2 }, { m: 'PETG', v: 'Kingroon', c: [143, 167, 200] }, + { m: 'PETG', v: 'Generic', c: [99, 44, 44] }, { m: 'PETG', v: 'Kingroon', c: [196, 112, 83] }]), + unit(1, [{ m: 'PLA', v: 'Jayo', c: [31, 138, 76] }, { m: 'PLA', v: 'Jayo', c: [240, 240, 240] }, + null, { m: 'PLA', v: 'Generic', c: [43, 43, 43] }]), + unit(2, [{ m: 'PETG', v: 'Generic', c: [232, 177, 42] }, { m: 'PETG', v: 'Generic', c: [122, 79, 208] }, + { m: 'PETG', v: 'Generic', c: [214, 214, 214] }, null]), + unit(3, [{ m: 'ABS', v: 'Generic', c: [15, 111, 209] }, { m: 'ABS', v: 'Generic', c: [201, 201, 201] }, + null, null]), + ]; + await setAce(four); + say('four units is sixteen bays', $$('#filament .ace-bay').length, 16); + say('and it costs the panel nothing, because a card shows only its own source', + fits() && Math.round($('#filament .ace-card').getBoundingClientRect().height) === 215, + true); + say('each card names its own unit', + $$('#filament .ace-unit b').map((e) => e.textContent).join(','), + 'ACE A,ACE B,ACE C,ACE D'); + say('every occupied bay carries a mark', + $$('#filament .ace-card')[0].querySelectorAll('.ace-prov').length, 4); + /* + * And every one of them is an EYE, whatever named it. + * + * The word still says which source it was - multiACE's `rfid → override → derived` + * is the thing worth knowing about a bay - but the glyph does not, because a pencil + * is an offer to edit and naming a bay from this panel is not built. Three of these + * wore one over nothing. `--drive` reads the drawing, not just the title, which is + * how the mark and the promise are held to each other. + */ + say('and every one of them reads, because no bay can be named from here', + [...$$('#filament .ace-card')[0].querySelectorAll('.ace-prov')] + .map((e) => (e.querySelector('svg circle') ? 'eye' : 'pencil')).join(','), + 'eye,eye,eye,eye'); + say('while the word still says which source named it', + [...$$('#filament .ace-card')[0].querySelectorAll('.ace-prov')] + .map((e) => (/read only/.test(e.title) ? 'tag' : 'named')).join(','), + 'tag,named,named,named'); + + /* ---- one unit feeding three heads ---------------------------------- */ + // ACE_SET_HEAD_ACE binds a head to a unit and says nothing about the reverse. The + // duplication objection is answered where it happens: the reading risk, not the + // drawing. + const shared = JSON.parse(JSON.stringify(four)); + shared.device_count = 1; + shared.head_ace = { 0: 0, 1: 0, 2: 0, 3: 0 }; + shared.aces = [four.aces[0]]; + await setAce(shared); + say('a unit feeding every head is drawn on every card it feeds', + $$('#filament .ace-cab').length, 4); + say('and each says so, rather than looking like four cabinets', + $$('#filament .ace-card.is-shared').length, 4); + say('sixteen bays or four, the panel is the same height', fits(), true); + + /* ---- switching a source -------------------------------------------- */ + await setAce(JSON.parse(JSON.stringify(measured))); + const before = P.mock.printer.gcodeLog.length; + const sel = $('#filament .ace-card .ace-src'); + sel.value = 'ace:0'; + sel.dispatchEvent(new Event('change')); + await wait(60); + const sent = P.mock.printer.gcodeLog.slice(before).join(' | '); + // head_feeder is read BEFORE head_ace, so binding a head to a unit without clearing + // it changes nothing anyone can see. + say('binding a head to a unit clears manual and feeder first', + /ACE_SET_HEAD_MANUAL HEAD=0 ENABLE=0/.test(sent) + && /ACE_SET_HEAD_FEEDER HEAD=0 ENABLE=0/.test(sent) + && /ACE_SET_HEAD_ACE HEAD=0 ACE=0/.test(sent), true); + say('and the switch holds what was asked for while it waits', + $('#filament .ace-card .ace-src').value, 'ace:0'); + say('marked as unconfirmed rather than as fact', + $('#filament .ace-card .ace-src').dataset.pend, 'sent'); + await P.session.refreshAce(); await wait(120); + say('the machine agrees and the mark goes', + $('#filament .ace-card .ace-src').dataset.pend, undefined); + + /* ---- the dryer ------------------------------------------------------ */ + $('#filament .ace-dry').click(); await wait(40); + const d = $('.dialog'); + say('the Dry chip is what opens the dryer, and nothing else does', !!d, true); + say('it draws the reading as a quantity', !!d.querySelector('.dry-drop svg'), true); + say('the two readings are named as Bambu names them', + [...d.querySelectorAll('.dry-k')].slice(0, 2).map((e) => e.textContent).join('/'), + 'Humidity/Temperature'); + say('three settings, and two of them take a number', + d.querySelectorAll('.dry-field').length + ':' + d.querySelectorAll('.dry-custom').length, + '3:2'); + const lit = () => [...d.querySelectorAll('.dry-field')] + .map((f) => { const o = f.querySelector('.dry-opt.is-on'); return o ? o.textContent : '-'; }) + .join('/'); + // The line under the dialog used to be the macro it would send. It says what will + // HAPPEN now - the numbers and the wire disagree (hours offered, minutes sent) and + // that is a reason to get the conversion right, not to make the reader check the + // arithmetic in G-code. The wire is still asserted, further down, on the wire. + const cmd = () => d.querySelector('.dry-cmd').textContent; + say('the presets are lit and the summary matches them', + lit() + ' | ' + cmd(), '55 °C/4 h/Off | Dries at 55 °C for 4 h, and not automatically.'); + const type = (i, v) => { const e = d.querySelectorAll('.dry-custom')[i]; + e.value = v; e.dispatchEvent(new Event('input')); return e; }; + type(0, '68'); + say('typing a temperature un-lights its preset', lit(), '-/4 h/Off'); + say('and the summary follows what was typed', /at 68 °C for 4 h/.test(cmd()), true); + type(0, ''); + say('clearing it hands the highlight straight back', lit(), '55 °C/4 h/Off'); + const e0 = type(0, '999'); e0.dispatchEvent(new Event('change')); + say('a value past what the macro takes is clamped, not refused', + d.querySelectorAll('.dry-custom')[0].value, '80'); + d.querySelectorAll('.dry-field')[0].querySelectorAll('.dry-opt')[0].click(); + say('choosing a preset clears the typed value', + d.querySelectorAll('.dry-custom')[0].value, ''); + say('and lights that preset', lit(), '45 °C/4 h/Off'); + say('the panel body is untouched while it is open', fits(), true); + const nDry = P.mock.printer.gcodeLog.length; + d.querySelector('.dialog-foot .btn.primary').click(); + await wait(700); + // On the WIRE, which is where these two were always the point: the panel offers HOURS + // and `ACE_DRY` takes MINUTES, so a dialog offering 4 would have dried for four + // minutes; and automatic drying is ENABLE/RH_START, not the THRESHOLD that was + // guessed - `ACE_SET_AUTO_DRY THRESHOLD=` answers `ok` and changes nothing. + const dryLines = P.mock.printer.gcodeLog.slice(nDry).join('\n'); + say('the panel offers hours and the wire carries minutes', + /ACE_DRY ACE=0 TEMP=45 DURATION=240/.test(dryLines) + && !/DURATION=4\b/.test(dryLines), true); + say('and automatic drying is ENABLE/RH_START, not the THRESHOLD that was guessed', + !/THRESHOLD/.test(dryLines), true); + say('starting it puts the chip on a countdown', + /\d+ m \/ /.test($('#filament .ace-dry').textContent), true); + say('and the chip`s row still does not overflow', + $$('#filament .ace-strip').filter((e) => e.scrollWidth > e.clientWidth + 1).length, 0); + $('#filament .ace-dry').click(); await wait(40); + say('re-opening it offers Stop rather than Start', + $('.dialog-foot .btn.primary').textContent, 'Stop'); + say('and says so, rather than naming the macro', + $('.dialog .dry-cmd').textContent.trim(), 'Stops drying now.'); + const nStop = P.mock.printer.gcodeLog.length; + // The claim that a running dryer refuses loads was never verified and the macro help + // says nothing of the kind. + say('and claims nothing about loads being refused', + /refus/i.test($('.dialog').textContent), false); + $('.dialog-foot .btn.primary').click(); await wait(700); + // ACED__DRY_STOP stops "the current ACE", which on a machine with two units is + // whichever is active rather than the one whose chip was pressed. The unit is named + // on the wire; the dialog says what will happen. + say('and stops THIS unit by name', + P.mock.printer.gcodeLog.slice(nStop).join('\n').trim(), 'ACE_STOP_DRYING ACE=0'); + say('stopping it puts the chip back to offering', + $('#filament .ace-dry').textContent.trim(), 'Dry'); + + /* ---- the two menus, and their two scopes ---------------------------- */ + say('nothing is open to start with', $$('.menu').length, 0); + document.getElementById('filament-settings').click(); await wait(30); + say('the panel`s menu names the printer', + $('.menu .menu-head').textContent, 'This printer'); + say('holding the settings a person sets once', + $$('.menu .menu-item').length, 5); + say('each named for what it sets, with no G-code on any of them', + [$$('.menu .mcmd').length, + $$('.menu .menu-item').some((b) => /ACE_[A-Z]/.test(b.title || ''))].join('/'), + '0/false'); + say('and it is pulled inside the window rather than off its edge', + $('.menu').getBoundingClientRect().right <= window.innerWidth + 1, true); + document.body.click(); await wait(30); + $$('#filament .ace-more')[3].click(); await wait(30); + say('a card`s menu names its toolhead', $('.menu .menu-head').textContent, 'Toolhead 4'); + say('an ACE-fed head can swap to another bay', + $$('.menu .menu-item').some((b) => /Swap/.test(b.textContent)), true); + document.body.click(); await wait(30); + $$('#filament .ace-more')[1].click(); await wait(30); + say('a feeder-fed head cannot, because there are no other bays', + $$('.menu .menu-item').some((b) => /Swap/.test(b.textContent)), false); + // Not `Reload` any more, and not both verbs either. A stock feeder has one thing it + // can be asked to do at a time - it holds filament or it does not - which is the rule + // multiace-actions.html settled and aceVerbs() carries. + say('a loaded stock feeder offers Unload, and only Unload', + $$('.menu .menu-item span:not(.mcmd)').map((e) => e.textContent) + .filter((t) => /^(Load|Unload)/.test(t)).join(','), 'Unload'); + document.body.click(); await wait(30); + // Emptied the way the machine empties a head: `channel_action_state`, which is the + // last operation the channel FINISHED. Not `print_task_config.filament_exist`, the + // slicer's assignment, which survives a physical unload; and not + // `filament_at_extruder`, which on the printer stayed TRUE on both emptied heads. + // drive/ace-verbs.js pins down both wrong answers. + // On the printer, not on the mirror: the simulator pushes a full snapshot every tick, + // so a value written into the mirror survives only until the next one - which made + // this check pass or fail on how long the script before it took. + P.mock.printer.channels[1].act = 'unload_finish'; + await wait(1400); + $$('#filament .ace-more')[1].click(); await wait(30); + // An unload with nothing to unload is not a greyed control now, it is not a verb: + // there is no reason to show for something that does not exist in this state. + say('an empty one offers Load, and does not list Unload at all', + $$('.menu .menu-item span:not(.mcmd)').map((e) => e.textContent) + .filter((t) => /^(Load|Unload)/.test(t)).join(','), 'Load'); + document.body.click(); await wait(30); + P.mock.printer.channels[1].act = 'load_finish'; + await wait(1400); + say('a menu costs the panel body nothing', fits(), true); + + /* ---- the ACE's own icons, at the standard's sizes ------------------- */ + // The square glyph is the form for an icon slot the 44x26 cabinet cannot go in, and + // 24x24 is its nominal size - which a menu row can take, so it is drawn at it. + $$('#filament .ace-more')[3].click(); await wait(30); + const sq = $('.menu .ace-glyph-sq'); + say('a menu row gets the square glyph at its nominal 24x24', + sq ? `${Math.round(sq.getBoundingClientRect().width)}x` + + `${Math.round(sq.getBoundingClientRect().height)}` : 'none', '24x24'); + say('and it is the front face - body and four bays, no hood', + sq.querySelectorAll('rect').length, 5); + say('nothing in the menu wraps now that a glyph and a macro share the row', + $$('.menu .menu-item').filter((e) => e.getBoundingClientRect().height > 46).length, 0); + document.body.click(); await wait(30); + + /* ---- the settings that ARE reported back ---------------------------- */ + // These were written as write-only on the assumption that none of them was readable. + // Three of the four are right there in the object. + document.getElementById('filament-settings').click(); await wait(30); + $$('.menu .menu-item').find((b) => /Spoolman/.test(b.textContent)).click(); + await wait(60); + say('the Spoolman dialog opens on the machine`s own URL', + $('.dialog input[type="url"]').value, 'http://192.168.2.30:7912'); + say('and on its own auto-sync setting', + $('.dialog .field-toggle input').checked, false); + $('.dialog-x').click(); await wait(30); + + /* ---- clicking a bay --------------------------------------------------- */ + // A swap is minutes of physical work that purges filament, and the bays sit under the + // pointer while someone is reading the card - so the click opens the bay's own sheet + // and nothing is sent until a verb in it is pressed. What that sheet OFFERS is + // drive/ace-verbs.js; what is checked here is that it asks at all, and costs nothing. + // Which head it is depends on what the run has already switched - the source test + // above left Toolhead 1 on ACE A - so it is read off the card rather than written + // down. + const withCab = $$('#filament .ace-card').findIndex((c) => c.querySelector('.ace-cab')); + $$('#filament .ace-cab .ace-bay')[0].click(); await wait(40); + say('clicking a bay asks first', !!$('.dialog'), true); + // SWAP, and which one it is depends on what is physically in the head rather than on + // what any record says. This head was switched to ACE A above, so `head_source` names + // nothing from that unit - but the head still holds the filament its stock feeder put + // there, and the sensor is what the verb list asks. Getting ACE A's filament in means + // taking that out first, which is a swap. + // + // This expectation has moved twice, each time toward the more truthful answer: the + // panel used to send ACE_SWAP_HEAD for everything because it is the macro that takes a + // slot, then LOAD because `head_source` was empty, and now SWAP because the head is + // not. ACE_SWAP_HEAD is gone from the panel entirely since - it is the print's swap + // and opens with a Z hop - and Swap goes out as an unload then a load. + // say() here compares with ===, so an array expectation can never match. + say('and a bay says what state the head is in, without offering the swap', + `${/is loaded/.test($('.dialog').textContent)}/${$$('.dialog .verb').length}`, + 'true/0'); + const n0 = P.mock.printer.gcodeLog.length; + $('.dialog-x').click(); await wait(30); + say('and closing it sends nothing', P.mock.printer.gcodeLog.length, n0); + + // The verb itself, where it is now chosen. Which one it is still depends on what is + // physically in the head rather than on what any record says - that is the part of + // this check worth keeping, and it moved to the toolhead with the verb. + $$('#filament .ace-tool')[withCab].click(); await wait(60); + say('the toolhead offers it, labelled per bay', + $$('.dialog .pickbay .picklab').map((e) => e.textContent)[0], 'Swap'); + $('.dialog-x').click(); await wait(30); + + /* ---- the eye and the pencil ---------------------------------------- */ + /* + * A mark has to agree with what the click does, and the MACHINE decides which it is. + * + * This asked whether the spool carried an RFID tag, which is a different question. + * Measured on 811002511261022618B3: head 2 carries a tag, reads + * `filament_official: false, filament_edit: true`, and its record has been + * overridden - the sub-type in `print_task_config` is empty where the tag says + * `Silk`. So a tag present does not mean read-only, and the panel now reads + * `filament_edit` rather than deciding for itself. + * + * Both states are reached here, with the tag held CONSTANT across them so the thing + * being varied is the only thing that moved. + */ + const kind = (n) => (!n ? 'none' : n.querySelector('circle') ? 'eye' + : n.tagName.toLowerCase() === 'svg' ? 'pencil' : 'sprite'); + const tagInfo = (main) => ({ MAIN_TYPE: main, SUB_TYPE: 'Silk', VENDOR: 'Jayo', + ARGB_COLOR: 4294198070, HOTEND_MIN_TEMP: 190, + HOTEND_MAX_TEMP: 260, BED_TEMP: 45, SKU: 0 }); + // The mock ships four untagged spools, so the eye branch is vacuous without this. + // Heads 1 AND 2 get the same tag; only head 1 is the machine's own record. + P.state.apply({ filament_detect: { state: [0, 0, 0, 0], + info: [tagInfo('PLA'), tagInfo('PLA'), tagInfo('NONE'), tagInfo('NONE')] } }); + // On the simulated PRINTER, so `filament_edit` is derived from it the way the machine + // derives its own, and the push carries both. + P.mock.printer.filamentOfficial = [true, false, false, false]; + await wait(1500); + const idRow = () => $$('.menu .menu-item').filter((b) => /this filament/.test(b.textContent))[0]; + $$('#filament .ace-more')[0].click(); await wait(60); + say('the machine`s own record offers View, with the eye', + `${/View this filament/.test(idRow().textContent)}/${kind(idRow().querySelector('svg'))}`, + 'true/eye'); + // And the sheet obeys the same answer: it used to open three inputs and a Confirm + // under a row that said "View", which is the report this came from. + idRow().click(); await wait(140); + say('and opening it is a reading, not a form', + [$$('.dialog input').length, $('.dialog .btn.primary').textContent, + $$('.dialog .dialog-foot .btn').length].join('/'), '0/Close/1'); + // The record IN USE, which is not the tag: the mock's slot 1 is Snapmaker PLA and the + // tag on it reads Jayo. The sheet shows what the printer is going by, and the tag's + // own block below it says what the spool claims - two readings, not one. + say('showing the record in use above what the spool claims', + [/Snapmaker/.test($('.dialog .ms-block').textContent), + /From the spool tag/.test($('.dialog').textContent)].join('/'), 'true/true'); + $('.dialog-x').click(); await wait(60); + + document.body.click(); await wait(60); + /* + * And the head whose LATCH is open but whose spool still carries a tag reads too. + * + * `filament_edit` is the machine's permission and it is a latch: the same firmware + * function that writes a slot sets `filament_official[ch] = False`, so one edit + * unlocks a tagged spool until the tag is read again. Head 2 of the real machine is + * sitting in that state - an NTAG reading Forshape PLA Silk, `print_task_config` + * saying sub-type "" - and the machine will take another write. + * + * The page is deliberately STRICTER than the permission here and never looser: a + * spool that carries a tag is not typed over from this panel, because the record + * reverts on its next load and drifts further from the tag each time. + */ + $$('#filament .ace-more')[1].click(); await wait(60); + say('a tag the machine would let you overwrite is still not edited from here', + `${/View this filament/.test(idRow().textContent)}/${kind(idRow().querySelector('svg'))}`, + 'true/eye'); + idRow().click(); await wait(140); + say('and its sheet is a reading, like the official one', + [$$('.dialog input').length, $('.dialog .btn.primary').textContent].join('/'), + '0/Close'); + $('.dialog-x').click(); await wait(60); + document.body.click(); await wait(60); + + // The head with no tag at all is the one that is a form. + $$('#filament .ace-more')[2].click(); await wait(60); + say('an untagged head offers Edit, with the pencil', + `${/Edit this filament/.test(idRow().textContent)}/${kind(idRow().querySelector('svg'))}`, + 'true/pencil'); + idRow().click(); await wait(140); + say('and that one really is a form, with a Confirm that writes', + [$$('.dialog input').length > 0, $('.dialog .btn.primary').textContent].join('/'), + 'true/Confirm'); + $('.dialog-x').click(); await wait(60); + document.body.click(); await wait(60); + + /* ---- and the tag is its own mark, because it is its own fact -------- */ + /* + * Reported from the panel: "the Forshape PLA is also an RFID tag, but that does not + * show". It did not, and it never had on this machine. + * + * The green `RFID` word lives in the four-slot form, and a printer that reports an + * `ace` object never draws that form - it draws four toolhead cards, and heads on + * their STOCK FEEDER are drawn as a feeder box inside one. So a tagged feeder spool + * had no tag mark at all here. It was masked while the eye was driven by `f.tag`; + * pointing the eye at the machine's `filament_edit` (which is right) left a pencil + * and nothing else on exactly the head that motivated the change. + * + * Two marks, two questions, mirrored about the same roll. + */ + const cards = () => $$('#filament .ace-card'); + say('a printer with an ACE draws cards, not the four-slot form', + [cards().length, $$('#filament .slot').length].join('/'), '4/0'); + say('a tagged head carries BOTH marks', + [!!cards()[1].querySelector('.ace-prov'), + !!cards()[1].querySelector('.ace-tag')].join('/'), 'true/true'); + // The tag mark is not redundant beside the eye: it says WHICH refusal this is - the + // spool's own record, rather than the machine having nothing to edit. + say('and the eye, because a tagged spool is not typed over from here', + !!cards()[1].querySelector('.ace-prov svg circle'), true); + say('the tag mark says what it is, in words', + /carries an RFID tag/.test(cards()[1].querySelector('.ace-tag').title), true); + say('a head with no tag carries only the one mark', + [!!cards()[2].querySelector('.ace-prov'), + !!cards()[2].querySelector('.ace-tag')].join('/'), 'true/false'); + // Geometry: a tag must not move the roll. `.ace-tag` is absolute like its twin, and + // a bay is 62px - anything in flow there costs a material name its last characters. + const box = (n) => { const r = n.getBoundingClientRect(); + return `${Math.round(r.width)}x${Math.round(r.height)}`; }; + say('a tag costs the spool no layout', + box(cards()[1].querySelector('.ace-bay')), + box(cards()[2].querySelector('.ace-bay'))); + const off = (n) => { const r = n.getBoundingClientRect(); + const d = cards()[1].querySelector('.ace-disc').getBoundingClientRect(); + return r.left + r.width / 2 - (d.left + d.width / 2); }; + say('and the two sit mirrored about the roll, at the same height', + [Math.abs(off(cards()[1].querySelector('.ace-prov')) + + off(cards()[1].querySelector('.ace-tag'))) < 0.5, + Math.abs(cards()[1].querySelector('.ace-prov').getBoundingClientRect().top + - cards()[1].querySelector('.ace-tag').getBoundingClientRect().top) < 0.5] + .join('/'), 'true/true'); + say('and none of it cost the body its 456', fits(), true); + + /* ---- entering head mode, which the machine answers by wiping the heads --- */ + /* + * Reported: "switching between ACE modes on --real switches filaments, blacks them." + * + * The machine does it, deliberately. `ACE_RUN_MODE_SWITCH` into head mode runs + * `_clear_filament_display(h)` for every FEEDER head, which sends + * `SET_PRINT_FILAMENT_CONFIG FILAMENT_TYPE="" VENDOR="" FILAMENT_COLOR_RGBA=00000000`. + * `00000000` is RRGGBBAA with alpha ZERO - no colour - and the panel painted it black, + * because cssColor threw the alpha away. The filament is still physically in the head. + */ + // A FEEDER head, found rather than assumed: an earlier block switched Toolhead 1 onto + // ACE A, so card 0 is a cabinet by now and `_clear_filament_display` only ever + // touches heads on their stock feeder. + const fed = cards().findIndex((c) => !c.querySelector('.ace-cab')); + const disc = (n) => getComputedStyle(cards()[n].querySelector('.ace-disc')).background; + const chip = (n) => cards()[n].querySelector('.ace-chip').textContent; + say('there is a stock-feeder head to wipe', fed >= 0, true); + const wasNamed = disc(fed); + const other = cards().findIndex((c, k) => k !== fed && !c.querySelector('.ace-cab')); + const otherWas = disc(other); + // Kept rather than written back from a literal: which head this is depends on what + // the blocks above left, and each one ships a different colour. + const held = ['filamentVendor', 'filamentType', 'filamentSubType', 'filamentColorRgba'] + .map((k) => [k, P.mock.printer[k][fed]]); + // The tag on this head, from the block above, and what it says. + const tagType = P.state.filaments()[fed].tag + ? P.state.filaments()[fed].tag.type : null; + P.mock.printer.clearFilamentDisplay(fed); + await wait(1500); + say('a wiped head is not black', /rgb\(0, *0, *0\)/.test(disc(fed)), false); + /* + * And a head whose SPOOL still knows what it is says so. + * + * `_clear_filament_display` wipes `print_task_config` and does not touch + * `filament_detect`, so on a tagged head the identity is still on the machine - in + * the other object. Measured on 811002511261022618B3 after cycling normal → multi → + * head: two heads reading `'' '' '00000000'` in one and `Jayo PLA Marble` / + * `Forshape PLA Silk` in the other. The panel drew both blank, which is what was + * reported. + */ + say('a wiped head falls back to the spool`s own record', + [chip(fed), /gradient/.test(disc(fed))].join('/'), `${tagType}/false`); + say('and it is read-only, because that record is the tag`s', + !!cards()[fed].querySelector('.ace-prov svg circle'), true); + + // Take the tag away too, and there is genuinely nothing left that names it - which is + // the state a machine whose feeder spools carry no tag is in after the same wipe. + // Occupied-and-unnamed is the neutral and the `?` a bay in that state has always used. + const info = [0, 1, 2, 3].map((k) => (k === fed + ? { MAIN_TYPE: 'NONE', SUB_TYPE: 'NONE', VENDOR: 'NONE' } + : (P.state.filaments()[k].tag ? { MAIN_TYPE: 'PLA', SUB_TYPE: 'Silk', VENDOR: 'Jayo', + ARGB_COLOR: 4294198070 } + : { MAIN_TYPE: 'NONE' }))); + P.state.apply({ filament_detect: { info } }); + await wait(900); + say('with no tag either, it is occupied and unnamed', + [/183, *189, *198/.test(disc(fed)), /gradient/.test(disc(fed))].join('/'), + 'true/false'); + say('and it says so, in the chip and in words', + [chip(fed), + /occupied, not named/.test(cards()[fed].querySelector('.ace-bay').title)].join('/'), + '?/true'); + say('a head the machine did not wipe is untouched', disc(other), otherWas); + say('and the card repainted at all, which needs the colour in its signature', + disc(fed) !== wasNamed, true); + say('and none of it cost the body its 456', fits(), true); + // Put it all back, so what follows sees the panel it expects - the tags this block + // took away as well as the identity the wipe cleared. + P.state.apply({ filament_detect: { state: [0, 0, 0, 0], + info: [tagInfo('PLA'), tagInfo('PLA'), tagInfo('NONE'), tagInfo('NONE')] } }); + held.forEach(([k, v]) => { P.mock.printer[k][fed] = v; }); + await wait(1500); + say('and it comes back when the machine names it again', + disc(fed), wasNamed); + + await setAce(null); + say('with no ACE the page draws the four slots', $$('#filament .slot').length, 4); + // Both tagged slots read, for two different reasons - slot 1 because the machine + // calls the record its own, slot 2 because the page will not type over a tag whose + // latch happens to be open. The badge still answers its own question, and here it + // agrees with the mark on both. + say('and the same pair marks them there', + $$('#filament .slot').map((sl) => + `${sl.querySelector('.slot-tag') ? 'RFID' : '-'}:${kind(sl.querySelector('.pencil'))}`) + .join(' '), + 'RFID:eye RFID:eye -:sprite -:sprite'); + P.state.apply({ filament_detect: null }); + + await setAce(measured); + say('the page ends where it started', $$('#filament .ace-card').length, 4); + } catch (e) { out.push('FAIL threw: ' + ((e && e.stack) || e)); } + window.__report = out.join('\n'); })(); +})(); diff --git a/resources/web/shared/tests/drive/ace-real.js b/resources/web/shared/tests/drive/ace-real.js new file mode 100644 index 00000000000..4c48e874f2e --- /dev/null +++ b/resources/web/shared/tests/drive/ace-real.js @@ -0,0 +1,231 @@ +/* The multiACE Filament card against a REAL printer. + * + * Read-only, deliberately and permanently. `ace-panel.js` switches sources, loads bays + * and starts the dryer; every one of those is minutes of physical work on a machine with + * filament in it, and a test suite is not a thing that should be able to purge a nozzle. + * This one asks the printer what it has and checks the panel drew that - nothing else. + * + * It also DUMPS the raw `ace` object, because that is the point: `state.js` reads several + * of its fields under more than one spelling, and until this has run against hardware the + * right spelling is a guess taken from multiACE's own source rather than a reading. + * + * python3 run_webkit.py --real --size 1920x1080 --drive drive/ace-real.js + * + * Needs Orca closed - it authenticates with the same saved clientId, and a broker evicts + * the older holder. + */ +(function () { + const out = []; const P = window.__devicePage; + const say = (n, g, w) => out.push(`${g === w ? 'PASS' : 'FAIL'} ${n}` + + (g === w ? '' : ` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const info = (s) => out.push(` ${s}`); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s) => document.querySelector(s); + const $$ = (s) => [...document.querySelectorAll(s)]; + const round = (v) => Math.round(v * 10) / 10; + + (async () => { try { + for (let i = 0; i < 40 && !(P && P.bridge); i += 1) await wait(400); + for (let i = 0; i < 40 && !P.state.lastUpdate; i += 1) await wait(500); + say('a session came up and state arrived', P.state.lastUpdate > 0, true); + + const t0 = Date.now(); + await P.session.refreshAce(); + const took = Date.now() - t0; + info(`sw_GetMachineState {objects:{ace:null}} took ${took} ms`); + await wait(200); + + const raw = P.state.get('ace'); + say('the printer answers for the `ace` object', !!raw, true); + if (!raw) { window.__report = out.join('\n'); return; } + + /* ---- what the machine actually said -------------------------------- */ + // Printed whole. Every field state.js reads defensively is settled by looking at + // this, and nothing else can settle it. + const dump = JSON.stringify(raw, null, 1); + info('---- raw `ace`, as the printer reports it ' + '-'.repeat(28)); + dump.split('\n').slice(0, 400).forEach((l) => info(l)); + if (dump.split('\n').length > 400) info(`... ${dump.split('\n').length - 400} more lines`); + info('-'.repeat(70)); + info(`top-level keys: ${Object.keys(raw).sort().join(', ')}`); + const a0 = (raw.aces || [])[0] || {}; + info(`aces[0] keys: ${Object.keys(a0).sort().join(', ')}`); + const s0 = (a0.slots || [])[0] || {}; + info(`aces[0].slots[0] keys: ${Object.keys(s0).sort().join(', ')}`); + info(`aces[0].dryer_status: ${JSON.stringify(a0.dryer_status)}`); + info(`humidity=${JSON.stringify(a0.humidity)} temp=${JSON.stringify(a0.temp)}`); + info(`head_source: ${JSON.stringify(raw.head_source)}`); + + /* ---- what state.js made of it -------------------------------------- */ + const ace = P.state.ace(); + info(`ace(): ${ace.unitCount} unit(s), mode ${ace.mode}, ` + + `heads ${ace.heads.map((h) => h.source + (h.unitId ? ':' + h.unitId : '')).join(' ')}`); + ace.units.forEach((u) => info( + ` ACE ${u.id} ${u.model} ${u.firmware || '(no firmware field)'} ` + + `${u.humidity == null ? 'humidity?' : u.humidity + '%'} ` + + `${u.temperature == null ? 'temp?' : u.temperature + 'C'} ` + + `dryer ${JSON.stringify(u.dryer)} ` + + `bays ${u.bays.map((b) => (b.occupied ? (b.material || '?') : '-')).join('/')}`)); + + /* ---- the fields that were guessed, and are not any more ------------ */ + // Each of these replaced a guess that the machine had quietly disagreed with. They + // are asserted here so a firmware that moves one fails loudly instead of showing a + // dash, or a countdown that is sixty times too fast. + const d0 = a0.dryer_status || {}; + say('dryer_status carries the four fields the panel reads', + ['status', 'target_temp', 'duration', 'remain_time'] + .filter((k) => !(k in d0)).join(',') || 'none', 'none'); + say('and its idle word is one the panel reads as stopped', + P.state.ace().units[0].dryer.running, String(d0.status) !== 'stop'); + const ad = a0.auto_dry || {}; + say('auto-dry is the unit`s own object, with the arguments the macro takes', + ['enabled', 'rh_start', 'rh_end', 'temp'].filter((k) => !(k in ad)).join(',') + || 'none', 'none'); + say('the unit names its own model rather than leaving it to `protocol`', + typeof a0.model === 'string' && a0.model.length > 0, true); + say('the settings a person sets once are reported, not write-only', + ['confirm_commands', 'spoolman_url', 'spoolman_auto', 'purge_matrix'] + .filter((k) => !(k in raw)).join(',') || 'none', 'none'); + const st = P.state.ace().settings; + info(`settings: confirm=${st.confirmCommands} spoolman=${st.spoolmanUrl || '(none)'} ` + + `auto=${st.spoolmanAuto} matrix=${st.purgeMatrix} bound=${st.bound}`); + + /* ---- what is IN each bay, which the ace object does not say -------- */ + // The discrepancy this file exists to catch: Orca's Prepare page polls + // /multiace/api/state from C++ and sees four named bays, while the Klipper object + // reports four blanks. The names live in multiACE's override store, and the store is + // a file under Moonraker's config root - which the page CAN fetch, because Moonraker + // reflects the Origin where nginx's /multiace/ sends no CORS header at all. + await P.handlers.syncBays(); + await wait(150); + const bays = P.store.aceBays; + info(`override store: ${bays ? Object.keys(bays).sort().join(' ') : '(none)'}`); + const rawNamed = (a0.slots || []).filter((s2) => (s2.material || '').trim()).length; + info(`raw slots with a material: ${rawNamed} of ${(a0.slots || []).length}`); + if (bays) { + const occupied = P.state.ace().units[0].bays.filter((b) => b.occupied); + const named = $$('#filament .ace-cab .ace-chip') + .map((e) => e.textContent).filter((t) => t && t !== '?' && t !== '/'); + say('every occupied bay the store names is drawn with that name', + named.length, occupied.length); + say('and the mark says the name came from multiACE, not from a tag', + $$('#filament .ace-cab .ace-prov') + .some((e) => /multiACE/.test(e.title)), true); + say('so no occupied bay is left reading `?`', + $$('#filament .ace-cab .ace-chip').filter((e) => e.textContent === '?').length, 0); + } else { + info('no override store on this printer - occupied bays stay unnamed, correctly'); + } + + /* ---- the background-swap gate -------------------------------------- */ + // Written down as unavailable: `bg_swap` is in multiACE's FastAPI state and not in + // the `ace` object. It IS its own Klipper object, so the page can ask for it. + try { + const snap = await P.bridge.request('sw_GetMachineState', + { objects: { ace_bg_swap: null } }); + P.state.applyPayload(snap); + const bg = P.state.get('ace_bg_swap'); + info(`ace_bg_swap: ${JSON.stringify(bg)}`); + say('the background-swap gate is a Klipper object, and it answers', + !!bg && Array.isArray(bg.enabled_heads), true); + } catch (e) { + say('the background-swap gate is a Klipper object, and it answers', + `threw: ${e.message}`, true); + } + + say('the unit count matches device_count', + ace.unitCount, Math.min(4, Number(raw.device_count) || 0)); + say('a humidity reading is a percentage, not a 1-5 bucket', + ace.units.every((u) => u.humidity == null || u.humidity > 5 || u.humidity === 0), true); + say('every unit reports a temperature', + ace.units.every((u) => u.temperature != null), true); + // The trap: head_ace answers for every head whether or not that head is on an ACE. + const lying = ace.heads.filter((h, i) => { + const ha = raw.head_ace && (raw.head_ace[i] !== undefined ? raw.head_ace[i] + : raw.head_ace[String(i)]); + return Number(ha) >= ace.unitCount && h.source === 'ace'; + }); + say('no head is drawn on a unit that is not attached', lying.length, 0); + + /* ---- and what the panel drew --------------------------------------- */ + const cards = $$('#filament .ace-card'); + say('the panel drew the ACE shape rather than the four slots', + cards.length + ':' + $$('#filament .slot').length, '4:0'); + say('one cabinet per head that is on a unit', + $$('#filament .ace-cab').length, + ace.heads.filter((h) => h.source === 'ace').length); + say('the sources on screen are the ones state.js resolved', + cards.map((c) => c.querySelector('.ace-src').value).join(','), + ace.heads.map((h) => (h.source === 'ace' ? `ace:${h.unitIndex}` : h.source)).join(',')); + say('the mode pill reports the machine`s mode', + $('#filament-mode').textContent.trim().toLowerCase(), + `ace mode · ${String(ace.mode || '')}`.toLowerCase()); + + const body = $('#filament'); + info(`body ${body.scrollHeight}/${body.clientHeight}`); + say('nothing is clipped in silence', body.scrollHeight <= body.clientHeight + 1, true); + say('a card is still the height that lets two rows fit', + Math.round(cards[0].getBoundingClientRect().height), 215); + const mid = (e) => { const r = e.getBoundingClientRect(); return r.left + r.width / 2; }; + say('the box and the toolhead sit on one axis on every card', + cards.filter((c) => round(Math.abs(mid(c.querySelector('.ace-tool')) + - mid(c.querySelector('.ace-box')))) > 1).length, 0); + const s = parseFloat(getComputedStyle(cards[0].querySelector('.ace-tool')) + .getPropertyValue('--s')); + let bad = 0; let drawn = 0; + cards.forEach((c) => { + const p = [...c.querySelectorAll('.ace-wire path')].pop(); + if (!p) return; + drawn += 1; + const m = p.getScreenCTM(); + const at = (l) => { const q = p.getPointAtLength(l); + return { x: q.x * m.a + q.y * m.c + m.e, + y: q.x * m.b + q.y * m.d + m.f }; }; + const t = c.querySelector('.ace-tool').getBoundingClientRect(); + const e = at(p.getTotalLength()); + if (Math.abs(e.x - (t.left + 32 * s)) > 1.5 || Math.abs(e.y - t.top) > 1.5 + || Math.abs(at(0).x - e.x) > 1.5) bad += 1; + }); + info(`tubes drawn: ${drawn}`); + say('every tube is vertical and lands on the inlet', bad, 0); + say('nothing inside a card overflows its cell', + $$('#filament .ace-card,#filament .ace-head,#filament .ace-hrow,#filament .ace-strip') + .filter((e) => e.scrollWidth > e.clientWidth + 1).length, 0); + say('no panel reported a paint error', + $$('[data-paint-error]').map((e) => e.id + ':' + e.dataset.paintError).join(','), ''); + + /* ---- the dryer dialog, opened and closed, sending nothing ----------- */ + const chip = $('#filament .ace-dry'); + if (chip) { + chip.click(); await wait(120); + const d = $('.dialog'); + say('the Dry chip opens the dialog on real readings', !!d, true); + if (d) { + const line = d.querySelector('.dry-cmd').textContent.trim(); + info(`dialog says: ${line}`); + /* + * What the dialog SAYS, which is no longer what it would send. + * + * These two lines used to assert `DURATION=240` and `ENABLE=`/`RH_START=` by + * reading the macro off this element - and the copy pass replaced it with prose, + * because a dialog says what a control is and the trace pane is where the wire + * belongs. Both facts still matter and are still checked; they moved to + * conformance_test.py, which reads the call site that has to be right. What is + * left for a HARDWARE run is that the sentence is built from this machine's own + * readings rather than from a default. + */ + say('the dialog states the duration in hours, as it offers them', + /for \d+ h/.test(line), true); + say('and says whether automatic drying is on, in words', + /automatically/.test(line), true); + say('and the droplet is drawn from a real humidity', + !!d.querySelector('.dry-drop svg rect'), true); + $('.dialog-x').click(); await wait(60); + } + say('and closing it sent nothing', $$('.dialog').length, 0); + } else { + info('no unit on this machine, so no dryer chip'); + } + } catch (e) { out.push('FAIL threw: ' + ((e && e.stack) || e)); } + window.__report = out.join('\n'); })(); +})(); diff --git a/resources/web/shared/tests/drive/ace-verbs-real.js b/resources/web/shared/tests/drive/ace-verbs-real.js new file mode 100644 index 00000000000..bb0c0abdc10 --- /dev/null +++ b/resources/web/shared/tests/drive/ace-verbs-real.js @@ -0,0 +1,275 @@ +/* What the panel offers on the REAL machine, and what that machine says it is doing. + * + * READ-ONLY, deliberately and permanently, for the same reason drive/ace-real.js is: every + * verb on this surface is minutes of physical work on a printer with filament in it, and + * ACE_BG_SWAP's own help says it purges ~60 mm through an open dock. A suite is not a + * thing that should be able to do that. So this opens sheets and menus, reads what they + * offer, dumps what `channel_state` actually says, and sends nothing. + * + * What only hardware can answer, and why it is worth a script: + * - `channel_state` is what the step bar reads. The words come from HelixScreen's + * classification of this firmware; this is the first time this page has looked at the + * field on a real machine, and it prints what it finds rather than asserting a guess. + * - `ace_bg_swap.enabled_heads` is `[]` here, so the refusal is the state the panel is + * actually in - which makes it the state most worth checking is drawn well. + */ +(function () { + const out = []; const P = window.__devicePage; + const say = (n, g, w) => out.push(`${g === w ? 'PASS' : 'FAIL'} ${n}` + + (g === w ? '' : ` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const note = (t) => out.push(' ' + t); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s) => document.querySelector(s); + const $$ = (s) => [...document.querySelectorAll(s)]; + const shut = () => { const x = $('.dialog-x'); if (x) x.click(); }; + + (async () => { + try { + /* + * Wait for the session, which this script never did. + * + * Its sibling ace-real.js has always had this and this one has not, so against a + * real printer it read `ace` before the connect had finished, found nothing, and + * walked off the end of an empty model - which is why the harness's own answer was + * "the driving script never reported" rather than a failure it could name. The + * handover predicted it in the right words and the wrong place: this section had + * "not yet been run on the machine". + */ + for (let i = 0; i < 40 && !(P && P.bridge); i += 1) await wait(400); + for (let i = 0; i < 40 && !P.state.lastUpdate; i += 1) await wait(500); + // `ace` is not on the subscription, so a session being up does not mean it is here. + for (let i = 0; i < 20 && !P.state.ace().present; i += 1) { + await P.session.refreshAce(); await wait(400); + } + const ace = P.state.ace(); + say('the machine has multiACE', ace.present, true); + + /* ---- what the printer says each channel is doing --------------------- */ + // Printed, not asserted: this is the field the step bar reads and the first look at + // it on hardware. An idle machine should say an idle word, and which word it uses + // is a fact about this firmware. + const feeds = P.state.feedChannels(); + note('channel_state per head: ' + + feeds.map((f, i) => `${i}=${(f && f.channelState) || 'null'}`).join(' ')); + note('feed sensors: ' + feeds.map((f) => f + ? `${f.inAce ? 'ace' : '-'}/${f.inToolhead ? 'tube' : '-'}/${f.atExtruder ? 'ext' : '-'}` + : 'none').join(' ')); + say('every head reports a channel state at all', + feeds.filter((f) => f && f.channelState).length, 4); + say('and the panel draws no step bar for an idle one', + $$('#filament .ace-flight').length, 0); + + /* ---- the gate, as this machine has it ------------------------------- */ + note('ace_bg_swap.enabled_heads: ' + JSON.stringify(ace.bgHeads) + + ' version: ' + ace.bgVersion); + say('the gate is read in the same call as `ace`', Array.isArray(ace.bgHeads), true); + + /* ---- the bay sheet, on the head this machine actually feeds ---------- */ + const fedHead = ace.heads.findIndex((h) => h.source === 'ace' && h.bay != null); + say('one head is fed from a bay', fedHead >= 0, true); + const fedBay = ace.heads[fedHead].bay; + note(`toolhead ${fedHead + 1} is fed from bay ${fedBay}`); + + const card = $$('#filament .ace-card')[fedHead]; + const bays = [...card.querySelectorAll('.ace-bay')]; + bays[fedBay].click(); await wait(120); + say('clicking the bay that is feeding opens its sheet', !!$('.dialog'), true); + say('and it offers no load and no swap, because neither is a verb here', + $$('.dialog .verb').length, 0); + say('saying which it is rather than showing an empty sheet', + /Already feeding/.test($('.dialog').textContent), true); + shut(); await wait(80); + + const other = [0, 1, 2, 3].find((k) => k !== fedBay); + bays[other].click(); await wait(120); + // A swap is not offered on a bay either: it moves a TOOLHEAD from one bay to + // another, and the toolhead is what every macro it sends addresses. The sheet says + // what state the head is in and offers nothing; the verb is on the head's sheet. + say('another bay offers no verb either, and says why', + [$$('.dialog .verb').length, /is loaded/.test($('.dialog').textContent)].join('/'), + '0/true'); + shut(); await wait(80); + + /* ---- the lines that would go out, with this machine's own indices ----- */ + // From the MODEL, not the DOM: the panel puts no macro name on screen (the trace + // pane is where the wire belongs), and this is the one place what would be sent can + // be checked against a real machine's indices rather than the simulator's. + const unitIx = ace.units[ace.heads[fedHead].unitIndex].index; + const verbs = P.multiACE.aceVerbs(ace, fedHead, other, P.state.headLoaded(fedHead)); + note('for that bay: ' + verbs.map((v) => `${v.name}${v.off ? ' (off)' : ''}`).join(', ')); + say('a different bay offers the two that take a slot', verbs.length, 2); + say('the first is a swap, because that head is loaded', verbs[0].name, 'Swap'); + /* + * And NOT `ACE_SWAP_HEAD`. That macro is the PRINT's swap: it opens with + * `G91 / G1 Z2 F600 / G90` to lift the nozzle off the part, and Klipper refuses a Z + * move on an unhomed Z - so on this machine, sitting at `homed_axes: "xy"`, it + * answered `ok`, printed `!! Must home Z axis first` and did nothing. Neither + * ACE_UNLOAD_HEAD nor ACE_LOAD_HEAD moves Z, which is why the pair is what + * multiACE's own dashboard and HelixScreen both send for this verb. + */ + say('and it goes out as an unload then a load, which need no homed Z', + verbs[0].cmd, `ACE_UNLOAD_HEAD HEAD=${fedHead}\n` + + `ACE_LOAD_HEAD HEAD=${fedHead} ACE=${unitIx} SLOT=${other}`); + note('toolhead.homed_axes right now: ' + + JSON.stringify((P.state.toolhead() || {}).homedAxes) + + ' — a swap must not care, and this is the machine it did care on'); + say('the background one is refused on this machine', !!verbs[1].off, true); + say('and names ACE_BG_SET_HEAD, which is what would lift it', + verbs[1].cmd, `ACE_BG_SET_HEAD HEAD=${fedHead} ENABLE=1`); + + /* ---- the card menu, on the same head --------------------------------- */ + card.querySelector('.ace-more').click(); await wait(120); + const items = $$('.menu .menu-item').map((b) => ({ + label: b.querySelector('span').textContent, + title: b.title, + muted: b.classList.contains('is-muted'), + })); + note('card menu: ' + items.map((i) => `${i.label}${i.muted ? ' (muted)' : ''}`).join(', ')); + say('the menu carries the verbs that take no slot', + items.filter((i) => /nload/.test(i.label)).map((i) => i.label).join(','), + 'Unload and retract,Background unload'); + // No macro chips on any of them: the reason is in the row's own hover, in words. + say('and the refused one gives its reason in words, not in G-code', + items.find((i) => i.label === 'Background unload').title, + 'Background unload — not enabled for this toolhead'); + document.body.click(); await wait(80); + + /* ---- the toolhead, on the machine ------------------------------------ */ + // The other thing you can click. Read-only like the rest: the sheet is opened, what + // it offers is read, and it is closed. Nothing in it is pressed. + say('every toolhead is a target on the real panel', + $$('#filament .ace-tool.is-target').length, 4); + const tool = $$('#filament .ace-tool')[fedHead]; + tool.click(); await wait(160); + say('clicking one opens that head`s sheet', + $('.dialog h3').textContent, `Toolhead ${fedHead + 1}`); + const picks = $$('.dialog .pickbay').map((b) => ({ + addr: b.querySelector('.pickdisc').textContent, + does: b.querySelector('.picklab').textContent, + off: b.disabled, + })); + note('bays brought to the sheet: ' + + picks.map((p) => `${p.addr}=${p.does}${p.off ? '(off)' : ''}`).join(' ')); + say('all four bays come to it, because a head is not a slot', picks.length, 4); + say('and the one already feeding is the one that is not offered', + picks.findIndex((p) => p.off), fedBay); + say('the head`s own verbs are below them', + $$('.dialog .verb .verb-name').map((e) => e.textContent.split(' — ')[0]).join(','), + 'Unload and retract,Background unload'); + note('head sheet identity: ' + $('.dialog .verb-idtext').textContent); + shut(); await wait(80); + + /* ---- a stock feeder head, which has one verb at a time ---------------- */ + const feedHead = ace.heads.findIndex((h) => h.source !== 'ace'); + const fcard = $$('#filament .ace-card')[feedHead]; + fcard.querySelector('.ace-more').click(); await wait(120); + const f = $$('.menu .menu-item span:not(.mcmd)').map((e) => e.textContent); + /* + * Against the SENSOR, not the job record - and on this machine the two disagree, + * which is the whole reason the panel was changed. + * + * `print_task_config.filament_exist` is what the slicer assigned to the slot and is + * not cleared by a physical unload; `filament_at_extruder` is what is in the head. + * A head unloaded by hand reads loaded in the first and empty in the second, and + * the panel offering `Unload` again was this check's expectation being right about + * the wrong field. + */ + const job = !!(P.state.filaments()[feedHead] || {}).loaded; + const feed = P.state.feedChannels()[feedHead] || {}; + const loaded = P.state.headLoaded(feedHead); + note(`toolhead ${feedHead + 1} is a stock feeder · ${feed.channelState}/` + + `${feed.actionState} says ${loaded ? 'loaded' : 'empty'} · at_extruder says ` + + `${feed.atExtruder ? 'loaded' : 'empty'} · print_task_config says ` + + `${job ? 'loaded' : 'empty'}`); + say('it offers exactly one of load and unload, and follows the sensor', + f.filter((t) => /^(Load|Unload)$/.test(t)).join(','), loaded ? 'Unload' : 'Load'); + say('and never a swap, because it has no second bay', + f.some((t) => /^Swap/.test(t)), false); + document.body.click(); await wait(80); + + /* ---- the field that says whether a head is holding filament ---------- */ + /* + * Reported twice, and the second report is why this section exists: an emptied + * toolhead went on offering `Unload`, and the fix that made every simulated check + * pass - reading `filament_at_extruder` - did nothing here, because on this machine + * that field does not go false when a head is emptied. + * + * A simulator cannot catch that. It can only be wrong in the ways it was written to + * be wrong, and it was written from the same belief as the panel. So this asserts + * against the machine that the three plausible fields disagree with each other, and + * that the panel is reading the one that is right. + */ + const feeds = P.state.feedChannels(); + const occ = feeds.map((f, i) => P.state.headLoaded(i)); + feeds.forEach((f, i) => note(`head ${i}: ${f.channelState}/${f.actionState}` + + ` · at_extruder=${f.atExtruder} detected=${f.detected} in_toolhead=${f.inToolhead}` + + ` · occupied=${occ[i]}`)); + + // Not a fixed expectation - what is loaded depends on what was left in the machine. + // What CAN be asserted is that the panel and the model never diverge, and that the + // marker, the sheet and the menu are three views of one answer. + for (let i = 0; i < 4; i++) { + const mark = $$('#filament .ace-sensor')[i]; + say(`head ${i + 1}: the marker draws the occupancy the model decided`, + mark.classList.contains('is-at') || mark.classList.contains('is-err'), occ[i]); + } + const emptied = feeds.findIndex((f) => f.actionState === 'unload_finish'); + if (emptied < 0) { + note('no head reports channel_action_state: unload_finish - nothing has been ' + + 'unloaded since boot, so the case that was reported is not on the machine ' + + 'right now.'); + } else { + note(`head ${emptied} was unloaded: at_extruder=${feeds[emptied].atExtruder}` + + ` (the field that was believed), job=` + + `${!!(P.state.filaments()[emptied] || {}).loaded} (the field before that)`); + say('an unloaded head is empty however loaded the other two fields look', + occ[emptied], false); + $$('#filament .ace-tool')[emptied].click(); await wait(220); + say('and it offers Load rather than Unload again', + $$('.dialog .verb .verb-name').map((e) => e.textContent) + .filter((t) => /^(Load|Unload)/.test(t)).join(','), 'Load'); + const x = $('.dialog-x'); if (x) x.click(); + document.body.click(); await wait(120); + } + + /* ---- what the printer said, when it said no ------------------------- */ + /* + * A swap reported "Nothing started. The toolhead is waiting for filament." after + * twenty-five seconds. The printer had answered in under one, in two places the + * page was not reading: + * + * ace.last_swap_result {head:3, ace:0, slot:1, status:"error", ts:...} + * the console !! Must home Z axis first: 229.300 250.000 277.000 + * + * `toolhead.homed_axes` was "xy" - ACE_SWAP_HEAD parks and picks a head and does + * not home first, unlike the U1's own feeder verbs. The `sw_SendGCodes` reply was + * `ok`, which on this machine is not a yes. + * + * Read-only: this asserts the page can REACH both, using whatever the machine + * already has in it. It sends nothing. + */ + const aceNow2 = P.state.ace(); + note(`ace.lastSwap: ${JSON.stringify(aceNow2.lastSwap)}` + + ` · swapping=${aceNow2.swapping} phase=${aceNow2.swapPhase}`); + say('the swap fields the panel now watches are on this machine', + [typeof aceNow2.swapping, 'lastSwap' in aceNow2].join('/'), 'boolean/true'); + + const { gcodeStoreUrl, lastPrinterError } = P.multiACE; + const url = gcodeStoreUrl(P.device, 40); + say('and a Moonraker console URL can be built for it', !!url, true); + let store = null; + try { const r = await fetch(url, { cache: 'no-store' }); + store = r.ok ? await r.json() : null; } catch (e) { store = null; } + say('the page can read the printer`s console across the origin', + !!(store && store.result && store.result.gcode_store), true); + const said = lastPrinterError(store); + note(`the last error the printer printed: ${said === null ? '(none)' : said}`); + say('and lastPrinterError returns a line or null, never a stray `!!`', + said === null || (typeof said === 'string' && !said.startsWith('!!')), true); + + say('the body still fits its 456 on the real machine', + $('#filament').scrollHeight <= $('#filament').clientHeight + 1, true); + } catch (e) { out.push('FAIL threw: ' + ((e && e.stack) || e)); } + window.__report = out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/ace-verbs.js b/resources/web/shared/tests/drive/ace-verbs.js new file mode 100644 index 00000000000..d0fe550e65f --- /dev/null +++ b/resources/web/shared/tests/drive/ace-verbs.js @@ -0,0 +1,303 @@ +/* What can be done to one filament, and what the machine is doing about it. + * + * Both were settled in docs/u1-webui/02-device-page/multiace-actions.html and neither can + * be checked by reading the page: the verb list depends on the state the HEAD is in, and + * the step bar on a `channel_state` that only moves during a three-minute physical swap. + * So this drives both. + * + * The two halves reach their state by different routes, and the difference is worth + * keeping in mind when adding to this: + * + * `ace` is NOT on the subscription, so the mirror can be written directly - + * nothing pushes over it. That is what drive/ace-panel.js does. + * `filament_feed` IS, so the simulator re-pushes its own snapshot and a write into the + * mirror is gone within the second. The SIMULATED PRINTER is what has + * to change, and the page then learns about it the way it would from a + * real one. + */ +(function () { + const out = []; const P = window.__devicePage; + const say = (n, g, w) => out.push(`${JSON.stringify(g) === JSON.stringify(w) ? 'PASS' : 'FAIL'} ${n}` + + (JSON.stringify(g) === JSON.stringify(w) ? '' : ` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s) => document.querySelector(s); + const $$ = (s) => [...document.querySelectorAll(s)]; + const names = () => $$('.verb .verb-name').map((e) => e.textContent); + // One closer for every popup this panel opens: the dialog's own X, and a body click for + // a menu, which closes on the document listener. + const shut = () => { const x = $('.dialog-x'); if (x) x.click(); document.body.click(); }; + (async () => { + try { + /* ---- a bay says what is in it; a swap is chosen at the toolhead ------ */ + // A swap reads as an operation on the filament when it is offered on the spool - + // "swap this one" - and what it actually does is move a TOOLHEAD from one bay to + // another. Every macro it sends addresses `HEAD=n`, and the head's own sheet brings + // every bay to it labelled with what each would do. + const bays = () => $$('#filament .ace-card.is-ace .ace-bay'); + bays()[2].click(); await wait(120); + say('the bay already feeding says so', + /Already feeding/.test($('.dialog').textContent), true); + shut(); await wait(80); + bays()[0].click(); await wait(120); + say('and another bay offers no swap, only what it holds', + [names(), /Toolhead 4 is loaded/.test($('.dialog').textContent)], [[], true]); + say('with the bay`s own identity above it', + !!$('.dialog .verb-idtext'), true); + shut(); await wait(80); + + // the head-level verbs, in the card menu where they belong + $$('#filament .ace-card.is-ace .ace-more')[0].click(); await wait(120); + const items = $$('.menu .menu-item span:not(.mcmd)').map((e) => e.textContent); + // Names only: the reason lives in the bay's sheet, which has a line for it, and in + // the row's own title. Spelling it in the label took the menu to 532 px on a 391 px + // card - the macro chip beside a muted row is the reason, in the panel's own idiom. + say('the menu carries the verbs that take no slot', + items.filter((t) => /nload/.test(t)), + ['Unload and retract', 'Background unload']); + // No macro chips: the reason is in the row's own hover, in words. + say('and the refused ones are muted, and carry no G-code', + [$$('.menu .menu-item.is-muted').map((b) => b.title), + $$('.menu .mcmd').length], + [['Background unload — not enabled for this toolhead', + 'Click the toolhead, then the bay'], 0]); + say('and points at the bay for the ones that do', + items.some((t) => /Swap to another bay/.test(t)), true); + document.body.click(); await wait(80); + + // a feeder head: one verb, and only one + $$('#filament .ace-card:not(.is-ace) .ace-more')[0].click(); await wait(120); + const f = $$('.menu .menu-item span:not(.mcmd)').map((e) => e.textContent); + say('a loaded stock feeder offers unload, alone', + f.filter((t) => /^(Load|Unload)/.test(t)), ['Unload']); + document.body.click(); await wait(80); + + // and what the machine is doing, from its own channel_state + /* ---- the toolhead, which is the other thing you can click ------------ */ + // Every one of these macros addresses HEAD=n, so the head is what they are about - + // and it is the only target that does not shrink with the panel. + say('every toolhead is a target', $$('#filament .ace-tool.is-target').length, 4); + const tool = $$('#filament .ace-tool')[3]; + say('and wears nothing at rest', getComputedStyle(tool, '::before').boxShadow, 'none'); + tool.classList.add('is-hover'); + say('the pointer lights the bay`s own traced edge, 1.5 px and no fill', + getComputedStyle(tool, '::before').boxShadow, 'rgb(12, 99, 226) 0px 0px 0px 1.5px inset'); + tool.classList.remove('is-hover'); + tool.click(); await wait(180); + say('clicking one opens that toolhead`s sheet', $('.dialog h3').textContent, 'Toolhead 4'); + say('naming what it holds and where from', + $('.dialog .verb-idtext').textContent, 'PETG BasicGeneric · ACE A'); + // two of the verbs take a SLOT= and a head is not one, so the bays come to it + say('the bays come to the sheet, each saying what it would do in this state', + $$('.dialog .pickbay').map((b) => b.querySelector('.picklab').textContent).join(','), + 'Swap,Swap,feeding,Swap'); + say('and the one already feeding is not offered', + $$('.dialog .pickbay')[2].disabled, true); + say('with the head`s own verbs below them', + $$('.dialog .verb .verb-name').map((e) => e.textContent.split(' — ')[0]).join(','), + 'Unload and retract,Background unload'); + shut(); await wait(100); + $$('#filament .ace-tool')[0].click(); await wait(180); + say('a stock feeder head has no bays to bring, and one verb', + [$$('.dialog .pickbay').length, + $$('.dialog .verb .verb-name').map((e) => e.textContent).join(',')].join('/'), + '0/Unload'); + shut(); await wait(100); + say('and none of that cost the body its 456', + $('#filament').scrollHeight <= $('#filament').clientHeight + 1, true); + + /* ---- an activity code is not a fault --------------------------------- */ + // Reported: starting a load raised a red banner reading `Printer fault · code + // 0000000000000240 · not in the shipped catalogue`. That is 0x240 = 576, which is + // `action_code` for "Auto Loading" - the fine-grained ACTIVITY table, padded into a + // 16-digit fault code that could never match because it never was one. + P.mock.printer.mainState = 1; P.mock.printer.actionCode = 576; + await wait(1500); + say('an activity code does not raise the fault banner', $('#fault').hidden, true); + say('and it is still read as activity', + P.state.activity().actionCode, 576); + P.mock.printer.actionCode = 0; P.mock.printer.mainState = 0; await wait(1400); + + /* ---- a non-background verb takes the machine over, and says so -------- */ + // Reported: `Toolhead 1: load failed: sw_SendGCodes timed out after 15000ms` for a + // load that was running - a load HOMES first, and the bridge gives up at 15s. The + // request is not awaited now, and the wait is a blocking dialog because a swap is + // three minutes during which a second verb is a collision rather than a queue. + $$('#filament .ace-tool')[3].click(); await wait(160); + $$('.dialog .pickbay').find((b) => b.querySelector('.picklab').textContent === 'Swap') + .click(); + await wait(400); + say('a swap opens a blocking dialog', !!$('.dialog.blocking'), true); + say('naming both ends of it', $('.dialog.blocking h3').textContent, + 'Swap A1 → Toolhead 4'); + say('with no way to dismiss it while the machine is working', + !$('.dialog.blocking .dialog-x'), true); + // And what went out is an UNLOAD then a LOAD, not ACE_SWAP_HEAD. + // + // That macro is the print's swap: it opens with `G91 / G1 Z2 F600 / G90` to lift + // the nozzle off the part, and Klipper refuses a Z move on an unhomed Z - so it + // answered `ok`, printed `!! Must home Z axis first` and did nothing on a machine + // at `homed_axes: "xy"`. Neither half of the pair moves Z. Asserted on the WIRE, + // because the panel does not put macro names on screen. + say('and a swap sends the pair that needs no homed Z, in that order', + P.mock.printer.gcodeLog[P.mock.printer.gcodeLog.length - 1], + 'ACE_UNLOAD_HEAD HEAD=3\nACE_LOAD_HEAD HEAD=3 ACE=0 SLOT=0'); + P.mock.printer.channels[3].state = 'unload_heating'; await wait(1500); + say('and it reports the step the printer is on, not a spinner alone', + $('.blocking-msg').textContent, 'Heat nozzle (3/6)'); + P.mock.printer.channels[3].state = 'load_flushing'; await wait(1500); + say('following the same bar across both halves', + $('.blocking-msg').textContent, 'Purge (6/6)'); + P.mock.printer.channels[3].state = 'load_finish'; await wait(1700); + say('and it closes when the machine says it finished', !$('.dialog.blocking'), true); + P.mock.printer.channels[3].state = 'wait_insert'; + + /* ---- multiACE's own verdict, in a second rather than twenty-five ----- */ + // Reported: a swap sat for 25s and then said "Nothing started. The toolhead is + // waiting for filament." The printer had answered in under a second, in two places + // the page was not reading: `ace.last_swap_result.status` went to `error`, and its + // console carried `!! Must home Z axis first` - `homed_axes` was "xy". The RPC reply + // was `ok`, which on this machine is not a yes. + // + // The Z failure itself is gone with ACE_SWAP_HEAD, and only that macro writes + // `last_swap_result` - so what this now covers is the PRINT's swap failing + // underneath a verb someone started, which dooms that verb too. + $$('#filament .ace-tool')[3].click(); await wait(160); + $$('.dialog .pickbay').find((b) => b.querySelector('.picklab').textContent === 'Swap') + .click(); + await wait(500); + say('a swap is waiting', !!$('.dialog.blocking'), true); + // On the SIMULATED PRINTER, not the mirror: the wait re-reads `ace` every 400 ms, + // so a value written into the mirror is gone before the next check. + P.mock.printer.ace.last_swap_result = + { head: 3, ace: 0, slot: 0, status: 'error', ts: 1234.5 }; + await wait(1400); + // The reason itself comes from Moonraker, which is not there in the simulator - so + // this is the fallback, and that it FAILS FAST is the part being checked. + say('and multiACE saying error ends it at once, without waiting out the 25 s', + [!!$('.dialog.blocking'), $('.blocking-msg').textContent], + [true, 'The swap failed.']); + say('with a way out, because the machine is no longer working', + !!$('.dialog .dialog-foot .btn'), true); + $('.dialog-foot .btn').click(); await wait(120); + P.mock.printer.ace.last_swap_result = null; + await wait(400); + + /* ---- and a verb the machine ignores does not look like one it is doing --- */ + // Reported: `Load - Toolhead 2` sat on "Asking the printer..." A stock feeder head + // sends `ACE_LOAD_HEAD HEAD=1` with no ACE= and no SLOT=, and that macro's own help + // says it loads a toolhead FROM ACE. Whatever the machine does with it, the panel + // must not spend ninety seconds saying nothing: the state the printer IS in is the + // one thing worth putting on screen. + P.mock.printer.channels[2].act = 'unload_finish'; await wait(1500); + $$('#filament .ace-tool')[2].click(); await wait(200); + say('a toolhead`s verbs are names, with no G-code under them', + [$$('.dialog .verb .verb-name').map((e) => e.textContent), + $$('.dialog .verb .verb-cmd').length, $$('.dialog .verb')[0].title], + [['Load'], 0, 'Load']); + // The wire is asserted on the WIRE. It is not in the DOM any more, and the + // simulated printer records every script it is handed. + const sent = () => P.mock.printer.gcodeLog[P.mock.printer.gcodeLog.length - 1]; + $$('.dialog .verb')[0].click(); + await wait(500); + say('and a stock feeder sends the U1`s own macro, not an ACE one at a head with ' + + 'no ACE', + sent(), 'AUTO_FEEDING EXTRUDER=2 LOAD=1 STAGE=prepare\n' + + 'AUTO_FEEDING EXTRUDER=2 LOAD=1 STAGE=doing'); + say('a verb on an idle head opens the same blocking dialog', + [!!$('.dialog.blocking'), $('.dialog.blocking h3').textContent], + [true, 'Load — Toolhead 3']); + say('and says the state the printer is in, in words rather than its own enum', + $('.blocking-msg').textContent, 'Waiting for filament'); + + /* ---- after an unload, the head is empty and the verbs say so ---------- */ + // Reported from ordinary use TWICE, against two different wrong fields. + // + // `filaments()[i].loaded` is `print_task_config` - what the SLICER assigned to the + // slot - and a physical unload does not clear it, so an emptied head went on + // offering Unload. Reading `filament_at_extruder` instead fixed the simulator and + // not the printer: measured on 811002511261022618B3 with toolhead 1 unloaded by + // hand, that field was still TRUE, along with `filament_detected` and + // `filament_in_toolhead`. `channel_action_state` was the field that had moved. + // + // So this drives all three, and asserts that the two liars are lying. + const exists = () => JSON.stringify(P.state.objects.print_task_config.filament_exist); + const feed0 = () => P.state.objects['filament_feed left'].extruder0; + const before = exists(); + P.mock.printer.channels[0].act = 'unload_finish'; + P.mock.printer.channels[0].state = 'unload_finish'; + await wait(1500); + say('the job record is untouched by an unload, which is the trap', exists(), before); + say('and so is every filament_* boolean, which is the second trap', + [feed0().filament_at_extruder, feed0().filament_detected, + feed0().filament_in_toolhead], [true, true, true]); + say('the field that moves is the last operation the channel finished', + feed0().channel_action_state, 'unload_finish'); + $$('#filament .ace-tool')[0].click(); await wait(180); + say('so an emptied stock feeder offers Load, not Unload again', + $$('.dialog .verb .verb-name').map((e) => e.textContent).join(','), 'Load'); + say('and says it is holding nothing', + /Nothing loaded/.test($('.dialog .verb-idtext').textContent), true); + shut(); await wait(100); + // And then `channel_state` settles back to `wait_insert`, which is where head 1 was + // found on the printer - unloaded, and no longer saying so in the live field. A + // panel reading only that one goes back to offering Unload a second or two later. + P.mock.printer.channels[0].state = 'wait_insert'; + await wait(1500); + $$('#filament .ace-tool')[0].click(); await wait(180); + say('and it stays Load once the live state settles back to wait_insert', + $$('.dialog .verb .verb-name').map((e) => e.textContent).join(','), 'Load'); + shut(); await wait(100); + $$('#filament .ace-more')[0].click(); await wait(140); + say('the card menu agrees, because both read the same answer', + $$('.menu .menu-item span:not(.mcmd)').map((e) => e.textContent) + .filter((t) => /^(Load|Unload)/.test(t)).join(','), 'Load'); + document.body.click(); await wait(80); + + // the same hole was in the ACE head: `head_source` names the bay it was fed from + // and does not stop naming it because the filament came out. + P.mock.printer.channels[3].act = 'unload_finish'; + await wait(1500); + say('an emptied ACE head is a LOAD from any bay, not a swap', + (bays()[0].click(), await wait(160), + names().map((t) => t.split(' — ')[0])), ['Load']); + shut(); await wait(100); + P.mock.printer.channels[0].act = 'load_finish'; + P.mock.printer.channels[0].state = 'load_finish'; + P.mock.printer.channels[3].act = 'none'; + await wait(1500); + + // The SIMULATED PRINTER, not the mirror: `filament_feed` is on the subscription, + // so the mock re-pushes its own snapshot and a write into the mirror is overwritten + // within the second. `ace` could be written directly precisely because it is not. + const setState = async (v) => { + P.mock.printer.channels[3].state = v; + await wait(1400); + }; + await setState('unload_heating'); + await wait(120); + say('an unload_heating draws the swap bar at its heat step', + [$$('#filament .ace-card.is-ace .ace-steps i').length, + $('#filament .ace-card.is-ace .ace-flight-at').textContent], [6, 'Heat nozzle']); + say('with the steps behind it done', + $$('#filament .ace-card.is-ace .ace-steps i.is-done').length, 2); + await setState('load_flushing'); + await wait(120); + say('and the load half lands on the same bar, not a new one', + [$$('#filament .ace-card.is-ace .ace-steps i').length, + $('#filament .ace-card.is-ace .ace-flight-at').textContent], [6, 'Purge']); + await setState('unload_fail'); + await wait(120); + // It used to draw `unload_fail` verbatim, on the reasoning that translating a + // firmware state would be this page's opinion of what happened. Two words is not an + // opinion, and the enum was the only thing on screen naming the failure. + say('a failure is named in words, and still says which half failed', + $('#filament .ace-card.is-ace .ace-flight-at').textContent, 'Unload failed'); + await setState('none'); + await wait(120); + say('and idle draws nothing at all', $$('#filament .ace-flight').length, 0); + say('the body still fits its 456', + $('#filament').scrollHeight <= $('#filament').clientHeight + 1, true); + } catch (e) { out.push('FAIL threw: ' + ((e && e.stack) || e)); } + window.__report = out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/camera-real.js b/resources/web/shared/tests/drive/camera-real.js new file mode 100644 index 00000000000..248a425cc2a --- /dev/null +++ b/resources/web/shared/tests/drive/camera-real.js @@ -0,0 +1,75 @@ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const q=(s)=>document.querySelectorAll(s); + (async ()=>{ + try{ + for (let i=0;i<40 && !(P&&P.bridge);i++) await wait(400); + for (let i=0;i<40 && !P.store.device;i++) await wait(400); + out.push(` device ${P.store.device && P.store.device.ip} reachable=${P.store.reachable}`); + const H = P.byModule.camera; + + await H.startCamera(); + await wait(1500); + const st=H.cameraStatus(); + out.push(' cameras: ' + P.store.cam.cams.map(c=>`${c.name}/${c.service}${c.snapshot_url?'':' (no still)'}`).join(' ')); + say('the real printer answered the camera list', P.store.cam.cams.length >= 2, true); + say('multiACE was rejected', P.store.cam.cams.some(c=>c.name==='multiACE'), false); + say('auto chose the direct snapshot', st.transport, 'snapshot'); + say('a tile is up', q('.cam-tile').length >= 1, true); + const im=document.querySelector('.cam-tile img.cam-frame'); + // The first frame is a real HTTP round trip to the printer; waiting for it is the + // check, not a workaround for one. + for (let i=0;i<40 && !(im.naturalWidth>0);i++) await wait(250); + say('a real frame decoded', im && im.naturalWidth > 0, true); + out.push(` frame ${im.naturalWidth}x${im.naturalHeight} src ${im.src.slice(0,58)}...`); + say('it is the case camera at 1080p', im.naturalWidth, 1920); + + // ---- measure the rate through the PANEL, not a synthetic loop ---- + const count = (sec) => new Promise((res)=>{ + let n=0; const el=document.querySelector('.cam-tile img.cam-frame'); + const h=()=>n++; + el.addEventListener('load',h); + setTimeout(()=>{ el.removeEventListener('load',h); res(n/sec); }, sec*1000); + }); + const fps = await count(4); + out.push(` MEASURED ${fps.toFixed(1)} fps through the panel at 15 requested`); + say('the panel is well past the monitor file’s 0.5 fps', fps > 5, true); + + // ---- and that the fps cap actually caps ---- + H.setCameraFps(1); await wait(600); + const slow = await count(4); + out.push(` MEASURED ${slow.toFixed(1)} fps at 1 requested`); + say('the cap is honoured', slow <= 2.5, true); + H.setCameraFps(15); await wait(400); + + // ---- the grid, and the focus rule, against real cameras ---- + H.setCameraView('grid'); await wait(2500); + say('grid draws four cells', q('.cam-tile').length, 4); + const live=[...q('.cam-tile img.cam-frame')].filter(i=>i.naturalWidth>0); + say('every real camera decoded', live.length, P.store.cam.cams.length); + out.push(' tiles: ' + [...q('.cam-tile[data-cam]')].map(t=>{ + const i=t.querySelector('img'); return `${t.dataset.cam} ${i.naturalWidth}x${i.naturalHeight}`; + }).join(' ')); + + // focused tile fast, the rest at 1 + const rate = (sel, sec) => new Promise((res)=>{ + let n=0; const el=document.querySelector(sel); if(!el) return res(-1); + const h=()=>n++; el.addEventListener('load',h); + setTimeout(()=>{ el.removeEventListener('load',h); res(n/sec); }, sec*1000); + }); + H.focusTile(0); await wait(500); + const [f0,u1] = await Promise.all([ + rate('.cam-tile[data-index="0"] img', 4), + rate('.cam-tile[data-index="1"] img', 4)]); + out.push(` focused ${f0.toFixed(1)} fps unfocused ${u1.toFixed(1)} fps`); + say('the focused tile runs fast', f0 > 4, true); + say('the unfocused one does not', u1 <= 2.5, true); + + await H.stopCamera(); await wait(600); + say('stop clears the tiles', q('.cam-tile').length, 0); + } catch(e){ out.push('FAIL drive threw: '+(e&&e.stack||e)); } + window.__report=out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/camera.js b/resources/web/shared/tests/drive/camera.js new file mode 100644 index 00000000000..827b36f613b --- /dev/null +++ b/resources/web/shared/tests/drive/camera.js @@ -0,0 +1,113 @@ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const q=(s)=>document.querySelectorAll(s); + (async ()=>{ + try{ + const H = P.byModule.camera; + say('the panel has a gear', !!document.getElementById('camera-settings'), true); + say('camera is off at rest', P.store.cam.streaming, false); + say('nothing is playing yet', q('.cam-tile').length, 0); + + // ---- start ---- + await H.startCamera(); + await wait(400); + const st = H.cameraStatus(); + say('the simulator was asked and answered', P.store.cam.cams.length, 3); + say('the multiACE panel was rejected as a camera', + P.store.cam.cams.some(c=>c.name==='multiACE'), false); + say('the touchscreen is kept, and last', + P.store.cam.cams[P.store.cam.cams.length-1].name, 'gui'); + say('a direct transport was chosen, not the monitor file', st.transport, 'snapshot'); + say('this engine reports no H.264', st.caps.h264, false); + say('this engine reports no WebRTC', st.caps.webrtc, false); + say('single view draws one tile', q('.cam-tile').length, 1); + const im0 = document.querySelector('.cam-tile img.cam-frame'); + say('the poller pointed it somewhere', !!(im0 && im0.src), true); + // The rule, not the artifact: an http still is re-fetched every frame and must be + // cache-busted; a data: URI has no cache and appending to it corrupts the data. + say('cache-busting follows the scheme', + /^data:/.test(im0.src) ? !/\?t=/.test(im0.src) : /[?&]t=\d+/.test(im0.src), true); + say('the frame actually decoded', im0.naturalWidth > 0, true); + say('no failure marker', im0.dataset.failed, undefined); + + // ---- views ---- + for (const [v,n] of [['split',2],['grid',4],['pip',2],['single',1]]) { + H.setCameraView(v); await wait(250); + say(`${v} draws ${n} tiles`, q('.cam-tile').length, n); + } + H.setCameraView('grid'); await wait(300); + say('grid: three cameras and one explanation', q('.cam-tile.is-empty').length, 1); + say('the empty cell says why', q('.cam-tile.is-empty .cam-tile-msg')[0].textContent, 'No fourth camera'); + say('every real tile is named', q('.cam-tile[data-cam] .cam-tile-name').length, 3); + const srcs=[...q('.cam-tile img.cam-frame')].map(i=>i.src).filter(Boolean); + say('all three tiles are pointed somewhere', srcs.length, 3); + say('every tile decoded', [...q('.cam-tile img.cam-frame')].every(i=>i.naturalWidth>0), true); + + // ---- the focus rule ---- + say('tile 0 has focus by default', q('.cam-tile.is-focus').length, 1); + H.focusTile(2); await wait(250); + const f=document.querySelector('.cam-tile.is-focus'); + say('focus moved to the clicked tile', f && f.dataset.index, '2'); + say('and only one tile has it', q('.cam-tile.is-focus').length, 1); + + // ---- geometry: tiles fill the viewport and do not overlap ---- + const view=document.querySelector('.cam-view').getBoundingClientRect(); + const rs=[...q('.cam-tile')].map(t=>t.getBoundingClientRect()); + say('four cells in a 2x2', rs.length, 4); + say('the grid covers the viewport width', + Math.abs((rs[0].width+rs[1].width+2) - view.width) < 3, true); + say('and its height', Math.abs((rs[0].height+rs[2].height+2) - view.height) < 3, true); + say('no tile escapes the viewport', + rs.every(r=>r.left>=view.left-1 && r.right<=view.right+1 + && r.top>=view.top-1 && r.bottom<=view.bottom+1), true); + say('the play control is above the tiles', + getComputedStyle(document.querySelector('.cam-play')).zIndex, '4'); + + // ---- the settings popover ---- + document.getElementById('camera-settings').click(); + await wait(200); + const pop=document.querySelector('.popover'); + say('the gear opens a popover', !!pop, true); + say('the picture is still up behind it', q('.cam-tile').length, 4); + say('it offers four views', pop.querySelectorAll('.cam-set-group')[0].querySelectorAll('.cam-seg-btn').length, 4); + const modes=[...pop.querySelectorAll('.cam-mode')]; + say('five transports listed', modes.length, 5); + const off=modes.filter(m=>m.dataset.off==='1'); + say('two are unavailable in this engine', off.length, 2); + say('and each says why', + off.every(m=>{const t=m.querySelector('.cam-mode-tag.is-no'); return t && t.textContent.length>8;}), true); + out.push(' reasons: ' + off.map(m=>m.querySelector('.cam-mode-tag').textContent).join(' | ')); + say('Auto is selected', modes[0].dataset.on, '1'); + say('and reports what it picked', + modes[0].querySelector('.cam-mode-tag').textContent, 'Direct snapshot'); + say('unavailable transports cannot be clicked', off.every(m=>m.disabled), true); + const fps=[...pop.querySelectorAll('.cam-set-group')].pop().querySelectorAll('.cam-seg-btn'); + say('the fps scale stops at 15', [...fps].map(b=>b.textContent).join(','), '1,5,10,15'); + + // ---- the stock printer's path: one file, half a frame a second ---- + document.querySelector('.popover-x').click(); await wait(120); + H.setCameraTransport('monitor'); await wait(500); + say('the monitor transport is in use', H.cameraStatus().transport, 'monitor'); + say('it draws exactly one tile', q('.cam-tile').length, 1); + const mim=document.querySelector('.cam-tile[data-cam="__monitor"] img.cam-frame'); + say('and points it at the monitor file', !!(mim && mim.src), true); + say('which decodes too', mim.naturalWidth > 0, true); + say('the monitor tile carries no camera name', + q('.cam-tile[data-cam="__monitor"] .cam-tile-name').length, 0); + H.setCameraTransport('auto'); await wait(400); + say('back to auto picks the direct transport again', H.cameraStatus().transport, 'snapshot'); + + // ---- stop ---- + await H.stopCamera(); await wait(250); + say('stopping clears the tiles', q('.cam-tile').length, 0); + say('and says the camera is not on', document.querySelector('.cam-msg').textContent, 'Camera not on'); + // nothing may keep polling after a stop + const before=[...q('img')].length; + await wait(400); + say('no poller survived the stop', P.store.cam.streaming, false); + } catch(e){ out.push('FAIL drive threw: '+(e&&e.stack||e)); } + window.__report=out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/dom-dump.js b/resources/web/shared/tests/drive/dom-dump.js new file mode 100644 index 00000000000..9af0308654e --- /dev/null +++ b/resources/web/shared/tests/drive/dom-dump.js @@ -0,0 +1,24 @@ +/* Walk .app and print every tag, id, class and data-*, plus the geometry that matters. + Diff two runs of this to answer "what is on the page", not "is this one thing right". */ +(function () { + const L = []; + const walk = (el, d) => { + const cls = el.className && typeof el.className === 'string' + ? '.' + el.className.trim().split(/\s+/).filter(Boolean).join('.') : ''; + const id = el.id ? '#' + el.id : ''; + const data = Object.keys(el.dataset || {}).sort() + .map(k => `[${k}=${el.dataset[k]}]`).join(''); + const r = el.getBoundingClientRect(); + const geo = (r.width || r.height) + ? ` {${Math.round(r.width)}x${Math.round(r.height)}@${Math.round(r.left)},${Math.round(r.top)}}` : ''; + L.push(' '.repeat(d) + el.tagName.toLowerCase() + id + cls + data + geo); + [...el.children].forEach(c => walk(c, d + 1)); + }; + const app = document.querySelector('.app'); + if (app) walk(app, 0); else L.push('NO .app'); + L.push('--- summary ---'); + L.push('nodes: ' + document.querySelectorAll('*').length); + const cs = getComputedStyle(document.querySelector('.content')); + L.push('content display: ' + cs.display + ' cols: ' + cs.gridTemplateColumns); + window.__report = L.join('\n'); +})(); diff --git a/resources/web/shared/tests/drive/homing-real.js b/resources/web/shared/tests/drive/homing-real.js new file mode 100644 index 00000000000..9efbc43effc --- /dev/null +++ b/resources/web/shared/tests/drive/homing-real.js @@ -0,0 +1,85 @@ +/* + * The Homing dialog against a REAL G28, which is the only witness that counts. + * + * This one MOVES THE MACHINE: it presses the page's own Home button and watches the + * dialog against the printer's own state until the axes stop. Everything else about + * this wait was got wrong twice from plausible readings of fields that turned out not + * to mean what they looked like, so it is checked here where the bed can be seen. + * + * python3 run_webkit.py --real --sn --size 1920x1080 --drive drive/homing-real.js + * + * Needs Orca closed - it authenticates with the same saved clientId, and a broker + * evicts the older holder. Takes about a minute; a G28 measured 42 s. + */ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const info=(s)=>out.push(' '+s); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const open=()=>!!document.querySelector('.dialog.blocking'); + const msg =()=>{const m=document.querySelector('.blocking-msg');return m?m.textContent:null;}; + const g=(k)=>P.state.get(k)||{}; + + (async ()=>{ + try{ + for (let i=0;i<40 && !(P&&P.bridge);i++) await wait(400); + for (let i=0;i<40 && !P.state.lastUpdate;i++) await wait(500); + say('a session came up and state arrived', P.state.lastUpdate>0, true); + await P.session.refreshWaitState(); + info('before: homed_axes='+JSON.stringify(g('toolhead').homed_axes) + +' idle_timeout='+g('idle_timeout').state); + + const t0=Date.now(); const at=()=>((Date.now()-t0)/1000); + P.byModule.control.home(); + + // Sample until the dialog goes, or two minutes, whichever comes first. + let openedAt=null, closedAt=null, busySeen=0, lastRow=''; + let velZeroWhileOpen=0, sawMotion=false, labels=new Set(); + for (let i=0;i<600;i++){ + const up=open(); + if (up && openedAt===null) openedAt=at(); + const mr=g('motion_report'), it=g('idle_timeout'); + const v=Number(mr.live_velocity)||0; + const busy=it.state==='Printing'; + if (busy) busySeen++; + if (Math.abs(v)>0.001) sawMotion=true; + if (up) { if (Math.abs(v)<=0.001) velZeroWhileOpen++; if (msg()) labels.add(msg()); } + const row=[up?'DIALOG':'- - ', 'idle='+it.state, + 'vel='+v.toFixed(2), + 'z='+(Array.isArray(mr.live_position)?Number(mr.live_position[2]).toFixed(1):'-'), + 'msg='+JSON.stringify(msg())].join(' '); + if (row!==lastRow){ info(at().toFixed(1).padStart(5)+' '+row); lastRow=row; } + if (!up && openedAt!==null){ closedAt=at(); break; } + await wait(200); + } + + say('the dialog opened', openedAt!==null, true); + say('the printer actually ran it', busySeen>0 && sawMotion, true); + say('and the dialog was still up when the machine went quiet', closedAt!==null, true); + if (closedAt===null){ window.__report=out.join('\n'); return; } + + info(`open ${openedAt.toFixed(1)}s -> closed ${closedAt.toFixed(1)}s ` + +`(${(closedAt-openedAt).toFixed(1)}s), idle_timeout said Printing for ` + +`${(busySeen*0.2).toFixed(1)}s`); + info('lines shown: '+JSON.stringify([...labels])); + + // The bug, stated as a number: the dialog used to go at the first quiet sample. + // It must instead last essentially as long as the machine claimed to be working. + say('it lasted as long as the machine was working, not a moment of it', + closedAt >= busySeen*0.2, true); + say('and that was a real procedure, not a moment', closedAt > 10, true); + // Zero velocity is not the end of anything: it was measured reading 0 for two + // seconds at a time, eight times, mid-G28. + say('it survived the zero-velocity gaps rather than ending on one', + velZeroWhileOpen > 5, true); + + await P.session.refreshWaitState(); + info('after: homed_axes='+JSON.stringify(g('toolhead').homed_axes) + +' idle_timeout='+g('idle_timeout').state); + say('the axes are homed once it is over', P.state.toolhead().allHomed, true); + say('and the machine is idle, so nothing was still moving behind the dialog', + g('idle_timeout').state!=='Printing', true); + }catch(e){ out.push('FAIL threw: '+(e&&e.stack||e)); } + window.__report=out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/homing.js b/resources/web/shared/tests/drive/homing.js new file mode 100644 index 00000000000..7974b6dda74 --- /dev/null +++ b/resources/web/shared/tests/drive/homing.js @@ -0,0 +1,88 @@ +/* + * The Homing dialog against the G28 this machine actually performs. + * + * The first two attempts at this wait both closed the dialog while the bed was still + * moving, and both were built on `homed_axes`. A real G28 was then watched end to end + * (811002511261022618B3, 2026-09-01, 42 s, sampled every 200 ms) and disposed of it: + * + * 0.0s homed_axes "xyz" idle_timeout "Ready" vel 0 <- G28 sent + * 1.6s homed_axes "xyz" idle_timeout "Printing" vel 0 + * 5.5s homed_axes "xyz" vel 40 + * 7.6s homed_axes "xyz" vel 0 <- and seven more + * 27.6s homed_axes "xyz" vel 0 z 309 + * 41.7s homed_axes "xyz" idle_timeout "Ready" vel 0 <- done + * + * `homed_axes` never moved, so as a done() it was true before the machine started; and + * `live_velocity` sat at exactly 0 for two seconds at a time, eight times, so no + * stillness window short enough to be responsive survives it. `idle_timeout` bracketed + * the operation exactly, and that is what the wait ends on. + * + * That shape is replayed here, shortened but with the killer intervals intact: the + * lead-in before the machine picks the command up, and a long zero-velocity gap in the + * middle of the work. The simulator models neither field, so both are posed on `state` + * - the object the wait reads them from. The loop under test is the page's. + */ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const open=()=>!!document.querySelector('.dialog.blocking'); + const msg =()=>{const m=document.querySelector('.blocking-msg');return m?m.textContent:null;}; + + // What the machine says. `homed` is fixed at xyz for the whole run, because that is + // what it read - changing it here would be testing a printer nobody has. + const homed = 'xyz'; + let running = false, vel = 0; + const S = P.state; + S.toolhead = () => ({ activeKey:null, activeIndex:null, x:0, y:0, z:0, e:0, + homedAxes: homed, isHomed:()=>true, allHomed:true, present:true }); + S.busyReason = () => ({ label: vel ? 'Moving…' : (running ? 'Working…' : null), + busy: !!vel || running, vague: true, homing:false, + homedAxes: homed, calibrationStep:null }); + S.activity = () => ({ mainState:0, actionCode:0 }); + + (async ()=>{ + try{ + P.byModule.control.home(); + await wait(600); + say('the dialog opens', open(), true); + // 0.0 -> 1.6s: the command is sent, the machine has not picked it up. Nothing is + // moving and idle_timeout still reads Ready, so every "it has stopped" test is + // momentarily true - and homed_axes has been true since before the press. + say('and does not close before the machine has started', open(), true); + say('nor on the homed_axes it was already reporting', + P.state.toolhead().allHomed, true); + + running = true; // idle_timeout -> Printing, still nothing moving + await wait(1200); + say('still up once the machine takes the command', open(), true); + + vel = 40; // the first homing move + await wait(1200); + say('still up while an axis is homing', open(), true); + + vel = 0; // the measured two-second gap BETWEEN moves + await wait(2600); + say('STILL up across a two-second gap between homing moves', open(), true); + + vel = 2; // the bed, travelling + await wait(1200); + say('still up while the bed travels', open(), true); + say('and the dialog keeps its own line rather than flickering Moving/Working', + msg(), 'Homing all axes…'); + + vel = 0; running = false; // idle_timeout -> Ready + await wait(1600); + say('and closes when the machine goes quiet', open(), false); + + // ---- a G28 the machine never acknowledges is reported, not closed ---- + // Nothing ever reports busy, so there is no edge to end on. The wait must not sit + // there for ever, and must not pretend it finished either. + P.byModule.control.home(); + await wait(1200); + say('a command the machine never picks up leaves the dialog up', open(), true); + say('and it is still the waiting line', msg(), 'Homing all axes…'); + }catch(e){ out.push('FAIL threw: '+(e&&e.stack||e)); } + window.__report = out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/job-band.js b/resources/web/shared/tests/drive/job-band.js new file mode 100644 index 00000000000..9f1684e2ec8 --- /dev/null +++ b/resources/web/shared/tests/drive/job-band.js @@ -0,0 +1,90 @@ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const b=(s)=>{const e=document.querySelector(s);if(!e)return null;const r=e.getBoundingClientRect(); + return {w:Math.round(r.width),h:Math.round(r.height),x:Math.round(r.left),y:Math.round(r.top), + cy:Math.round(r.top+r.height/2)};}; + (async()=>{ try{ + const body=document.querySelector('#task'); + const panel=document.querySelector('.col-main > .panel:last-child'); + out.push(` card ${body.clientHeight} panel ${b('.col-main > .panel:last-child').h} content ${body.scrollHeight}`); + say('nothing is clipped', body.scrollHeight <= body.clientHeight, true); + say('the card is a band, not a stack', body.clientHeight < 160, true); + + // ---- the status is in the header ---- + const badge=document.querySelector('.panel-head .job-badge'); + say('the status badge is in the panel header', !!badge, true); + say('and it is in the TASK panel header', + badge.closest('.panel') === panel, true); + // Against the simulator this is always `printing`; against a printer it is whatever + // the machine last did. Compare with the live state, not with a word - the first + // real run read `cancelled` and was right to. + const live = P.state.job().state; + say('it reads the live state', badge.textContent, + live === 'standby' ? 'idle' : live); + // This check used to assert the OPPOSITE - that the hover read + // `print_stats.state: `, which f7ddc2973 put there so that renaming standby + // to idle "hid nothing". a946c250b took it off again and was right to: translating + // is not hiding. `stateLabel()` is a single rename and every other state passes + // through verbatim, so the badge already carries the machine's word; the only thing + // the hover added was the FIELD NAME, which says where the page read it. That is + // the page's business, and the wire has a home already - the trace pane. + // + // So the check is inverted, and it is the rule it guards rather than the instance: + // no schema on a hover. conformance_test.py asks the same of every hover on the + // page; this one asks it of the running DOM, where a title assembled at runtime is + // visible and a source scan cannot see it. + say('and carries no schema on its hover', + /[a-z0-9]_[a-z0-9]|\.[a-z_]{3}/.test(badge.title || ''), false); + say('the card no longer repeats the machine name', + document.querySelectorAll('#task .job-dev').length, 0); + say('nor carries its own badge', document.querySelectorAll('#task .job-badge').length, 0); + + // ---- the band's shape ---- + say('the thumbnail anchors the block', b('.job-thumb').w, 96); + const th=b('.job-thumb'), col=b('.job-col'); + say('the column sits beside it, not under it', col.x > th.x + th.w - 1, true); + say('three rows', document.querySelectorAll('#task .job-row').length, 3); + const bar=b('.job-bar'), btns=b('.job-actions'); + say('the buttons ride the bar row', Math.abs(bar.cy - btns.cy) <= 1, true); + say('and sit to its right', btns.x > bar.x + bar.w - 1, true); + const name=b('.job-name'), meta=b('.job-meta'); + say('the metadata shares the name row', Math.abs(name.cy - meta.cy) <= 1, true); + const pct=b('.job-pct'), nums=b('.job-nums'); + say('the numbers share the percentage row', Math.abs(pct.cy - nums.cy) <= 1, true); + + // ---- it still says what it used to say ---- + say('percentage', /^\d+%$/.test(document.querySelector('.job-pct').textContent), true); + say('layers', /^Layer \d+\/\d+$/.test(document.querySelector('.job-layer').textContent), true); + say('time left', /^\u2014\s\d+h \d+m$/.test(document.querySelector('.job-time').textContent), true); + say('elapsed', /^\d+h \d+m$/.test(document.querySelector('.job-elapsed').textContent), true); + say('filament used, in metres and not guessed grams', + /^\d+\.\d m$/.test(document.querySelector('.job-used').textContent), true); + const nm=document.querySelector('.job-name'); + say('the filename stays on one line', nm.scrollHeight <= nm.clientHeight + 1, true); + say('and the whole of it is reachable', nm.title.length > nm.textContent.length - 1, true); + + // ---- the badge follows the machine, not a click ---- + // Only the simulator can be told to pause on demand; against a printer this is + // skipped rather than faked, and says so. + if (P.mock) { + P.mock.printer.pause(); await wait(1600); + say('pausing moves the header badge', badge.textContent, 'paused'); + say('and recolours it', badge.dataset.state, 'paused'); + say('the card grew a resume button', + document.querySelector('.job-btn[data-kind="play"]') !== null, true); + P.mock.printer.resume(); await wait(1600); + say('resuming moves it back', badge.textContent, 'printing'); + } else { + out.push(' (pause/resume needs the simulator; skipped against a printer)'); + } + + // ---- and the camera took the height back ---- + const cam=b('.col-main > .panel'), main=b('.col-main'); + out.push(` column ${main.h} = camera ${cam.h} + 20 + task ${b('.col-main > .panel:last-child').h}`); + say('the column is exactly filled', + Math.abs(main.h - (cam.h + 20 + b('.col-main > .panel:last-child').h)) <= 1, true); + } catch(e){ out.push('FAIL threw: '+(e&&e.stack||e)); } + window.__report=out.join('\n'); })(); +})(); diff --git a/resources/web/shared/tests/drive/no-printer.js b/resources/web/shared/tests/drive/no-printer.js new file mode 100644 index 00000000000..974d0cdc5e4 --- /dev/null +++ b/resources/web/shared/tests/drive/no-printer.js @@ -0,0 +1,28 @@ +/* The not-connected branch, with no printer involved. Camera discovery reads the + device record, so this is exactly the path the project's own rule says to force. */ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + (async()=>{ try{ + for (let i=0;i<30 && !(P&&P.bridge);i++) await wait(400); + await wait(3000); + out.push(` reachable=${P.store.reachable} device=${P.store.device && P.store.device.ip}`); + say('the page is up', !!document.querySelector('.app'), true); + say('both columns still built', document.querySelectorAll('.view-col').length, 2); + say('every panel painted', document.querySelectorAll('#view-control .panel').length, 4); + say('nothing reported a paint error', + [...document.querySelectorAll('[data-paint-error]')].map(e=>e.id+':'+e.dataset.paintError).join(','), ''); + const t0=Date.now(); + await P.byModule.camera.startCamera(); + const took=Date.now()-t0; + out.push(` startCamera took ${took}ms with nothing there`); + say('discovery gives up rather than hanging', took < 12000, true); + say('and finds no cameras', P.store.cam.cams.length, 0); + say('so it falls back to the monitor file', + P.byModule.camera.cameraStatus().transport, 'monitor'); + await P.byModule.camera.stopCamera(); + say('the gear still opens', (document.getElementById('camera-settings').click(), !!document.querySelector('.popover')), true); + } catch(e){ out.push('FAIL threw: '+(e&&e.stack||e)); } + window.__report=out.join('\n'); })(); +})(); diff --git a/resources/web/shared/tests/drive/popover-live.js b/resources/web/shared/tests/drive/popover-live.js new file mode 100644 index 00000000000..5ec29546d35 --- /dev/null +++ b/resources/web/shared/tests/drive/popover-live.js @@ -0,0 +1,62 @@ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const pop=()=>document.querySelector('.popover'); + const segOn=(i)=>{const g=pop().querySelectorAll('.cam-set-group')[i]; + const b=g.querySelector('.cam-seg-btn[data-on="1"]'); return b && b.textContent;}; + const modeOn=()=>{const m=[...pop().querySelectorAll('.cam-mode[data-on="1"]')]; + return m.length===1 ? m[0].querySelector('.cam-mode-name').textContent : `${m.length} lit`;}; + (async()=>{ try{ + await P.byModule.camera.startCamera(); await wait(500); + document.getElementById('camera-settings').click(); await wait(300); + say('the popover is open', !!pop(), true); + say('it starts on Single', segOn(0), 'Single'); + + // ---- the bug: click a view and the tick must move WITHOUT reopening ---- + const grid=[...pop().querySelectorAll('.cam-set-group')[0].querySelectorAll('.cam-seg-btn')] + .find(b=>b.textContent==='Grid'); + grid.click(); + await wait(900); // one render tick + say('the popover is still open after clicking', !!pop(), true); + say('and the tick moved to Grid without reopening', segOn(0), 'Grid'); + say('the page behind it followed too', document.querySelectorAll('.cam-tile').length, 4); + + // ---- fps ---- + const fpsGroup=[...pop().querySelectorAll('.cam-set-group')].pop(); + [...fpsGroup.querySelectorAll('.cam-seg-btn')].find(b=>b.textContent==='5').click(); + await wait(900); + say('the fps tick moved', [...fpsGroup.querySelectorAll('.cam-seg-btn')].length && + [...pop().querySelectorAll('.cam-set-group')].pop() + .querySelector('.cam-seg-btn[data-on="1"]').textContent, '5'); + say('and the store agrees', P.store.cam.fps, 5); + + // ---- transport ---- + say('Auto is lit to begin with', modeOn(), 'Auto'); + [...pop().querySelectorAll('.cam-mode')].find(m=> + m.querySelector('.cam-mode-name').textContent==='Monitor file').click(); + await wait(1000); + say('the transport tick moved', modeOn(), 'Monitor file'); + say('and the page switched transport', + P.byModule.camera.cameraStatus().transport, 'monitor'); + say('exactly one transport is ever lit', + pop().querySelectorAll('.cam-mode[data-on="1"]').length, 1); + + // ---- and it does NOT rebuild when nothing changed ---- + const before=pop().querySelector('.cam-set-group'); + await wait(1600); // a couple of render ticks + say('an idle tick leaves the DOM alone', pop().querySelector('.cam-set-group')===before, true); + + // ---- the other popovers must be untouched ---- + document.querySelector('.popover-x').click(); await wait(150); + document.querySelector('.qtile[data-k="speed"]').click(); await wait(300); + const sp=pop(); say('a Control popover still opens', !!sp, true); + const range=sp.querySelector('input[type="range"]'); + say('and it has its slider', !!range, true); + const node=range; + await wait(1600); + say('and nothing rebuilds it under the user', + pop().querySelector('input[type="range"]')===node, true); + } catch(e){ out.push('FAIL threw: '+(e&&e.stack||e)); } + window.__report=out.join('\n'); })(); +})(); diff --git a/resources/web/shared/tests/drive/storage-paging.js b/resources/web/shared/tests/drive/storage-paging.js new file mode 100644 index 00000000000..f39e72606f9 --- /dev/null +++ b/resources/web/shared/tests/drive/storage-paging.js @@ -0,0 +1,108 @@ +/* + * Storage's paging, across all three kinds. + * + * Every one of these sources pages - `start`/`limit` for history, `page_index` for + * recordings, `page_number` for files - and only history had it wired, so the other two + * showed a first page and offered no way to ask for a second. The first page was also + * 20 or 24 rows against a grid that holds 21, so the answer to "what is on this + * printer" arrived a row short of the space it had. + * + * The simulator holds a handful of items, which is too few to page, so the three reads + * are answered here: sixty rows, then a short page. Everything else - the store, the + * commands, the grid, the footer - is the page's. + * + * python3 run_webkit.py --size 1920x1080 --drive drive/storage-paging.js + */ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const info=(s)=>out.push(' '+s); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const vis=(s)=>{const e=document.querySelector(s);return !!e && e.getBoundingClientRect().height>0;}; + const foot=()=>document.querySelector('.stor-foot span').textContent; + const cards=()=>document.querySelectorAll('.stor-card').length; + + const seen=[]; + const real = P.bridge.request.bind(P.bridge); + const rows=(n,tag)=>Array.from({length:n},(_,i)=>({job_id:tag+i, filename:'/f/'+tag+i+'.gcode', + status:'completed', end_time:1756000000+i, print_duration:600, exists:true})); + // Recordings as the printer really sends them: `date_index` and `gcode_name`, no + // `name` and no `id` - and a gcode_name that REPEATS, because a file recorded more + // than once is the normal case. Measured on hardware: 48 distinct gcode_names across + // 60 recordings, one of them four times, which drew 72 cards for 60 items. + const tl=(n,tag)=>Array.from({length:n},(_,i)=>({gcode_name:tag+(i%3), + date_index:'2026080118'+tag+String(i).padStart(3,'0'), + video_duration:'0:10', video_file_size:1024, generate_date:'2026-08-01'})); + const files=(n,tag)=>Array.from({length:n},(_,i)=>({path:'logs/'+tag+i+'.log', size:100, modified:1756000000})); + + P.bridge.request = async (cmd, params) => { + seen.push([cmd, JSON.stringify(params)]); + // only the paged reads are slowed - a roots lookup on the way there would blow the + // test's own budget, not the page's + if (!/History|Timelapse|FileListPage/.test(cmd)) return real(cmd, params); + await wait(500); // long enough to see the spinner + if (cmd === 'sw_GetPrintHistory') + return { jobs: params.start === 0 ? rows(60,'a') : rows(7,'b') }; + if (cmd === 'sw_GetCameraTimelapseInstance') + return { instances: params.page_index === 0 ? tl(60,'a') : tl(4,'b') }; + if (cmd === 'sw_GetFileListPage') + return { files: params.page_number === 0 ? files(60,'a') : files(3,'b') }; + return real(cmd, params); + }; + + (async ()=>{ + try{ + P.handlers.showView('storage'); await wait(200); + + for (const [kind,first,second,cursor] of [ + ['prints', 60, 67, 'start'], + ['timelapses', 60, 64, 'page_index'], + ['logs', 60, 63, 'page_number']]) { + seen.length = 0; + P.handlers.openStorage(kind); await wait(150); + say(`${kind}: a first read shows a spinner, not an empty box`, + vis('.stor-grid') === false && vis('.spinner'), true); + await wait(700); + say(`${kind}: the first page arrived`, cards(), first); + say(`${kind}: and the count says so`, foot(), `${first} shown`); + say(`${kind}: Load more is offered`, vis('.stor-more'), true); + say(`${kind}: and nothing is spinning`, vis('.stor-foot .spinner'), false); + + // Checked without waiting: the flag is raised before the request goes out, so a + // reply that beats the check is the only way this can be seen to fail. On the + // real printer a page came back inside 250 ms and did exactly that. + document.querySelector('.stor-more').click(); + say(`${kind}: pressing it is a page in flight`, + P.handlers.storageData().loadingMore, true); + await wait(150); + say(`${kind}: which spins`, vis('.stor-foot .spinner'), true); + say(`${kind}: and takes the button away while it works`, vis('.stor-more'), false); + say(`${kind}: the grid is not disturbed`, cards(), first); + await wait(700); + say(`${kind}: the page appended`, cards(), second); + say(`${kind}: the count followed`, foot(), `${second} shown`); + say(`${kind}: a short page means no more`, vis('.stor-more'), false); + say(`${kind}: and the spinner is gone`, vis('.stor-foot .spinner'), false); + const asked = seen.filter(([c])=>/History|Timelapse|FileListPage/.test(c)) + .map(([,p])=>JSON.parse(p)[cursor]); + say(`${kind}: the cursor advanced on the wire (${cursor})`, + JSON.stringify(asked), JSON.stringify(kind==='prints'?[0,60]:[0,1])); + // One node per item, however the source names them. A key that repeats leaks a + // node per repaint, and nothing on screen says so - the count read 60 while the + // grid held 72. + say(`${kind}: one card per item, no orphans`, + document.querySelectorAll('.stor-grid .stor-card').length, second); + say(`${kind}: and every card is separately addressable`, + new Set([...document.querySelectorAll('.stor-card')] + .map(c=>c.dataset.key)).size, second); + } + + // the two subjects that used to share cam.error + P.store.timelapses.error = 'x'; + say('a failed listing no longer writes on the camera', + P.store.cam.error, ''); + }catch(e){ out.push('FAIL threw: '+(e&&e.stack||e)); } + P.bridge.request = real; + window.__report=out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/storage-real.js b/resources/web/shared/tests/drive/storage-real.js new file mode 100644 index 00000000000..a6a0b9323bf --- /dev/null +++ b/resources/web/shared/tests/drive/storage-real.js @@ -0,0 +1,69 @@ +/* + * Storage against a REAL printer, read-only: it lists and it pages, and sends nothing + * that changes anything. + * + * This is where the paging was actually settled. The simulator holds a handful of + * items, so nothing there can page and nothing there collides; the printer holds sixty + * recordings of which one file appears four times, and that is what showed the grid + * drawing 72 cards for 60 items - a card key that was not unique, leaking a node per + * repaint. `storage-paging.js` now poses that collision offline; this is the witness it + * was written from. + * + * python3 run_webkit.py --real --sn --size 1920x1080 --drive drive/storage-real.js + * + * Needs Orca closed - it authenticates with the same saved clientId. + */ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const info=(s)=>out.push(' '+s); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const vis=(s)=>{const e=document.querySelector(s);return !!e && e.getBoundingClientRect().height>0;}; + const foot=()=>{const e=document.querySelector('.stor-foot span');return e?e.textContent:null;}; + const cards=()=>document.querySelectorAll('.stor-card').length; + const settle=async()=>{for(let i=0;i<60 && P.handlers.storageData().loading;i++) await wait(300); await wait(500);}; + (async ()=>{ + try{ + for (let i=0;i<40 && !(P&&P.bridge);i++) await wait(400); + for (let i=0;i<40 && !P.state.lastUpdate;i++) await wait(500); + say('a session came up and state arrived', P.state.lastUpdate>0, true); + P.handlers.showView('storage'); await wait(300); + const tabs=[...document.querySelectorAll('#storage .tab')].map(t=>t.title); + say('three kinds', tabs.length, 3); + info('tabs: '+JSON.stringify(tabs)); + + for (const k of ['timelapses','prints','logs']) { + P.handlers.openStorage(k); await settle(); + const d=P.handlers.storageData(); + info(`${k}: ${cards()} cards, footer ${JSON.stringify(foot())}, ` + + `hasMore=${d.hasMore}, error=${JSON.stringify(d.error||'')}`); + say(`${k}: it read the machine without error`, d.error||'', ''); + // The count and the grid are two views of one list. They disagreed by twelve on + // this printer, and nothing on screen said which was right. + say(`${k}: the count matches the grid`, foot(), cards()+' shown'); + say(`${k}: one card per item`, cards(), d.items.length); + say(`${k}: every card separately addressable`, + new Set([...document.querySelectorAll('.stor-card')].map(c=>c.dataset.key)).size, + d.items.length); + say(`${k}: Load more offered exactly when there is more`, + vis('.stor-more'), !!d.hasMore); + if (!d.hasMore) continue; + const before=cards(); + // Read the flag, not the DOM: a page came back inside 250 ms here, so anything + // that waits before looking is racing the printer rather than testing the page. + document.querySelector('.stor-more').click(); + say(`${k}: pressing it is a page in flight`, + P.handlers.storageData().loadingMore, true); + for (let i=0;i<60 && P.handlers.storageData().loadingMore;i++) await wait(300); + await wait(400); + say(`${k}: and it appended`, cards()>before, true); + say(`${k}: still one card per item`, cards(), P.handlers.storageData().items.length); + info(`${k}: ${before} -> ${cards()}`); + } + const glyph=document.querySelector('.stor-card .stor-glyph'); + say('a log card wears the log icon', glyph&&glyph.getAttribute('src'), 'icons/iconLog.svg'); + say('and the icon loaded on this engine', glyph&&glyph.naturalWidth>0, true); + }catch(e){ out.push('FAIL threw: '+(e&&e.stack||e)); } + window.__report=out.join('\n'); + })(); +})(); diff --git a/resources/web/shared/tests/drive/task-card.js b/resources/web/shared/tests/drive/task-card.js new file mode 100644 index 00000000000..340381dffc7 --- /dev/null +++ b/resources/web/shared/tests/drive/task-card.js @@ -0,0 +1,40 @@ +(function(){ + const out=[]; const P=window.__devicePage; + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait=(ms)=>new Promise(r=>setTimeout(r,ms)); + const b=(s)=>{const e=document.querySelector(s);if(!e)return null;const r=e.getBoundingClientRect(); + return {w:Math.round(r.width),h:Math.round(r.height),y:Math.round(r.top)};}; + (async()=>{ try{ + const body=document.querySelector('#task'); + out.push(` printing: body ${body.clientHeight} content ${body.scrollHeight} panel ${b('.col-main > .panel:last-child').h}`); + say('nothing is clipped while printing', body.scrollHeight <= body.clientHeight, true); + const job=document.querySelector('.job'); + const last=job.lastElementChild.getBoundingClientRect(); + say('the last row is inside the panel', + Math.round(last.bottom) <= Math.round(body.getBoundingClientRect().bottom), true); + say('the progress bar is visible', !!b('.job-bar') && b('.job-bar').h > 0, true); + say('both job buttons are visible', document.querySelectorAll('.job-actions .job-btn').length, 2); + const btn=document.querySelectorAll('.job-actions .job-btn')[1].getBoundingClientRect(); + say('and the second one is on screen', + Math.round(btn.bottom) <= Math.round(body.getBoundingClientRect().bottom), true); + + // ---- and the idle machine, where the card is one line ---- + P.mock.printer.printState='standby'; + P.mock.printer.mainState='standby'; + P.mock.printer.filename=''; + await wait(1800); + out.push(` idle card: ${document.querySelector('.job') ? '.job' : (document.querySelector('.empty') ? '.empty' : '?')}`); + out.push(` idle: body ${body.clientHeight} content ${body.scrollHeight}`); + say('idle does not clip either', body.scrollHeight <= body.clientHeight, true); + // Not a floor any more - `--task-h` is gone. The card always draws a 96px thumbnail, + // so what stops it collapsing is the card, not a number in the stylesheet. + say('and does not collapse to nothing', body.clientHeight >= 120, true); + + // the camera still takes the rest of the column + const main=b('.col-main'), cam=b('.col-main > .panel'), task=b('.col-main > .panel:last-child'); + out.push(` column ${main.h} = camera ${cam.h} + 20 + task ${task.h}`); + say('the column is exactly filled', Math.abs(main.h - (cam.h + 20 + task.h)) <= 1, true); + say('the camera still takes the surplus', cam.h > cam.w * 9 / 16, true); + } catch(e){ out.push('FAIL threw: '+(e&&e.stack||e)); } + window.__report=out.join('\n'); })(); +})(); diff --git a/resources/web/shared/tests/run_selftest.py b/resources/web/shared/tests/run_selftest.py new file mode 100644 index 00000000000..7a320d61c89 --- /dev/null +++ b/resources/web/shared/tests/run_selftest.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Run tests/selftest.html in headless Chromium and report the result. + +Exercises the SHARED modules (bridge, state store, protocol constants) against +the simulated host, so it guards both reconstructed surfaces. + +Serves resources/web over loopback (ES modules need a real origin), drives the +self-test page, prints each assertion, and exits non-zero on any failure. + +Requires playwright + a chromium build. Usage: + python3 resources/web/shared/tests/run_selftest.py [--shots ] +""" +import sys, os, threading, functools, http.server, socketserver, argparse + +HERE = os.path.dirname(os.path.abspath(__file__)) +PAGE = os.path.dirname(HERE) +WEB = os.path.dirname(PAGE) + +def serve(directory): + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory) + class Q(socketserver.TCPServer): + allow_reuse_address = True + def log_message(self, *a): pass + httpd = Q(("127.0.0.1", 0), handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd, httpd.server_address[1] + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--shots", help="directory to write screenshots into") + args = ap.parse_args() + + from playwright.sync_api import sync_playwright + httpd, port = serve(WEB) + base = f"http://127.0.0.1:{port}/shared" + print(f"serving {WEB} on {base}") + + rc = 0 + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": 1280, "height": 900}) + page.on("console", lambda m: m.type == "error" and print(" [console error]", m.text)) + page.on("pageerror", lambda e: print(" [page error]", e)) + + page.goto(f"{base}/tests/selftest.html", wait_until="networkidle") + page.wait_for_function("document.getElementById('sum').textContent !== ''", + timeout=90_000) + + for row in page.query_selector_all("#out .t"): + cls = row.get_attribute("class") or "" + print((" PASS " if "pass" in cls else " FAIL ") + row.inner_text()) + summary = page.inner_text("#sum") + print("\n" + summary) + if "fail" in (page.get_attribute("#sum", "class") or "") or \ + not summary.startswith(summary.split("/")[1].split()[0]): + pass + passed, total = summary.split()[0].split("/") + rc = 0 if passed == total else 1 + + if args.shots: + os.makedirs(args.shots, exist_ok=True) + page.screenshot(path=os.path.join(args.shots, "selftest.png"), full_page=True) + print(f"wrote {args.shots}/selftest.png") + + browser.close() + httpd.shutdown() + return rc + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/web/shared/tests/run_webkit.py b/resources/web/shared/tests/run_webkit.py new file mode 100644 index 00000000000..91658b7357d --- /dev/null +++ b/resources/web/shared/tests/run_webkit.py @@ -0,0 +1,761 @@ +#!/usr/bin/env python3 +"""Drive the Device page in WebKitGTK - the engine Orca's own webview uses. + + python3 resources/web/shared/tests/run_webkit.py [--shots ] + python3 resources/web/shared/tests/run_webkit.py --real # against the printer + python3 resources/web/shared/tests/run_webkit.py --real --watch # and stay open + +`run_selftest.py` wants playwright and a chromium that will not start here (no +libnspr4/libnss3), which is why nothing had actually opened either page in a browser +for a long while. WebKitGTK is present, because it is what Orca renders with, and +PyGObject can drive it - so these checks run against the same engine that will run the +page for real, which is a better witness than chromium would have been anyway. + +What this catches that a source-text check cannot: whether focus really selects the +field, whether a committed value survives the next state push, whether a number input +accepts `select()` at all. Every one of those is engine behaviour. + +Without `--watch` this is a test: it checks, reports and exits. With `--watch` it is a +window - the checks still run and report, then it stays up until you close it, driving a +live printer if `--real` is on. + +`--real` swaps the simulator for a real printer: `window.wx` is installed as a user +script and answered by `docs/u1-webui/tools/u1_bridge.py`, which speaks MQTT to the +machine. **Orca must be closed** - it authenticates with the same saved clientId and a +broker evicts the older holder. It also does not test Orca's own bridge; it is a second +host speaking the same contract. See the note at the top of u1_bridge.py. + +Needs a display (WSLg provides one) and the GTK3/WebKit2 typelibs. Screenshots come +from GdkPixbuf rather than WebKit's own snapshot API, which hands back a cairo surface +there is no pycairo here to receive. +""" +import argparse +import functools +import http.server +import json +import os +import signal +import socketserver +import sys +import threading +import time + +# WebKit's accelerated compositor is what makes a screenshot blank here: EGL finds no +# driver under WSL, so the window it composites into stays empty and every PNG comes out +# byte-identical whatever changed. Rendering on the CPU instead costs nothing on a page +# this size and is what makes --shots evidence rather than decoration. Set before Gtk is +# imported, because WebKit reads it once. +os.environ.setdefault("WEBKIT_DISABLE_COMPOSITING_MODE", "1") + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("WebKit2", "4.1") +from gi.repository import Gtk, WebKit2, GLib, Gdk # noqa: E402 + +HERE = os.path.dirname(os.path.abspath(__file__)) +WEB = os.path.dirname(os.path.dirname(HERE)) +# Served one level up, at resources/, because the shipped bundle's index.html carries +# and resolves every asset against it. +SERVE = os.path.dirname(WEB) +ROOT = os.path.dirname(os.path.dirname(WEB)) +TOOLS = os.path.join(ROOT, "docs", "u1-webui", "tools") +HARNESS = os.path.join(TOOLS, "harness") + +# The page reaches a printer through `window.wx`. Orca installs that; outside Orca it +# has to be installed here, and answered by something that can talk to the machine. +DRIVE_LIMIT = 300 # a --drive script waits on a machine, so give it room + +WX_SHIM = """ +window.wx = { postMessage: function (s) { + window.webkit.messageHandlers.wx.postMessage(String(s)); +} }; +""" + +# Driven in the page, against the simulator's seeded values. Each line of the report is +# "PASS name" or "FAIL name got X want Y", which is what the Python side prints. +CHECKS = r""" +(function () { + const out = []; + const say = (name, got, want) => + out.push(`${got === want ? 'PASS' : 'FAIL'} ${name}` + + (got === want ? '' : ` got ${JSON.stringify(got)} want ${JSON.stringify(want)}`)); + // ---- is what the page hides actually hidden? ------------------------- + // [hidden] is a UA-stylesheet rule and loses to ANY class or id rule that sets + // `display`. Three elements have now been bitten by that - .fault, .stor-foot, and + // #view-storage, which went on showing the Storage panel's header bar on the Device + // control page. Two of them were pinned by name in a conformance check, which is what + // let the third through. Asking the running page covers every case there will be. + { + const showing = [...document.querySelectorAll('[hidden]')] + .filter((n) => getComputedStyle(n).display !== 'none') + .map((n) => (n.id ? '#' + n.id : '.' + n.className)); + say('everything the page hides is actually hidden', showing.join(', '), ''); + } + + // ---- keyedList, against a real DOM ----------------------------------- + // Reported from the running page: the Filament panel showed ten slots where there are + // four, cycling 3-4-1-2. A node whose signature changed was dropped from the leftovers + // map AND replaced, 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. This is DOM reconciliation + // and a stub cannot show it, so it is tested here on the real thing. + { + const kl = window.__devicePage.render.keyedList; + const box = document.createElement('div'); + document.body.appendChild(box); + const items = [{id: 'a', v: 1}, {id: 'b', v: 1}, {id: 'c', v: 1}]; + const paint = () => kl(box, items, { + key: (it) => it.id, + sig: (it) => String(it.v), + create: (it) => Object.assign(document.createElement('span'), + {textContent: it.id + it.v}), + }); + const shown = () => [...box.children].map((n) => n.textContent).join(' '); + paint(); paint(); + say('a repaint with nothing changed touches nothing', shown(), 'a1 b1 c1'); + items[0].v = 2; paint(); + say('a changed item is replaced, not added alongside', shown(), 'a2 b1 c1'); + items.forEach((it) => { it.v = 3; }); paint(); + say('and the whole list changing does not multiply it', shown(), 'a3 b3 c3'); + items.splice(1, 0, {id: 'z', v: 1}); paint(); + say('an item inserted in the middle lands in the middle', shown(), 'a3 z1 b3 c3'); + items.shift(); items.pop(); paint(); + say('and removals still leave', shown(), 'z1 b3'); + box.remove(); + } + + const rows = [...document.querySelectorAll('.status-row')]; + const key = (n, k) => n.dispatchEvent(new KeyboardEvent('keydown', {key: k, bubbles: true})); + if (rows.length !== 5) return `FAIL five reading rows got ${rows.length}`; + + // ---- the target field ------------------------------------------------ + // Toolhead 1 is seeded at 220 and the bed at 60 by mockhost.js. + const t = rows[0].querySelector('.tgt'); + say('the machine target is in the field', t.value, '220'); + say('a heater that is off shows nothing, so the dash placeholder is what is seen', + rows[2].querySelector('.tgt').value, ''); + + t.focus(); + document.execCommand('insertText', false, '9'); + say('focus selects, so a keystroke replaces rather than appends', t.value, '9'); + key(t, 'Escape'); + say('Escape puts the machine value back', t.value, '220'); + + t.focus(); t.value = ''; t.blur(); + say('an empty field is not an instruction to switch the heater off', t.value, '220'); + say('and nothing was sent for it', String(rows[0].dataset.pend), 'undefined'); + + t.focus(); t.value = '205'; key(t, 'Enter'); + say('Enter sends the value and holds it on screen', t.value, '205'); + say('and the row waits for the printer to confirm', rows[0].dataset.pend, '1'); + + const b = rows[4].querySelector('.tgt'); + b.focus(); b.value = '0'; key(b, 'Enter'); + say('zero is how off is asked for, and it is explicit', rows[4].dataset.pendVal, '0'); + + const n = rows[1].querySelector('.tgt'); + n.focus(); n.value = '400'; n.blur(); + say('a value past the machine limit reverts', n.value, '215'); + + // ---- the column still holds its widest reading ----------------------- + // 350 nozzle over a 350 reading is the worst case the row can be asked to show. + const r0 = rows[0]; + r0.querySelector('.cur').textContent = '350'; + r0.querySelector('.tgt').value = '350'; + say('the widest reading does not overflow the 126px column', + r0.scrollWidth <= r0.clientWidth, true); + say('and the target is not clipped inside its own field', + r0.querySelector('.tgt').scrollWidth <= 35, true); + + // ---- the row must not move as the numbers do ------------------------- + // A reading going 99 -> 100 used to carry the slash, the field and the unit along + // with it, and five rows at different temperatures never lined up with each other. + const widths = ['9', '26', '100', '350', '_']; + rows.forEach((r, i) => { r.querySelector('.cur').textContent = widths[i]; }); + const left = (r, sel) => Math.round(r.querySelector(sel).getBoundingClientRect().left); + for (const sel of ['.sl', '.tgt', '.unit']) { + const seen = [...new Set(rows.map(r => left(r, sel)))]; + say(`${sel} sits at the same place on every row, whatever the reading`, + seen.length, 1); + } + say('and no row overflows at three digits', + rows.every(r => r.scrollWidth <= r.clientWidth), true); + + // ---- and the block sits over the squares below it -------------------- + const card = document.querySelector('#status-card').getBoundingClientRect(); + const r = rows[0]; + const mid = (r.querySelector('img').getBoundingClientRect().left + + r.querySelector('.unit').getBoundingClientRect().right) / 2; + say('the readings are centred in their column, like the tiles under them', + Math.abs(mid - (card.left + card.width / 2)) <= 1, true); + + // ---- the two columns, and which panel grows in each ------------------- + // The four panels were one 2x2 grid of equal cells locked to 830/548. They are two + // unequal columns now, and the whole point is that each distributes height on its own: + // Control shrinks to its cluster and Filament takes what it gives up. Geometry is the + // only honest witness to that, and it is why --size exists - below the 1600 breakpoint + // this is a single centred column and none of it applies. + const box = (sel) => { + const e = document.querySelector(sel); + if (!e) return null; + const r = e.getBoundingClientRect(); + return { w: Math.round(r.width), h: Math.round(r.height), + x: Math.round(r.left), y: Math.round(r.top) }; + }; + const wide = window.innerWidth >= 1600; + say('two columns are built whatever the width', + document.querySelectorAll('.view-col').length, 2); + say('the camera and the job card share one column', + document.querySelectorAll('.col-main > .panel').length, 2); + say('control and filament share the other', + document.querySelectorAll('.col-side > .panel').length, 2); + say('one panel per column takes the leftover height', + document.querySelectorAll('.view-col > .panel.grows').length, 2); + if (wide) { + const main = box('.col-main'), side = box('.col-side'); + const cam = box('.col-main > .panel'), ctl = box('.col-side > .panel'); + const fil = box('.col-side > .panel.grows'), task = box('.col-main > .panel:last-child'); + say('the side column is --col-w', side.w, 830); + say('the columns start level', main.y, side.y); + say('and end level', Math.abs((main.y + main.h) - (side.y + side.h)) <= 2, true); + // 588 is what the old 830/548 ratio forced on the Control panel. Anything near it + // means the ratio came back, which is the regression this layout exists to undo. + say('control sizes to its cluster, not to a ratio', ctl.h < 460, true); + say('and its cluster is untouched at 758', box('.control-grid').w, 758); + say('the job card sizes to itself rather than to a number', + Math.abs(task.h - 40 - document.querySelector('#task').scrollHeight) <= 1, true); + say('the camera takes more than 16:9 of its column', cam.h > cam.w * 9 / 16, true); + say('filament got the height control released', fil.h > 324, true); + say('the destination fits the window', + document.documentElement.scrollHeight <= window.innerHeight + 1, true); + } else { + const cols = [...document.querySelectorAll('.view-col')] + .map((e) => Math.round(e.getBoundingClientRect().width)); + say('below the breakpoint both columns are the measured single width', + cols[0] === cols[1] && cols[0] === Math.round(0.6 * (window.innerWidth - 262) + 219), + true); + const cam = box('.col-main > .panel'); + say('and the camera keeps 16:9 there, since no column can give it height', + Math.abs(cam.h - 40 - cam.w * 9 / 16) <= 2, true); + } + // `.panel-body` hides its overflow, so a body shorter than its card clips it in + // silence - which is exactly what a pinned 150px did to the Printing Task panel: the + // progress bar and both job buttons were cut off and nothing said so. Asked of every + // body at every width, because a body can be too short in either layout. + document.querySelectorAll('#view-control .panel-body').forEach((el) => { + if (getComputedStyle(el).overflowY === 'auto') return; // scrolling is not clipping + say(`#${el.id} shows all of its content`, + el.scrollHeight <= el.clientHeight + 1, true); + }); + + // ---- the multiACE filament card --------------------------------------- + // Every one of these is a difference between two numbers, because that is the only + // thing that catches them: an inherited `align-items: flex-start` left every card in + // the study silently left-aligned, a `flex: 0 0 64px` collapsed the 140px toolhead to + // 64, and neither was visible in a picture. Screenshots here are real again, and still + // would not have shown either. + { + const cards = [...document.querySelectorAll('#filament .ace-card')]; + say('the ACE panel draws one card per toolhead', cards.length, 4); + const mid = (e) => { const r = e.getBoundingClientRect(); return r.left + r.width / 2; }; + const round = (v) => Math.round(v * 10) / 10; + + // The machine reports head_ace {0:0, 1:1, 2:2, 3:0} with ONE unit attached, so heads + // 2 and 3 name units that are not there. Trusting it draws three cabinets that do + // not exist; head_manual and head_feeder have to be read first. + say('a head`s source resolves manual, then feeder, then ace', + cards.map((c) => c.querySelector('.ace-src').value).join(','), + 'feeder,feeder,feeder,ace:0'); + say('and head_ace naming a unit that is not attached draws no cabinet', + document.querySelectorAll('#filament .ace-cab').length, 1); + // One word for one thing: the card's own strip has always said `Hand-fed`, and the + // list saying `Manual` for the same source was two vocabularies for one control. + say('the source is a list that can name WHICH ace, not an icon triple', + [...cards[0].querySelectorAll('.ace-src option')].map((o) => o.textContent).join(' · '), + 'Default feeder · ACE A · Hand-fed'); + + // One centred axis: the bays, the merge and the inlet share the card's centre line, + // so the tube that matters is vertical. + const off = cards.map((c) => round(Math.abs(mid(c.querySelector('.ace-tool')) + - mid(c.querySelector('.ace-box'))))); + say('the box and the toolhead sit on one axis on every card', + off.filter((d) => d > 1).length, 0); + + // A bay is drawn as a head, and a stock feeder wears whatever a bay wears - or the + // four cards' spools stop sitting on the same two lines. + const at = (c, sel) => { + const b = c.querySelector('.ace-box').getBoundingClientRect(); + const e = c.querySelector(sel); + return e ? Math.round(e.getBoundingClientRect().top - b.top) : null; + }; + say('a feeder spool sits at an ACE bay`s exact height', + [...new Set(cards.map((c) => at(c, '.ace-disc')))].length + + ':' + [...new Set(cards.map((c) => at(c, '.ace-chip')))].length, '1:1'); + + // The seam runs THROUGH the roll as a hard stop, so half of every spool is in each + // half. Disc-relative, which is why widening the gap under the roll moves the chip + // and not the seam. + const cab = document.querySelector('#filament .ace-cab-top'); + const disc = cab.querySelector('.ace-disc').getBoundingClientRect(); + // Read off what is PAINTED, not off the custom property: an unregistered custom + // property computes to its own token string - `calc(5px + 36px / 2)` - so parsing it + // would be checking the arithmetic against itself. The computed background-image has + // the stop resolved to a real length. + const stop = parseFloat((getComputedStyle(cab).backgroundImage.match(/[\d.]+px/g) || []) + .map(parseFloat).find((v) => v > 0)); + say('the seam falls on the roll`s own centre line', + round(cab.getBoundingClientRect().top + stop - (disc.top + disc.height / 2)), 0); + // 16px of shoulder either side: the box hugs its four spools rather than filling the + // card, which is what makes it an object sitting IN the card rather than the card's + // own ground. + say('and the cabinet hugs its four spools', + Math.round(cab.getBoundingClientRect().width + - cab.querySelector('.ace-bays').getBoundingClientRect().width), 32); + + // The sensor dot is centred on the artwork's BODY, which extruderBackground.svg + // draws y=17.4..127.6 of its 64x140 - so the middle is (32, 72.5) at any scale. + const tool = cards[0].querySelector('.ace-tool'); + const s = parseFloat(getComputedStyle(tool).getPropertyValue('--s')); + const tr = tool.getBoundingClientRect(); + const dot = cards[0].querySelector('.ace-sensor').getBoundingClientRect(); + say('the sensor dot is centred on the artwork`s body', + round(dot.left + dot.width / 2 - (tr.left + 32 * s)) + ',' + + round(dot.top + dot.height / 2 - (tr.top + 72.5 * s)), '0,0'); + + // The tube is checked by arithmetic and not by looking: each coloured path is asked + // for its own endpoints and compared with the inlet it claims to enter. + let bad = 0, drawn = 0; + cards.forEach((c) => { + const p = [...c.querySelectorAll('.ace-wire path')].pop(); + if (!p) return; + drawn += 1; + const m = p.getScreenCTM(); + const at2 = (l) => { const q = p.getPointAtLength(l); + return { x: q.x * m.a + q.y * m.c + m.e, + y: q.x * m.b + q.y * m.d + m.f }; }; + const t = c.querySelector('.ace-tool').getBoundingClientRect(); + const e = at2(p.getTotalLength()); + if (Math.abs(e.x - (t.left + 32 * s)) > 1.5 || Math.abs(e.y - t.top) > 1.5 + || Math.abs(at2(0).x - e.x) > 1.5) bad += 1; + }); + say('every tube is vertical and lands on the inlet', `${drawn - bad}/${drawn}`, '4/4'); + + // `.panel-body` hides its overflow, so a card that outgrows its cell is clipped in + // silence - which is what the whole 456px budget is about. + say('nothing inside a card overflows its cell', + [...document.querySelectorAll('#filament .ace-card,#filament .ace-head,' + + '#filament .ace-hrow,#filament .ace-strip')] + .filter((e) => e.scrollWidth > e.clientWidth + 1).length, 0); + // At rest a bay wears nothing at all: anything permanent round it either hides the + // seam that runs through the roll or fights it. + const rest = getComputedStyle(document.querySelector('#filament .ace-bay'), '::before'); + say('at rest a bay wears nothing at all', + rest.boxShadow + '|' + rest.backgroundColor, 'none|rgba(0, 0, 0, 0)'); + // 215 is the whole budget: two rows plus an 8px gap plus 17 of padding is 455 in a + // body that measures 456 at 1920x1080. A header that grows a line costs 20 of that, + // and `.panel-body` is `overflow: hidden` - so it would be clipped in silence at some + // other window size rather than here. + say('a card is the height that lets two rows fit the body', + Math.round(cards[0].getBoundingClientRect().height), 215); + // The ACE had been drawn four ways across the app and this panel made it five, with + // a badge of its own shape. These hold it to docs/ace-mmu/16-ace-visuals.md - the + // same standard the C++ Prepare page draws from - by asking for the nominal + // proportions rather than the rendered size, since the unit row is 17 px and the + // standard's own mechanism for that is a zoom. + const badge = document.querySelector('#filament .ace-card.is-ace .ace-badge:not(.ace-modbadge)'); + say('the ACE badge is the standard`s 44x26, whatever it is drawn at', + badge.getAttribute('viewBox'), '0 0 44 26'); + say('and keeps that ratio on screen', + Math.abs(badge.getBoundingClientRect().width + / badge.getBoundingClientRect().height - 44 / 26) < 0.02, true); + // G, not A: one body the full width with a colour stop through it, and the spools + // drawn LAST so none is cropped. The draw order is the point, so it is what is asked. + const kids = [...badge.children].map(e => e.tagName.toLowerCase()); + say('the badge is one body the full width, not a hood over a wider base', + [badge.querySelector('rect').getAttribute('x'), + badge.querySelector('rect').getAttribute('width')].join(','), '0,44'); + say('split by a colour stop rather than a second box', + badge.querySelector('path').getAttribute('d').startsWith('M0 16'), true); + say('and the spools are drawn over both, so none is cropped', + kids.indexOf('path') < kids.lastIndexOf('rect'), true); + say('one bay per slot, colour only, at the standard`s 6x16', + [...badge.querySelectorAll('rect')].slice(1) + .map(e => e.getAttribute('width') + 'x' + e.getAttribute('height')).join(','), + '6x16,6x16,6x16,6x16'); + say('with 4 px of margin all round, which is the gap`s own number', + [...badge.querySelectorAll('rect')].slice(1) + .map(e => Number(e.getAttribute('x'))).join(','), '4,14,24,34'); + say('the ACE mode pill reports the mode rather than the one it was built with', + document.getElementById('filament-mode').textContent.trim(), 'ACE mode · Head'); + } + + say('the page never scrolls sideways', + document.documentElement.scrollWidth <= window.innerWidth + 1, true); + + return out.join('\n'); +})() +""" + + +# With --real there are no seeded values to assert against, so these check the things +# that are true of any printer: that a host answered at all, that state arrived, and +# that what the rows show is what the machine reported. +REAL_CHECKS = r""" +(function () { + const out = []; + const say = (name, got, want) => + out.push(`${got === want ? 'PASS' : 'FAIL'} ${name}` + + (got === want ? '' : ` got ${JSON.stringify(got)} want ${JSON.stringify(want)}`)); + const dp = window.__devicePage; + if (!dp) return 'FAIL the page did not finish booting'; + say('a real host answered, so the simulator never installed itself', dp.mock, null); + + const st = dp.state; + const objs = Object.keys(st.objects); + say('machine state arrived', objs.length > 0, true); + out.push(`INFO ${objs.length} objects: ${objs.slice(0, 10).join(', ')}`); + if (!objs.length) return out.join('\n'); + + const heads = st.toolheads(), bed = st.bed(); + heads.forEach((h, i) => + out.push(`INFO toolhead ${i + 1}: ${h.temperature} / ${h.target} \u00B0C ` + + `state=${h.state} power=${h.power}`)); + out.push(`INFO bed: ${bed.temperature} / ${bed.target} \u00B0C`); + + const rows = document.querySelectorAll('.status-row'); + say('five reading rows', rows.length, 5); + say('the row shows the temperature the machine reported', + rows[0].querySelector('.cur').textContent, String(Math.round(heads[0].temperature))); + + const reading = [...heads.map(h => h.target), bed.target]; + const off = reading.findIndex(t => !t); + if (off >= 0) { + say(`a heater that is off shows a dash, not a zero (row ${off + 1})`, + rows[off].querySelector('.tgt').value, ''); + } else { + out.push('INFO every heater has a target, so the dash case was not exercised'); + } + const hot = reading.findIndex((t, i) => + t > 0 && (i < 4 ? heads[i].temperature : bed.temperature) < t - 2); + out.push(hot >= 0 ? `INFO row ${hot + 1} is heating: ${rows[hot].dataset.heat}` + : 'INFO nothing is heating right now'); + return out.join('\n'); +})() +""" + + +def _sigint(handler): + """Ask GLib to deliver Ctrl-C. The call moved namespaces; take whichever is here.""" + try: + gi.require_version("GLibUnix", "2.0") + from gi.repository import GLibUnix # noqa: PLC0415 + GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, handler) + except (ValueError, ImportError): + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, handler) + + +def serve(directory): + class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + class Q(socketserver.TCPServer): + allow_reuse_address = True + + httpd = Q(("127.0.0.1", 0), functools.partial(H, directory=directory)) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd.server_address[1] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--shots", help="directory to write screenshots into") + ap.add_argument("--size", metavar="WxH", default="1500x980", + help="window size; the layout breakpoint is 1600, so pass " + "e.g. --size 1920x1080 to see the two-column layout") + ap.add_argument("--settle", type=float, default=None, + help="seconds to let state arrive before checking " + "(default 3, or 15 with --real: a connect has to happen first)") + ap.add_argument("--original", action="store_true", + help="load the SHIPPED Flutter bundle instead of the " + "reconstruction. Implies --real: the bundle has no simulator " + "of its own, so something has to answer window.wx.") + ap.add_argument("--prime", metavar="PATH", default="", + help="with --original: load this ?path= surface first, then go to " + "the Device tab - the host's cache is shared between surfaces. " + "Off by default: ?path=1 is a login surface and writes nothing, " + "which is worth knowing before spending 45s on it again.") + ap.add_argument("--real", action="store_true", + help="talk to the real printer instead of the simulator " + "(Orca must be closed)") + ap.add_argument("--device-ip", metavar="IP", + help="with --real: pretend the saved printer is at this address. " + "Point it somewhere unroutable (192.0.2.1) to exercise the " + "page with no printer there.") + ap.add_argument("--trace", action="store_true", + help="log each command's parameters in full instead of the first " + "120 characters") + ap.add_argument("--sn", metavar="SN", + help="with --real: use only this saved device. Orca's config can " + "hold stale records, and the shipped bundle tries to connect " + "every one it is given.") + ap.add_argument("--drive", metavar="FILE", + help="run this JavaScript in the page instead of the built-in " + "checks; it reports by setting window.__report when done") + ap.add_argument("--watch", type=float, nargs="?", const=0.0, default=None, + metavar="SECONDS", + help="leave the window open after the checks so it can be used by " + "hand: bare --watch stays until the window is closed, " + "--watch 90 stays that long") + args = ap.parse_args() + if args.original: + args.real = True + if args.settle is None: + args.settle = 25.0 # Flutter boots, then runs its own connect + if args.settle is None: + args.settle = 15.0 if args.real else 3.0 + + if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): + print("no display: this needs one (WSLg provides it). Skipping.") + return 0 + + port = serve(SERVE) + bridge = None + ucm = WebKit2.UserContentManager() + + if args.real: + sys.path.insert(0, TOOLS) + from u1_bridge import Bridge # noqa: PLC0415 + scripts = [WX_SHIM] + if args.original: + # The bundle treats a failed cloud call as "the user is offline" and tears + # its own session down, so the REST API has to be answered before it boots. + # Same stub the screenshot harness uses. + scripts.append(open(os.path.join(HARNESS, "cloud-stub.js"), + encoding="utf-8").read()) + for src in scripts: + ucm.add_script(WebKit2.UserScript.new( + src, WebKit2.UserContentInjectedFrames.TOP_FRAME, + WebKit2.UserScriptInjectionTime.START, None, None)) + ucm.register_script_message_handler("wx") + + # An UNATTENDED run that wants pictures gets an offscreen window, because that is + # the one thing here that can be read back: `Gtk.OffscreenWindow.get_pixbuf()` renders + # the widget tree through cairo, while `Gdk.pixbuf_get_from_window` reads an X window + # that WebKit never drew into. A watched run keeps the real window - being able to + # click the page is the whole point of --watch - and takes the blank PNGs with it. + offscreen = bool(args.shots) and args.watch is None + win = Gtk.OffscreenWindow() if offscreen else Gtk.Window() + # The Device page is responsive and its breakpoint is now load-bearing - the two + # column layout only engages above it - so a suite that can only ever look at one + # width can only ever check one of the two layouts. + try: + _w, _h = (int(n) for n in args.size.lower().split("x")) + except Exception: + raise SystemExit(f"--size wants WxH, got {args.size!r}") + win.set_default_size(_w, _h) + view = WebKit2.WebView.new_with_user_content_manager(ucm) + win.add(view) + win.show_all() + + if args.real: + def to_page(msg, as_string=True): + # Orca posts the WCP envelope as a STRING - send_to_js builds + # `window.postMessage(JSON.stringify({...}), '*')` - and the legacy + # `command` messages as an object. The reconstruction's client parses + # either; the Flutter bundle does not, and silently ignored every reply + # until this matched. + body = json.dumps(json.dumps(msg)) if as_string else json.dumps(msg) + js = f"window.postMessage({body}, '*')" + # The bridge answers on its own threads; WebKit is main-loop only. + GLib.idle_add(lambda: bool(view.evaluate_javascript( + js, -1, None, None, None, None, None) and False)) + + devices = None + if args.device_ip or args.sn: + from u1_bridge import orca_devices # noqa: PLC0415 + devices = orca_devices() + if args.sn: + devices = [d for d in devices if d.get("sn") == args.sn] + print(f" using only {args.sn} ({len(devices)} saved record(s) match)") + if args.device_ip: + devices = [dict(d, ip=args.device_ip) for d in devices] + print(f" pretending the printer is at {args.device_ip}") + bridge = Bridge(send=to_page, devices=devices, + trace=100000 if args.trace else 120) + if args.original: + # Orca reaches the Device tab with a printer host already attached. The + # bundle never brings one up itself - it makes its own MQTT clients for + # its own purposes and expects sw_GetMachineState to just work. + threading.Thread(target=bridge.self_connect, daemon=True).start() + + seen = [] + inner = bridge.handle + + def counting(text): + try: + seen.append(json.loads(text)["payload"]["cmd"]) + except Exception: # noqa: BLE001 + pass + return inner(text) + bridge.handle = counting + + def on_wx(_ucm, result): + # 4.1 hands over a JSCValue; 4.0 wrapped it in a JavascriptResult. + val = result if hasattr(result, "to_string") else result.get_js_value() + bridge.handle(val.to_string()) + ucm.connect("script-message-received::wx", on_wx) + + bundle = f"http://127.0.0.1:{port}/web/flutter_web/index.html?path=" + if args.original: + url = bundle + (args.prime or "2") + else: + url = f"http://127.0.0.1:{port}/web/device_page/index.html" + if not args.real: + url += "?mock=1" + view.load_uri(url) + print(f"serving {SERVE} on {url}" + + (" [real printer]" if args.real else " [simulator]") + + (" [shipped bundle]" if args.original else "")) + + state = {"rc": 1, "report": "the page never finished loading", "printed": False} + + def report(): + """Print now rather than after the loop: when the window stays open, a report + that waits for the loop to end is a report the user cannot read yet.""" + if state["printed"]: + return + state["printed"] = True + lines = state["report"].split("\n") + for line in lines: + print(" " + line, flush=True) + bad = sum(1 for line in lines if line.startswith("FAIL")) + ran = sum(1 for line in lines if line.startswith(("PASS", "FAIL"))) + print(f"\n{ran - bad}/{ran} checks passed", flush=True) + + def wrap_up(): + """The checks are done. Either that was the whole job, or the window is the job.""" + shot("checked") + report() + if args.watch is None: + Gtk.main_quit() + return False + if args.watch: + print(f"\n window open for {args.watch:.0f}s - close it to finish sooner", + flush=True) + GLib.timeout_add(int(args.watch * 1000), + lambda: (shot("watched"), Gtk.main_quit(), False)[2]) + else: + print("\n window open - close it, or Ctrl-C here, to finish", flush=True) + return False + + def shot(name): + if not args.shots: + return + os.makedirs(args.shots, exist_ok=True) + if offscreen: + pb = win.get_pixbuf() + else: + gw = win.get_window() + pb = Gdk.pixbuf_get_from_window(gw, 0, 0, gw.get_width(), gw.get_height()) + if pb is None: + print(f" (no pixbuf for {name})") + return + pb.savev(os.path.join(args.shots, f"{name}.png"), "png", [], []) + print(f" wrote {name}.png") + + def done(v, res): + try: + state["report"] = v.evaluate_javascript_finish(res).to_string() + state["rc"] = 1 if "FAIL" in state["report"] else 0 + except Exception as e: # noqa: BLE001 + state["report"] = f"FAIL the checks did not run: {e}" + wrap_up() + + def go(): + shot("loaded") + if args.drive: + # A driving script is asynchronous by nature - it waits on the machine - + # so it hands its report back through window.__report rather than by + # returning, and this polls for it. + view.evaluate_javascript(open(args.drive, encoding="utf-8").read(), + -1, None, None, None, None, None) + deadline = time.time() + DRIVE_LIMIT + def poll(): + if time.time() > deadline: + state["report"] = "FAIL the driving script never reported" + return wrap_up() + view.evaluate_javascript("window.__report || ''", -1, None, None, None, + picked) + return False + def picked(v, res): + try: + got = v.evaluate_javascript_finish(res).to_string() + except Exception: # noqa: BLE001 + got = "" + if got: + state["report"] = got + state["rc"] = 1 if "FAIL" in got else 0 + wrap_up() + else: + GLib.timeout_add(500, poll) + poll() + return False + if args.original: + # A Flutter canvas has no DOM worth querying, so the evidence is what the + # page asked the host for and whether the session came up. + cmds = [] + for c in seen: + if c not in cmds: + cmds.append(c) + lines = [f"INFO the bundle issued {len(seen)} commands, " + f"{len(cmds)} distinct"] + lines.append(f"INFO {', '.join(cmds)}" if cmds + else "FAIL the bundle never reached the host") + lines.append(("PASS" if bridge.engine else "FAIL") + + " the bundle brought an MQTT session up") + lines.append(("PASS" if bridge.sn else "FAIL") + + f" and attached an engine for {bridge.sn}") + state["report"] = "\n".join(lines) + state["rc"] = 1 if "FAIL" in state["report"] else 0 + return wrap_up() + view.evaluate_javascript(REAL_CHECKS if args.real else CHECKS, + -1, None, None, None, done) + return False + + phase = {"primed": not (args.original and args.prime)} + + def loaded(v, e): + if e != WebKit2.LoadEvent.FINISHED: + return + if not phase["primed"]: + # Let the priming surface run long enough to write what it writes, then + # hand the same host - and the same cache - to the Device tab. + def then(): + phase["primed"] = True + held = sorted(bridge.cache) if bridge else [] + print(f" primed on ?path={args.prime}; cache holds {held or 'nothing'}") + view.load_uri(bundle + "2") + return False + GLib.timeout_add(int(args.settle * 1000), then) + return + GLib.timeout_add(int(args.settle * 1000), go) + + view.connect("load-changed", loaded) + # Closing the window is how a person ends this, and Ctrl-C is how they end it from + # the terminal - GLib swallows SIGINT otherwise, so it has to be asked for. + win.connect("destroy", lambda *_: Gtk.main_quit()) + _sigint(lambda *_: (Gtk.main_quit(), False)[1]) + if args.watch is None: + # An unattended run must not hang if the page never answers. + GLib.timeout_add(int((args.settle + + (DRIVE_LIMIT + 15 if args.drive else 30)) * 1000), + Gtk.main_quit) + Gtk.main() + if bridge: + bridge.stop() + report() + return state["rc"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/web/shared/tests/selftest.html b/resources/web/shared/tests/selftest.html new file mode 100644 index 00000000000..7d0da2ebae7 --- /dev/null +++ b/resources/web/shared/tests/selftest.html @@ -0,0 +1,152 @@ + + + + +Device page — self test + + + +

Snapmaker U1 Device page — self test

+

Exercises the real modules against the simulated printer: envelope +shape, subscription ack-then-push, partial-state merging, control round-trips, error decoding.

+
+
+ + + + diff --git a/resources/web/shared/tests/unit_jsc.py b/resources/web/shared/tests/unit_jsc.py new file mode 100644 index 00000000000..d605bf2b89e --- /dev/null +++ b/resources/web/shared/tests/unit_jsc.py @@ -0,0 +1,1035 @@ +#!/usr/bin/env python3 +"""Run the pure logic against real captured payloads, in a real JS engine. + +There is no node and no playwright here, and the vendored chromium will not start for +want of libnspr4/libnss3 - so the 28 browser checks cannot run. JavaScriptCore *is* +present, because WebKitGTK is what Orca's own webview uses, and PyGObject can drive it. +That is enough to execute the functions that decide whether a real printer's payload +renders: the colour normaliser and the JSON-RPC unwrapper. + +The inputs are the captured values in docs/u1-webui/data/hardware-shapes.json, not +invented ones. This is the check that would have caught the whole class of bug: the +simulator agreed with the client, and neither agreed with the printer. + + python3 resources/web/shared/tests/unit_jsc.py +""" +import json +import os +import re +import sys + +import gi +gi.require_version("JavaScriptCore", "4.1") +from gi.repository import JavaScriptCore as JSC # noqa: E402 + +HERE = os.path.dirname(os.path.abspath(__file__)) +SHARED = os.path.dirname(HERE) +WEB = os.path.dirname(SHARED) +ROOT = os.path.dirname(os.path.dirname(WEB)) +DATA = os.path.join(ROOT, "docs", "u1-webui", "data") + +fails, checks = [], 0 + + +def check(name, cond, detail=""): + global checks + checks += 1 + if cond: + print(f" PASS {name}") + else: + print(f" FAIL {name}" + (f"\n {detail}" if detail else "")) + fails.append(name) + + +def module_source(path): + """Strip ES module syntax so the body can run in a bare JSC context.""" + src = open(path, encoding="utf-8").read() + src = re.sub(r"^\s*import\s.*?;\s*$", "", src, flags=re.M | re.S) + src = re.sub(r"^\s*export\s+(?=(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class))", + "", src, flags=re.M) + src = re.sub(r"^\s*export\s*\{[^}]*\}\s*;?\s*$", "", src, flags=re.M) + return src + + +def new_ctx(*paths): + ctx = JSC.Context.new() + for p in paths: + ctx.evaluate(module_source(p), -1) + exc = ctx.get_exception() + if exc: + raise RuntimeError(f"{os.path.basename(p)}: {exc.get_message()}") + return ctx + + +def js(ctx, expr): + v = ctx.evaluate(expr, -1) + exc = ctx.get_exception() + if exc: + ctx.clear_exception() + raise RuntimeError(f"{expr[:60]}: {exc.get_message()}") + return v + + +def main(): + hw = json.load(open(os.path.join(DATA, "hardware-shapes.json"), encoding="utf-8")) + + # --- every shared module must at least parse ------------------------- + print("== modules parse as ES modules ==") + # check_syntax on the UNMODIFIED source, in module mode: this validates the real + # file including its import/export, and does not need a DOM. Executing these would + # only prove that `document` is missing in a headless engine. + mods = [] + for d in ("shared", "device_page", "print_processing"): + jsdir = os.path.join(WEB, d, "js") + if not os.path.isdir(jsdir): + continue + # Walk, not listdir: the Device page's modules live in core/, widgets/ and + # views/// now, and a flat listing quietly stopped checking + # all but two of them. + for root, _dirs, fs in os.walk(jsdir): + rel = os.path.relpath(root, jsdir) + pre = f"{d}/js" if rel == "." else f"{d}/js/{rel}" + mods += [(f"{pre}/{n}", os.path.join(root, n)) + for n in sorted(fs) if n.endswith(".js")] + for label, path in mods: + raw = open(path, encoding="utf-8").read() + res, exc = JSC.Context.new().check_syntax( + raw, -1, JSC.CheckSyntaxMode.MODULE, label, 1) + check(label, res == JSC.CheckSyntaxResult.SUCCESS, + exc.get_message() if exc else str(res)) + + ctx = new_ctx(os.path.join(SHARED, "js", "protocol.js")) + + # --- colours, against the captured values ---------------------------- + print("\n== filament colour, from the real wire values ==") + argb = [4294198070, 4294967260, 4294967295, 4284689452] + rgba = ["F44336FF", "FFFFDCFF", "FFFFFFFF", "632C2CFF"] + for i, (n, h) in enumerate(zip(argb, rgba)): + want = "#" + h[:6].upper() + got_i = js(ctx, f"cssColor({n})").to_string() + got_h = js(ctx, f"cssColor('{h}')").to_string() + check(f"slot {i + 1}: ARGB {n} -> {want}", got_i == want, f"got {got_i}") + check(f"slot {i + 1}: rgba '{h}' -> {want}", got_h == want, f"got {got_h}") + + check("the two independent wire fields agree for every slot", + all(js(ctx, f"cssColor({n})").to_string() + == js(ctx, f"cssColor('{h}')").to_string() for n, h in zip(argb, rgba))) + + check("a '#'-prefixed value still works (the simulator's old form)", + js(ctx, "cssColor('#E03131FF')").to_string() == "#E03131") + check("junk yields null rather than an invalid CSS colour", + js(ctx, "cssColor('NONE') === null").to_boolean() + and js(ctx, "cssColor(null) === null").to_boolean()) + check("dark filament gets light text", + js(ctx, "isDarkColor(4284689452)").to_boolean() # 632C2C + and not js(ctx, "isDarkColor('FFFFDCFF')").to_boolean()) # near-white + + # --- the envelope ---------------------------------------------------- + print("\n== JSON-RPC envelope ==") + ctx2 = new_ctx(os.path.join(SHARED, "js", "sswcp.js")) + tl = {"jsonrpc": "2.0", "result": {"count": 2, "instances": [{"a": 1}, {"a": 2}]}, + "id": 7, "cli_time": 1, "dev_time": -1} + check("a printer reply is unwrapped to its result", + js(ctx2, f"JSON.stringify(unwrapRpc({json.dumps(tl)}).instances)").to_string() + == '[{"a":1},{"a":2}]') + check("an Orca-answered payload is left alone", + js(ctx2, "JSON.stringify(unwrapRpc({dev_name:'U1',sn:'X'}))").to_string() + == '{"dev_name":"U1","sn":"X"}') + check("a bare array (sw_GetLocalDevices) is left alone", + js(ctx2, "JSON.stringify(unwrapRpc([{sn:'A'}]))").to_string() == '[{"sn":"A"}]') + check("an error envelope is passed through, not silently emptied", + js(ctx2, "JSON.stringify(unwrapRpc(" + "{jsonrpc:'2.0',error:{code:-32000,message:'x'},id:1}))").to_string() + == '{"jsonrpc":"2.0","error":{"code":-32000,"message":"x"},"id":1}') + # Inverted: this asserted that a {data, method} push is left alone, which encoded + # the belief that Orca passes payloads through untouched. It reshapes them - the + # push and the command reply have the SAME shape, and both unwrap here. + check("a status push is unwrapped to the objects it carries", + js(ctx2, "JSON.stringify(unwrapRpc({data:{extruder:{temperature:27}}," + "method:'notify_status_update'}))").to_string() + == '{"extruder":{"temperature":27}}', + "unwrapStatus then passes it through unchanged, having nothing left to strip") + check("and Klipper's array form survives it", + js(ctx2, "JSON.stringify(unwrapRpc({data:[{extruder:{temperature:27}},1.5]," + "method:'notify_status_update'}))").to_string() + == '[{"extruder":{"temperature":27}},1.5]', + "notify_status_update sends [ {objects}, eventtime ]; unwrapStatus takes [0]") + + # A timeout is not a refusal, and there are two clocks that produce one. + check("the client's own timeout is recognised", + js(ctx2, "isTimeout(new Error('sw_SendGCodes timed out after 15000ms'))") + .to_boolean()) + check("and Orca's, which is worded differently", + js(ctx2, "isTimeout({code: -2, message: 'time out'})").to_boolean(), + "handle_general_fail(-2, 'time out') - note the space") + check("Orca's code alone is enough, whatever the message says", + js(ctx2, "isTimeout({code: -2, message: ''})").to_boolean()) + check("a real refusal is still a refusal", + not js(ctx2, "isTimeout(new SswcpError(-1, 'unknown command', 'sw_X'))") + .to_boolean() + and not js(ctx2, "isTimeout(null)").to_boolean(), + "this is the check that decides whether the user is told to retry") + + # The shape Orca actually delivers, which is NOT the raw envelope: only a + # `passthrough` response target gets that. + check("Orca's reshaped reply is unwrapped to the result", + js(ctx2, "JSON.stringify(unwrapRpc({method:'', data:{count:5,jobs:[1,2]}}))") + .to_string() == '{"count":5,"jobs":[1,2]}', + "on_response_arrived builds {data: result, method}; reading r.jobs off that " + "returns undefined, which is how the timelapse list was silently empty") + check("an error reply is left whole, so the caller is owed the detail", + js(ctx2, "JSON.stringify(unwrapRpc({method:'', error:{code:-32601}}))") + .to_string() == '{"method":"","error":{"code":-32601}}') + check("a payload that merely has a data key is left alone", + js(ctx2, "JSON.stringify(unwrapRpc({data:'x', other:1}))").to_string() + == '{"data":"x","other":1}', + "the pair is the signature, not the data key on its own") + + # the shape the file browser actually receives + fl = {"jsonrpc": "2.0", "id": 3, + "result": {"files": [{"filename": "a.gcode"}], "dirs": [], + "disk_usage": {"free": 1}, "root_info": {"name": "gcodes"}}} + check("the file listing survives unwrapping as an array", + js(ctx2, f"(function(){{var d=unwrapRpc({json.dumps(fl)});" + f"return Array.isArray(d.files)&&d.files.length===1;}})()").to_boolean(), + "this is the read that returned [] on hardware") + + # --- state model against a captured object map ------------------------ + print("\n== state model, on captured objects ==") + ctx3 = new_ctx(os.path.join(SHARED, "js", "protocol.js"), + os.path.join(SHARED, "js", "state.js")) + objs = { + "extruder": {"temperature": 27.0, "state": "PARKED"}, + "extruder1": {"temperature": 26.0, "state": "PARKED"}, + "extruder2": {"temperature": 26.0, "state": "PARKED"}, + "extruder3": {"temperature": 26.0, "state": "ACTIVATE"}, + "motion_report": {"live_position": [105.5844, 108.2527, 10.0, 0.0]}, + "purifier": {"mode": 0, "exhaust_fan": {"speed": 0.0, "delay": 180}, + "inner_fan": {"speed": 0.0, "delay": 180}, "power_detected": False}, + "print_task_config": { + "filament_type": ["PLA", "PLA", "NONE", "PETG"], + "filament_vendor": ["Jayo", "Forshape", "NONE", "Generic"], + "filament_color_rgba": rgba, + "filament_color": argb, + "filament_exist": [True, True, False, True]}, + } + js(ctx3, f"var st=new MachineState(); st.apply({json.dumps(objs)});") + check("the active toolhead is the one reporting ACTIVATE", + js(ctx3, "st.toolhead().activeIndex").to_int32() == 3, + f"got {js(ctx3, 'st.toolhead().activeIndex').to_int32()}") + check("the axis readout reads motion_report.live_position", + abs(js(ctx3, "st.toolhead().x").to_double() - 105.5844) < 1e-6) + check("unknown homing is not reported as 'not homed'", + js(ctx3, "st.toolhead().allHomed === null").to_boolean()) + check("purifier mode 0 renders as a name, not a bare 0", + js(ctx3, "st.purifier().modeName").to_string() == "Off") + check("an object-valued fan is read as a percentage, not NaN", + js(ctx3, "st.purifier().exhaustFan").to_int32() == 0) + check("the empty slot is the one the printer says is absent", + js(ctx3, "JSON.stringify(st.filaments().map(f=>f.loaded))").to_string() + == "[true,true,false,true]") + check("slot colours normalise to CSS", + js(ctx3, "JSON.stringify(st.filaments().map(f=>cssColor(f.color)))").to_string() + == '["#F44336","#FFFFDC","#FFFFFF","#632C2C"]') + + # --- a transparent colour is an absence, and is not black --------------- + # + # multiACE wipes a feeder head's identity when the machine enters head mode: + # SET_PRINT_FILAMENT_CONFIG FILAMENT_TYPE="" VENDOR="" FILAMENT_COLOR_RGBA=00000000 + # `00000000` is RRGGBBAA with alpha ZERO. The alpha was being sliced off and thrown + # away, so it came back "#000000" and four spools were painted black. Reported as + # "switching between ACE modes switches filaments, blacks them". + print("\n== a colour the machine did not give ==") + cc = lambda v: js(ctx3, f"String(cssColor({v}))").to_string() + check("a fully transparent RRGGBBAA is no colour at all", + cc('"00000000"') == "null", f'got {cc(chr(34) + "00000000" + chr(34))}') + check("and so is an ARGB integer with no alpha", + cc("0") == "null" and cc("0x0000FF00") == "null", + f'got {cc("0")} and {cc("0x0000FF00")}') + # The whole point of reading the alpha rather than the RGB: opaque black IS a colour, + # and black filament is the commonest there is. + check("opaque black is still black, in both forms", + cc('"000000FF"') == "#000000" and cc("0xFF000000") == "#000000", + f'got {cc(chr(34) + "000000FF" + chr(34))} and {cc("0xFF000000")}') + check("a six-digit value has no alpha to read and is taken as written", + cc('"000000"') == "#000000" and cc('"8FA7C8"') == "#8FA7C8") + check("and a partly transparent colour is still that colour", + cc('"3366CC80"') == "#3366CC", f'got {cc(chr(34) + "3366CC80" + chr(34))}') + + # --- who owns a slot's identity, and who may change it ---------------- + # + # Both are the PRINTER's answers, per slot, and both were on the subscription and + # unread while the panel decided the same question off the RFID tag. They are not + # the same question: the payload below is 811002511261022618B3 as it stood on + # 2026-08-28, where head 2 carries a decodable tag (`filament_detect` reads Forshape + # PLA Silk, OFFICIAL, with a CARD_UID) and the machine still says it may be edited, + # because the record in use has been overridden - `filament_sub_type` there is `""` + # where the tag says `Silk`. + print("\n== who may edit a slot, and who decides ==") + TAGGED = {"MAIN_TYPE": "PLA", "SUB_TYPE": "Silk", "VENDOR": "Forshape", + "ARGB_COLOR": 4294967260, "CARD_UID": [4, 44, 238, 174], + "CARD_TYPE": "NTAG", "OFFICIAL": True} + NOTAG = {"MAIN_TYPE": "NONE", "SUB_TYPE": "NONE", "VENDOR": "NONE"} + measured = { + "print_task_config": { + "filament_type": ["PLA", "PLA", "NONE", "PETG"], + "filament_vendor": ["Jayo", "Forshape", "NONE", "Kingroon"], + "filament_sub_type": ["Marble", "", "NONE", "Basic"], + "filament_color_rgba": ["F44336FF", "FFFFDCFF", "FFFFFFFF", "8FA7C8FF"], + "filament_exist": [True, True, False, True], + "filament_official": [True, False, False, False], + "filament_edit": [False, True, False, True]}, + # Heads 1 AND 2 carry a tag. Only head 1's record is the tag's. + "filament_detect": {"info": [dict(TAGGED, VENDOR="Jayo", SUB_TYPE="Marble"), + TAGGED, NOTAG, NOTAG]}, + } + js(ctx3, f"var m=new MachineState(); m.apply({json.dumps(measured)});") + fl = lambda expr: js(ctx3, f"JSON.stringify(m.filaments().map(f=>{expr}))").to_string() + check("a tag is read on both heads that carry one", + fl("!!f.tag") == "[true,true,false,false]", f"got {fl('!!f.tag')}") + check("but only one of them is the machine's own record", + fl("f.official") == "[true,false,false,false]", f"got {fl('f.official')}") + # The machine's own permission, verbatim. Its firmware computes it as + # allowed_edit = filament_exist[ch] and not filament_official[ch] + # and enforces the other half in SET_PRINT_FILAMENT_CONFIG, which raises "official + # filament, not configurable!" without FORCE=1. + check("the machine's permission is carried as the machine states it", + fl("f.allowedEdit") == "[false,true,false,true]", + f"got {fl('f.allowedEdit')}") + # And the page is STRICTER than it, deliberately. That permission 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. Head 2 is sitting in that + # state - an NTAG reading Forshape PLA Silk is physically present, print_task_config + # says sub-type "" - and following the latch alone offers to type over a spool that + # reverts on its next load. + check("but a spool that carries a tag is not edited from this page", + fl("f.editable") == "[false,false,false,true]", f"got {fl('f.editable')}") + check("the head with a tag and an unlocked latch is the case that separates them", + js(ctx3, "JSON.stringify([m.filaments()[1].tag !== null," + " m.filaments()[1].allowedEdit, m.filaments()[1].editable])") + .to_string() == "[true,true,false]", + "reported from the panel: it has an rfid icon and is still editable") + # Stricter, never more permissive: the machine's own refusals still stand on their + # own reason, so an untagged official slot and an empty one stay closed. + check("and the machine's own refusals are not loosened by that", + js(ctx3, "m.filaments().every(f => f.editable ? f.allowedEdit : true)") + .to_boolean()) + check("an empty slot is not editable, because there is nothing there", + js(ctx3, "m.filaments()[2].editable === false").to_boolean()) + check("the untagged head is the only one this page will edit", + js(ctx3, "JSON.stringify(m.filaments().map((f,i)=>f.editable?i:null)" + ".filter(x=>x!==null))").to_string() == "[3]", + "the ACE-fed head: no tag, and the machine allows it") + + # A firmware that does not report the field at all must not read as "edit anything". + js(ctx3, "var n=new MachineState(); n.apply({print_task_config:{" + "filament_type:['PLA','PLA'],filament_exist:[true,true]}," + "filament_detect:{info:[" + json.dumps(TAGGED) + ",{MAIN_TYPE:'NONE'}]}});") + check("with no permission bit reported, the tag is the fallback and is named as one", + js(ctx3, "JSON.stringify(n.filaments().slice(0,2).map(f=>f.allowedEdit))") + .to_string() == "[false,true]", + "an absent `filament_edit` must not read as 'everything is editable'") + + # --- which toolhead is live ------------------------------------------- + print("\n== the active toolhead follows the live stream ==") + parked = {("extruder" if i == 0 else f"extruder{i}"): {"state": "PARKED"} + for i in range(4)} + + def active(objs): + js(ctx3, f"var t=new MachineState(); t.apply({json.dumps(objs)});") + return js(ctx3, "String(t.toolhead().activeIndex)").to_string() + + check("a live ACTIVATE is reported", + active({**parked, "extruder3": {"state": "ACTIVATE"}, + "toolhead": {"extruder": "extruder3"}}) == "3") + # the bug: `toolhead` is fetched once at connect, so it goes stale on every change + check("a tool change is seen even though `toolhead` is stale", + active({**parked, "extruder1": {"state": "ACTIVATE"}, + "toolhead": {"extruder": "extruder3"}}) == "1", + "preferring toolhead.extruder froze the active tool at connect, so pickTool " + "waited for a change it could not see and timed out having actually worked") + check("parking is seen: no extruder ACTIVATE means none live", + active({**parked, "toolhead": {"extruder": "extruder3"}}) == "null", + "parkTool waits for null, which a stale toolhead never produced") + # This test previously asserted the opposite, and encoded the bug. + check("toolhead.extruder is not treated as evidence that a head is engaged", + active({**parked, "toolhead": {"extruder": "extruder"}}) == "null", + "measured: it reads 'extruder' while all four report PARKED - it names the " + "last head used, not the one docked. Trusting it made the panel offer to " + "park nothing, which no-ops instantly and then times out") + check("with no extruder states at all the answer is 'nothing engaged'", + active({"toolhead": {"extruder": "extruder2"}}) == "null", + "a wrong answer here is worse than none: it drives a park that cannot work") + + # --- multiACE: two sources, one precedence ---------------------------- + # The panel drew three of four bays as `?` on a machine that knew all four, because + # the `ace` object carries no per-bay identity and the names live in multiACE's own + # override store. The merge is pure and lives in one module now, so the rule that + # decides which of the two wins is testable here rather than only on a page. + print("\n== multiACE: where a bay's identity comes from ==") + ctxA = new_ctx(os.path.join(SHARED, "js", "protocol.js"), + os.path.join(SHARED, "js", "multiACE.js")) + + # As measured: one unit, four occupied bays, every raw slot blank, head 3 fed from + # bay 2 - so bay 2 is the only one the object itself can name. + RAW = json.dumps({"ace": { + "mode": "head", "device_count": 1, "api_version": 1, + "head_manual": {"0": False, "1": False, "2": False, "3": False}, + "head_feeder": {"0": True, "1": True, "2": True, "3": False}, + "head_ace": {"0": 0, "1": 1, "2": 2, "3": 0}, + "head_source": {"3": {"ace_index": 0, "slot": 2, "type": "PETG", + "subtype": "Basic", "color": "632C2C", "brand": "Generic"}}, + "aces": [{"idx": 0, "connected": True, "protocol": "v2", "model": "ACE 2 Pro", + "humidity": 38, "temp": 30, "gate_status": [1, 1, 1, 1], + "dryer_status": {"status": "stop", "target_temp": 0, + "duration": 0, "remain_time": 0}, + "slots": [{"index": i, "status": "unknown", "material": "", + "brand": "", "subtype": "", "rfid": 0, + "color": [0, 0, 0]} for i in range(4)]}]}}) + OVERRIDES = json.dumps({ + "0_0": {"ace": 0, "slot": 0, "material": "PETG", "brand": "Kingroon", + "subtype": "Basic", "color": "#83AFFF"}, + "0_1": {"ace": 0, "slot": 1, "material": "PLA", "brand": "Jayo", + "subtype": "", "color": "#1f8a4c"}}) + + def bays(overrides="null", raw=RAW): + return js(ctxA, f"JSON.stringify(mergeAceBays(parseAce({raw}).units[0], " + f"{overrides}).map(b => b.source + ':' + (b.material || '-')))" + ).to_string() + + check("the ace object alone names only the bay a head is loaded from", + bays() == '["unknown:-","unknown:-","derived:PETG","unknown:-"]', + "every raw slot reads material:'' - these spools have no tags") + check("multiACE's store names the rest", + bays(OVERRIDES) == '["override:PETG","override:PLA","derived:PETG","unknown:-"]', + "this is the read the panel was missing, and what Orca's Prepare page already had") + check("a bay nothing names stays unnamed rather than borrowing a neighbour", + '"unknown:-"' in bays(OVERRIDES).split(",")[-1], + "occupied-and-unnamed is true; blank is a lie in the other direction") + + # multiACE's own precedence: a tag beats a name someone typed against the bay. + tagged = json.loads(RAW) + tagged["ace"]["aces"][0]["slots"][0] = {"index": 0, "status": "ready", "rfid": 2, + "material": "ABS", "brand": "Snapmaker", + "subtype": "", "color": [15, 111, 209]} + check("a tag beats the override store", + bays(OVERRIDES, json.dumps(tagged)).startswith('["rfid:ABS"'), + "the hardware's own answer outranks a name typed against the bay") + check("and with no store at all the merge is the identity function", + bays("null", json.dumps(tagged)).startswith('["rfid:ABS"'), + "no multiACE web service must mean 'nothing to merge', not an error") + + # The two guesses the printer disagreed with, held to the reading. + dry = js(ctxA, f"JSON.stringify(parseAce({RAW}).units[0].dryer)").to_string() + check("an idle dryer is not running and shows no countdown", + '"running":false' in dry, dry) + running = json.loads(RAW) + running["ace"]["aces"][0]["dryer_status"] = {"status": "keeping", "target_temp": 55, + "duration": 14400, "remain_time": 4740} + d2 = json.loads(js(ctxA, f"JSON.stringify(parseAce({json.dumps(running)})" + f".units[0].dryer)").to_string()) + check("`keeping` is the running word, not `running`", d2["running"] is True, + "measured by running the dryer for ten seconds; it was guessed as 'running'") + check("and its times are seconds on the wire, minutes on the panel", + (d2["totalMin"], d2["remainingMin"], d2["doneMin"]) == (240, 79, 161), + f"got {d2}") + + # --- what the printer said, when it said no ---------------------------- + # `sw_SendGCodes` answers `ok` for a macro that failed. The reason exists in exactly + # one place - Klipper's `!!` channel, in Moonraker's console history - and reading it + # is what turned "Nothing started" into "Must home Z axis first". + STORE = json.dumps({"result": {"gcode_store": [ + {"message": "ACE_SWAP_HEAD HEAD=3 ACE=0 SLOT=3", "type": "command"}, + {"message": "// multiace_event swap_failed head=3 status=error seq=2", + "type": "response"}, + {"message": "!! Must home Z axis first: 229.300 250.000 277.000 [0.000]", + "type": "response"}, + ]}}) + check("the printer's own error is read out of the console history", + js(ctxA, f"lastPrinterError({STORE})").to_string() + == "Must home Z axis first: 229.300 250.000 277.000 [0.000]", + "read off 811002511261022618B3 after a swap the page reported as 'nothing " + "started'") + check("a `//` note is not an error, however alarming it reads", + js(ctxA, 'lastPrinterError({result:{gcode_store:[' + '{message:"// multiace_event swap_failed status=error"}]}})') + .is_null(), + "only `!!` is Klipper's error channel") + check("and nothing to read is null rather than a sentence", + js(ctxA, "lastPrinterError(null)").is_null(), + "an unreachable Moonraker must not become the reported fault") + + check("the module says which multiACE it was verified against", + js(ctxA, "MULTIACE_VERIFIED.apiVersion").to_int32() == 1, + "a plugin, not firmware - the contract version is part of the evidence") + + # --- what can be done to one filament --------------------------------- + # The rule is not "which verbs exist" but "which are verbs at all in this state", and + # it is the half of the panel a screenshot cannot check: a Load offered on a loaded + # head and a Swap to the bay already feeding are both three-minute no-ops that a + # drawing looks perfectly happy with. + print("\n== multiACE: which verbs are verbs, in this state ==") + + def verbs(head, slot, loaded, raw=RAW): + arg = "null" if slot is None else str(slot) + out = js(ctxA, f"JSON.stringify(aceVerbs(parseAce({raw}), {head}, {arg}," + f" {str(loaded).lower()}).map(v => [v.name, v.off || '', v.cmd]))") + return json.loads(out.to_string()) + + # head 3 is the ACE-fed one and is fed from bay 2. + names = lambda vs: [v[0] for v in vs] + check("the bay already feeding the head is not a load and not a swap", + names(verbs(3, 2, True)) == ["Unload and retract", "Background unload"], + f"got {names(verbs(3, 2, True))}") + check("a different bay is a SWAP, because something is already loaded", + names(verbs(3, 0, True))[0] == "Swap", + f"got {names(verbs(3, 0, True))}") + # And a swap is an unload then a load, NOT ACE_SWAP_HEAD. + # + # That macro is the print's: it opens with `G91 / G1 Z2 F600 / G90` to lift the nozzle + # off the part, and Klipper refuses a Z move on an unhomed Z - so a swap from the + # panel on a machine at `homed_axes: "xy"` answered `ok`, printed + # `!! Must home Z axis first` and did nothing. Neither half of the pair moves Z, and + # the pair is what multiACE's own dashboard and HelixScreen both send for this verb. + check("and it is sent as an unload then a load, which need no homed Z", + verbs(3, 0, True)[0][2] + == "ACE_UNLOAD_HEAD HEAD=3\nACE_LOAD_HEAD HEAD=3 ACE=0 SLOT=0", + f"got {verbs(3, 0, True)[0][2]!r} - ACE_SWAP_HEAD is the print's swap and " + f"opens with a Z hop") + # The unload cannot be folded in: ACE_LOAD_HEAD's own guard refuses a head that + # already holds filament rather than swapping for you. + check("the unload comes first, because a load alone would be refused", + verbs(3, 0, True)[0][2].splitlines()[0].startswith("ACE_UNLOAD_HEAD"), + f"got {verbs(3, 0, True)[0][2]!r}") + # A load is still one line - only the swap grew a second. + check("and a load on an empty head is still the one macro", + verbs(3, 0, False)[0][2] == "ACE_LOAD_HEAD HEAD=3 ACE=0 SLOT=0", + f"got {verbs(3, 0, False)[0][2]!r}") + # An ACE head is empty when the OBJECT says so - `head_source` naming no slot for it - + # not when a caller passes loaded=false. The first cut of this check passed the flag + # and asserted a Load, and the model was right to answer Swap: the machine still said + # bay 2 was feeding that head. + RAW_EMPTY = RAW.replace('"head_source": {"3": {"ace_index": 0, "slot": 2, ' + '"type": "PETG", "subtype": "Basic", "color": "632C2C", ' + '"brand": "Generic"}}', '"head_source": {}') + # And it is empty when the SENSOR says so, not when `head_source` has stopped naming a + # bay. That record is multiACE's account of the last feed and does not clear because + # the filament came out - the same trap as `filament_exist` one level down, and the + # one that let an unloaded head go on offering Unload. + check("a head whose head_source still names a bay is empty if nothing is in it", + names(verbs(3, 0, False))[0] == "Load", + f'got {names(verbs(3, 0, False))} - `fed == null && !loaded` made this a Swap') + check("and the bay it was last fed from is a load like any other", + names(verbs(3, 2, False))[0] == "Load", f"got {names(verbs(3, 2, False))}") + check("and an empty head is a LOAD, because it is not a swap", + names(verbs(3, 0, False, RAW_EMPTY))[0] == "Load", + "the panel sent ACE_SWAP_HEAD for both before, on the grounds that it is the " + f"macro that takes a slot; got {names(verbs(3, 0, False, RAW_EMPTY))}") + check("an unload on an ACE head retracts into the bay, which a feeder cannot", + "RETRACT_LENGTH" in verbs(3, 2, True)[0][2], + f"got {verbs(3, 2, True)[0][2]}") + # head 0 is on its stock feeder: one verb at a time, and never a swap. + check("a loaded stock feeder offers unload, alone", + names(verbs(0, None, True)) == ["Unload"], f"got {names(verbs(0, None, True))}") + check("and an empty one offers load, alone", + names(verbs(0, None, False)) == ["Load"], f"got {names(verbs(0, None, False))}") + bg = [v for v in verbs(3, 0, True) if v[0].startswith("Background")] + check("with enabled_heads empty, both background verbs are refused", + all(v[1] for v in bg) and len(bg) == 2, f"got {bg}") + check("and each names the macro that would lift the refusal, not the one it cannot run", + all(v[2].startswith("ACE_BG_SET_HEAD") for v in bg), f"got {bg}") + # the same object with head 3 declared + RAW_BG = RAW[:-1] + ', "ace_bg_swap": {"version": "v0.9", "enabled_heads": [3],' \ + ' "busy": [], "state": {}}}' + bg2 = [v for v in verbs(3, 0, True, RAW_BG) if v[0].startswith("Background")] + check("declaring the head with ACE_BG_SET_HEAD lifts it", + not any(v[1] for v in bg2) and bg2[0][2].startswith("ACE_BG_SWAP"), + f"got {bg2}") + + # --- the three modes, which decide what the same four bays ARE --------- + # Every measurement behind this panel was taken in `head` mode, and the model was + # written from those readings. A mode is not a display preference: it is how many + # heads the ACE drives, and it changes the meaning of three fields at once. The two + # payloads below are the same machine minutes apart - 811002511261022618B3 on + # 2026-09-01, switched to multi and back over Moonraker HTTP - so the diff between + # them is exactly the diff the printer produced, not one composed here. + print("\n== multiACE: the three modes ==") + + # As measured: switching to multi moved TWO of the 38 keys. head_feeder, head_ace and + # head_source were left byte-identical, and that is the whole trap. + MULTI = json.dumps({"ace": { + "mode": "multi", "device_count": 1, "api_version": 1, "active_device": 0, + "ace_heads": [0, 1, 2, 3], + "head_manual": {"0": False, "1": False, "2": False, "3": False}, + "head_feeder": {"0": True, "1": True, "2": True, "3": False}, + "head_ace": {"0": 0, "1": 1, "2": 2, "3": 0}, + "head_source": {"3": {"ace_index": 0, "slot": 1, "type": "PETG", + "subtype": "Basic", "color": "8FA7C8", "brand": "Kingroon"}}, + "aces": [{"idx": 0, "connected": True, "protocol": "v2", "model": "ACE 2 Pro", + "slots": [{"index": k, "status": "ready", "material": "", "brand": "", + "rfid": 0, "color": [0, 0, 0]} for k in range(4)]}], + }}) + + def src(raw, i, field): + return js(ctxA, f"JSON.stringify(parseAce({raw}).heads[{i}].{field})").to_string() + + check("head mode: head_feeder is the answer, and three heads are on stock feeders", + [json.loads(src(RAW, i, "source")) for i in range(4)] + == ["feeder", "feeder", "feeder", "ace"], + f'got {[json.loads(src(RAW, i, "source")) for i in range(4)]}') + # The bug this fixes. head_feeder still reads {0,1,2 true} in the multi payload - + # the switch does not touch it - and the plugin's head_is_feeder() returns False + # outright outside head mode, so the panel drew three feeders on a machine whose + # every head is ACE-driven. + check("multi: ace_heads is read, so every head is ACE-driven despite head_feeder", + [json.loads(src(MULTI, i, "source")) for i in range(4)] == ["ace"] * 4, + f'got {[json.loads(src(MULTI, i, "source")) for i in range(4)]}') + check("and head_feeder really is unchanged in that payload", + json.loads(MULTI)["ace"]["head_feeder"] == json.loads(RAW)["ace"]["head_feeder"], + "the two payloads must differ only where the machine differed") + # head_ace is not the wiring outside head mode: it reads {0:0,1:1,2:2,3:0} with ONE + # unit, so heads 1 and 2 name units that do not exist. The unit is head_source's + # where a load has been recorded and the ACTIVE one otherwise, which is what + # cmd_ACE_LOAD_HEAD's non-head branch defaults ACE= to. + check("multi: the unit is the recorded one or the active one, never head_ace", + [json.loads(src(MULTI, i, "unitIndex")) for i in range(4)] == [0, 0, 0, 0], + f'got {[json.loads(src(MULTI, i, "unitIndex")) for i in range(4)]}') + check("multi: every head has a LANE, and it is its own index", + [json.loads(src(MULTI, i, "lane")) for i in range(4)] == [0, 1, 2, 3], + f'got {[json.loads(src(MULTI, i, "lane")) for i in range(4)]}') + check("head mode: no head has one, because any bay may feed the one head", + [json.loads(src(RAW, i, "lane")) for i in range(4)] == [None] * 4, + f'got {[json.loads(src(RAW, i, "lane")) for i in range(4)]}') + # head_source survives the switch, so the machine reported a bay feeding a head it is + # not lane-wired to. The mode is a claim about tubes and nothing verifies it: the + # panel keeps the record and does not round it to the mode's rule. + check("multi: a recorded feed off the lane is kept, not tidied to the lane", + json.loads(src(MULTI, 3, "bay")) == 1 and json.loads(src(MULTI, 3, "lane")) == 3, + f'got bay {src(MULTI, 3, "bay")} lane {src(MULTI, 3, "lane")}') + + # `normal` is the one mode never observed on hardware, and its list cannot be trusted: + # head_uses_ace() has NO branch for it - manual is False, head mode tests head_feeder, + # everything else returns True - so a normal machine would publish ace_heads [0,1,2,3], + # naming every head in the mode whose definition is that none of them is. + NORMAL = MULTI.replace('"mode": "multi"', '"mode": "normal"') + check("normal: the MODE decides, not ace_heads, which would claim all four", + [json.loads(src(NORMAL, i, "source")) for i in range(4)] == ["feeder"] * 4, + f'got {[json.loads(src(NORMAL, i, "source")) for i in range(4)]}') + + def mverbs(head, slot, loaded, unit=0, raw=MULTI): + out = js(ctxA, f"JSON.stringify(aceVerbs(parseAce({raw}), {head}, {slot}," + f" {str(loaded).lower()}, {unit}).map(v => [v.name, v.slotted, v.cmd]))") + return json.loads(out.to_string()) + + # A bay is not free to feed any head outside head mode: cmd_ACE_LOAD_HEAD's non-head + # branch is `slot = gcmd.get_int('SLOT', head)`, and the machine will NOT refuse a + # SLOT naming somebody else's lane - it will push filament somewhere it is not routed. + check("multi: a head's own lane is loadable", + [v[0] for v in mverbs(2, 2, False)] == ["Load"], + f"got {[v[0] for v in mverbs(2, 2, False)]}") + check("multi: another head's lane is not a verb at all, not a greyed one", + [v for v in mverbs(2, 0, False) if v[1]] == [], + f"got {mverbs(2, 0, False)}") + # ...but the verbs that address the HEAD survive the question, because they have + # nothing to do with which bay was asked about. The measured machine reaches exactly + # that state, so a blanket refusal emptied the menu on its one loaded head. + check("multi: and the head's own verbs are unaffected by an off-lane bay", + [v[0] for v in mverbs(3, 1, True)] == ["Unload and retract"], + f"got {[v[0] for v in mverbs(3, 1, True)]}") + # ace_bg_swap refuses outside head mode in its own words - "v0 requires head mode + # (1:1 ACE per head)" - and the gate is the MODE, which ACE_BG_SET_HEAD cannot lift. + check("multi: no background verb is offered, because none can be made available", + all("Background" not in v[0] + for h in range(4) for v in mverbs(h, h, True)), + "the mode is not a refusal a verb row can lift") + check("head mode still offers them, refused and naming the macro that declares a head", + any("Background" in v[0] for v in verbs(3, 0, True)), + f"got {names(verbs(3, 0, True))}") + + # The switch itself, which is the one control whose success arrives as a failure. + def change(a, b): + return json.loads(js(ctxA, f"JSON.stringify(aceModeChange('{a}','{b}'))").to_string()) + + check("multi <-> head is live, and needs no unload", + change("head", "multi")["live"] and change("multi", "head")["live"] + and not change("head", "multi")["needsEmpty"], + f'got {change("head", "multi")}') + check("anything through normal needs every head empty and a restart", + all(change(a, b)["needsRestart"] and change(a, b)["needsEmpty"] + for a, b in [("head", "normal"), ("normal", "multi"), ("normal", "head")]), + "a normal transition swaps three Klipper extras and raises") + check("the restart is owed exactly while the saved mode and the running one disagree", + [json.loads(js(ctxA, "JSON.stringify(acePendingMode(" + f"{{mode:'head',savedMode:{v}}}))").to_string()) + for v in ("'normal'", "'head'", "null")] == ["normal", None, None], + "ace.mode vs save_variables.ace__mode is the only thing that says so") + + # And the refusal, which the RPC never carries: the guard prints `//` NOTES and + # returns `ok`. Captured verbatim on 2026-09-01. + STORE = json.dumps({"result": {"gcode_store": [ + {"message": "SET_ACE_MODE MODE=normal"}, + {"message": "// Cannot switch mode! Filament still loaded in: E0, E1, E3"}, + {"message": "// Please unload all toolheads first, then try again."}]}}) + said = json.loads(js(ctxA, f"JSON.stringify(aceModeRefusal({STORE}))").to_string()) + check("the refusal is read off the note channel, both lines and in order", + said == ["Cannot switch mode! Filament still loaded in: E0, E1, E3", + "Please unload all toolheads first, then try again."], + f"got {said}") + check("and a console with no refusal in it says so rather than guessing", + js(ctxA, 'JSON.stringify(aceModeRefusal({result:{gcode_store:' + '[{message:"// Switching to MULTI mode..."}]}}))').to_string() == "null", + "a note that is not the refusal must not be reported as one") + + # The selector's options are the mode's, because two of the three setters are + # documented head-mode-only and the third - ACE_SET_HEAD_MANUAL - is not. + def opts(raw, head=0): + return json.loads(js(ctxA, f"JSON.stringify(aceSourceOptions(parseAce({raw}), {head})" + ".map(o => o.label))").to_string()) + + check("head mode offers the feeder, each unit by name, and the hand", + opts(RAW) == ["Default feeder", "ACE A", "Hand-fed"], f"got {opts(RAW)}") + check("multi offers the lane and the hand, because the lane is plumbing", + opts(MULTI, 1) == ["Bay A2", "Hand-fed"], f"got {opts(MULTI, 1)}") + check("normal offers the feeder and the hand, and never fully disables", + opts(NORMAL) == ["Default feeder", "Hand-fed"], f"got {opts(NORMAL)}") + # With a splitter on the head, slot i of EVERY unit reaches it - so naming one bay of + # the set was naming one bay of a set. Reported from the two-unit drawing. + _two = json.loads(MULTI) + _two["ace"]["device_count"] = 2 + _two["ace"]["aces"] = [_two["ace"]["aces"][0], + dict(_two["ace"]["aces"][0], idx=1, model="ACE Pro")] + TWO = json.dumps(_two) + check("and with two units the lane option names both bays, not the first", + opts(TWO, 0) == ["Bay A1 · B1", "Hand-fed"], f"got {opts(TWO, 0)}") + + # --- and what the machine says it is doing about it -------------------- + # `channel_state` is the U1's own and is already on the subscription; `swap_phase` is + # multiACE's and has never been captured on hardware. The classification below is + # HelixScreen's, taken rather than re-derived. + print("\n== multiACE: the steps, from channel_state ==") + + def step(state, kind="swap"): + out = js(ctxA, f"JSON.stringify(channelStep('{state}', '{kind}'))").to_string() + return json.loads(out) + + check("a swap is one bar of six, both halves on it", + step("unload_homing")["total"] == 6 and step("load_flushing")["at"] == 5, + f"got {step('unload_homing')} / {step('load_flushing')}") + check("the unload half lands where the unload half is", + [step(s)["at"] for s in ("unload_homing", "unload_picking", + "unload_heating", "unload_doing")] == [0, 1, 2, 3]) + check("and the load half's own Home/Select/Heat hold rather than jumping back", + [step(s)["at"] for s in ("load_homing", "load_picking", "load_heating")] + == [None, None, None], + "the head is mounted and already hot by then, so they pass straight through") + check("the heat step is the one that reads the nozzle", + step("unload_heating")["heat"] is True and step("unload_doing")["heat"] is False) + check("an unload on its own is four steps, not six", + step("unload_heating", "unload")["total"] == 4) + check("a *_fail is a failure, and carries the firmware's own word", + step("unload_fail")["failed"] is True and step("unload_fail")["state"] == "unload_fail") + check("a *_finish ends it", step("load_finish")["done"] is True) + check("idle words draw nothing at all", + step("none") is None and step("inited") is None and step("") is None) + # Measured on the machine, everything settled: two heads said `load_finish` and two + # said `wait_insert`. A terminal state is the RESTING state here - the field holds the + # last operation's ending rather than returning to a neutral word - so both have to be + # quiet or an idle panel is never quiet. + check("and so do the words a settled machine actually reports", + step("wait_insert") is None and step("load_finish")["done"] is True, + f'got {step("wait_insert")} / {step("load_finish")}') + check("an unrecognised state holds the right half rather than resetting the bar", + step("unload_something_new")["at"] == 0, + "this is a pre-1.0 plugin on a firmware that has grown states before") + + # --- what the machine says it is doing -------------------------------- + print("\n== activity, at both granularities ==") + ctx4 = new_ctx(os.path.join(SHARED, "js", "activity.js")) + + def act(main_state, action_code): + return js(ctx4, f"String(machineActivity({{mainState:{main_state}," + f"actionCode:{action_code}}}))").to_string() + + def busy(main_state, action_code): + return js(ctx4, f"isBusy({{mainState:{main_state}," + f"actionCode:{action_code}}})").to_boolean() + + check("an idle machine reports nothing", act(0, 0) == "null" and not busy(0, 0)) + # the case that mattered: a toolchange's calibration shows up in main_state only + check("an XY calibration is reported even though action_code is 0", + act(2, 0) == "XYZ calibrating" and busy(2, 0), + "reading only action_code left the wait silent for the very thing that " + "makes a toolchange take minutes") + check("a docking calibration is reported the same way", + act(12, 0) == "Docking Coordinate Calibrating" and busy(12, 0)) + check("the finer action_code wins when both are set", + act(1, 770) == "Checking Extruder Pick...") + check("plain 'Working' is not shown to someone already waiting on an operation", + act(1, 0) == "null", + "it says nothing the caller did not know, and would mask nothing useful") + check("the two tables are not merged", + js(ctx4, "String(ACTION_LABELS[1])").to_string() == "undefined", + "1 is Working in one table and Homing in the other") + + # --- homed, not homed, and not known ---------------------------------- + print("\n== homing state ==") + + def homed(objs): + js(ctx3, f"var h=new MachineState(); h.apply({json.dumps(objs)});") + return (js(ctx3, "String(h.toolhead().allHomed)").to_string(), + js(ctx3, "String(h.toolhead().isHomed('z'))").to_string()) + + check("a cold machine reports not homed, not unknown", + homed({"toolhead": {"homed_axes": ""}}) == ("false", "false"), + "an empty homed_axes is a plain answer; reading it as unknown let every Z " + "jog through to a printer that refuses it, so the bed buttons looked dead") + check("a homed machine reports homed", homed({"toolhead": {"homed_axes": "xyz"}}) + == ("true", "true")) + check("a partly homed machine is not all homed", + homed({"toolhead": {"homed_axes": "xy"}}) == ("false", "false")) + check("with no snapshot at all the answer is unknown, and nothing is blocked", + homed({}) == ("null", "true"), + "before the first query, refusing everything would be worse than allowing it") + + # --- why the machine is busy, replayed from a real toolchange --------- + print("\n== the captured toolchange, step by step ==") + tl = hw["toolchange"]["timeline"] + + js(ctx3, "var m = new MachineState();") + + def step(objs): + js(ctx3, f"m.apply({json.dumps(objs)});") + return json.loads(js(ctx3, "JSON.stringify(m.busyReason())").to_string()) + + r = step({"toolhead": {"homed_axes": "z"}, "idle_timeout": {"state": "Printing"}}) + check("0.7s - homing starts, and says so without an axis-by-axis account", + r["busy"] and r["label"] == "Homing axes\u2026", + "Klipper clears homed_axes as it re-homes, so naming the axes done reported " + "Z and then dropped it - the machine looked like it was going backwards") + r = step({"toolhead": {"homed_axes": "y"}}) + check("4.7s - and stays one step while the axes come in", + r["busy"] and r["label"] == "Homing axes\u2026", + "this is the phase a user reads as 'XY calibration'") + r = step({"toolhead": {"homed_axes": "xy"}}) + check("14.7s - still the one step", + r["busy"] and r["label"] == "Homing axes\u2026") + r = step({"extruder": {"activating_move": True}}) + check("28.7s - the grab is named separately from the homing", + r["busy"] and r["label"] == "Engaging toolhead 1\u2026") + r = step({"extruder": {"activating_move": False, "state": "ACTIVATE"}, + "toolhead": {"homed_axes": "xyz"}}) + check("30.7s - the head is live", js(ctx3, "String(m.toolhead().activeIndex)") + .to_string() == "0") + r = step({"idle_timeout": {"state": "Ready"}}) + check("32.7s - and the machine goes quiet", + not r["busy"] and r["label"] is None) + + # A printer that has gone away stops speaking, which is indistinguishable from one + # with nothing to say unless the question is asked about *now*. + print("\n== is anything still there ==") + js(ctx3, "var a = new MachineState();") + check("a store nothing has ever reached is infinitely old", + js(ctx3, "a.age() === Infinity").to_boolean(), + "lastUpdate of 0 read as 'connected' for the rest of the session") + js(ctx3, "a.apply({extruder: {temperature: 26}}); a.lastUpdate = 1000000;") + check("and one that just heard something is new", + js(ctx3, "a.age(1000500)").to_double() == 500.0) + check("a store that stopped hearing ages", + js(ctx3, "a.age(1060000)").to_double() == 60000.0, + "this is what a rebooting printer looks like from the page") + + # the two sources that were trusted before, and reported nothing at all + silent = hw["toolchange"]["silent_throughout"] + js(ctx3, "var q = new MachineState();") + js(ctx3, f"q.apply({json.dumps({'machine_state_manager': silent['machine_state_manager'], 'extruder_offset_calibration': {'calibration_step': 'idle'}})});") + check("machine_state_manager and calibration_step say nothing for a toolchange", + js(ctx3, "String(q.busyReason().label)").to_string() == "null" + and not js(ctx3, "q.busyReason().busy").to_boolean(), + "which is why the dialog had nothing to show - both were measured silent " + "across the whole 31s operation") + + # --- the temperature row, against a stubbed DOM ---------------------- + # These are the two faults a user hit on real hardware: a target of 0 sitting in the + # field so it had to be deleted before anything could be typed, and a value that had + # just been sent vanishing when the next state push landed a second later, before the + # printer had reported the change. + # ---- the primitive, with no DOM at all -------------------------------- + # pending.js exists because this bug has been found in three unrelated controls, so + # it is worth testing as itself rather than only through one of its customers. It + # touches nothing but a Map and a clock, so there is nothing to stub but the clock. + print("\n== pending: request against mirror ==") + ctxp = new_ctx(os.path.join(WEB, "device_page", "js", "core", "pending.js")) + js(ctxp, """ + var NOW = 1000000; + var fired = []; + var P = new Pending({ now: function () { return NOW; }, + onChange: function (k) { fired.push(k); } }); + """) + + check("nothing outstanding shows the machine", + js(ctxp, "P.resolve('t', 60).value").to_double() == 60 + and js(ctxp, "P.resolve('t', 60).state").to_string() == "") + + js(ctxp, "P.set('t', 200);") + check("a sent value is shown instead of the machine's", + js(ctxp, "P.resolve('t', 0).value").to_double() == 200 + and js(ctxp, "P.resolve('t', 0).state").to_string() == "sent", + "the whole bug in one line: the mirror still says 0 and must not win") + check("and starting a request asks for a repaint", + js(ctxp, "fired.join(',')").to_string() == "t", + "nothing on the stream prompts one - that is why the switch sat still") + + check("the machine echoing it ends the wait", + js(ctxp, "P.resolve('t', 200).state").to_string() == "" + and js(ctxp, "P.waiting('t')").to_boolean() is False) + + # the wire carries whatever it carries: 200 and "200" are the same answer + js(ctxp, "P.set('t', 200);") + check("a string from the wire confirms a number that was sent", + js(ctxp, "P.resolve('t', '200').state").to_string() == "", + "Object.is would go on waiting for a value that had already arrived") + + js(ctxp, "P.set('led', true);") + check("and a truthy mirror confirms a boolean", + js(ctxp, "P.resolve('led', 1).state").to_string() == "") + + # accepted, and then quietly ignored - an instant ok is indistinguishable from + # success, which is why this end exists at all + js(ctxp, "P.set('t', 200); NOW += 11000;") + check("a value the machine never echoes is given up on", + js(ctxp, "P.resolve('t', 0).state").to_string() == "lost") + check("and the machine's own value is shown while it is said", + js(ctxp, "P.resolve('t', 0).value").to_double() == 0 + and js(ctxp, "P.resolve('t', 0).asked").to_double() == 200, + "the number on screen must be true even while the message explains it") + js(ctxp, "NOW += 9000;") + check("the warning clears itself", + js(ctxp, "P.resolve('t', 0).state").to_string() == "") + + js(ctxp, "P.set('t', 200); P.fail('t', 200);") + check("a refused command stops the wait at once, without the timeout", + js(ctxp, "P.resolve('t', 0).state").to_string() == "lost", + "there is nothing coming to confirm a command that never left") + + js(ctxp, "P.set('t', 210); P.fail('t', 200);") + check("but a late refusal cannot overwrite what the user moved on to", + js(ctxp, "P.resolve('t', 0).state").to_string() == "sent" + and js(ctxp, "P.resolve('t', 0).value").to_double() == 210) + + print("\n== the temperature row ==") + # The temperature row is the Control panel's DOM, which lives with the rest of that + # panel now rather than in one shared ui.js. + ctx4 = new_ctx(os.path.join(WEB, "device_page", "js", "core", "pending.js"), + os.path.join(WEB, "device_page", "js", "views", "device-control", + "control", "control-view.js")) + # ui.js is written against a DOM there is none of here. The row functions touch four + # things - a dataset, an input value, one style width, and document.activeElement - + # so those four are all that is stubbed. Date.now is stubbed too, because one of the + # behaviours under test is a timeout. + js(ctx4, """ + var document = { activeElement: null }; + var NOW = 1000000; + Date.now = function () { return NOW; }; + function row() { + var parts = { '.cur': { textContent: '' }, + '.heat': { style: { width: '0' } }, + '.tgt': { value: '', dataset: { target: '0' } } }; + // `k` is how the row names itself to the pending store + return { dataset: { name: 'Toolhead 1', k: 'e0' }, title: '', + querySelector: function (s) { return parts[s]; } }; + } + // one store per row() so a test cannot inherit the previous one's wait + var P; + function fresh() { P = new Pending({ now: function () { return NOW; } }); return row(); } + var r = fresh(), t = r.querySelector('.tgt'); + """) + + js(ctx4, "updateTempRow(r, 24, 0, P);") + check("a heater that is off leaves the field empty, so the dash shows through", + js(ctx4, "t.value").to_string() == "", + "a literal 0 has to be deleted before a temperature can be typed") + check("an off row says so", + js(ctx4, "r.title").to_string() == "Toolhead 1 — off") + + js(ctx4, "updateTempRow(r, 24, 200, P);") + check("a target the machine reports lands in the field", + js(ctx4, "t.value").to_string() == "200") + check("and the row says it is heating", + js(ctx4, "r.dataset.heat").to_string() == "heating" + and js(ctx4, "r.title").to_string() == "Toolhead 1 — heating to 200 °C") + + js(ctx4, "document.activeElement = t; t.value = '55'; updateTempRow(r, 24, 200, P);") + check("a focused field is never overwritten by an incoming reading", + js(ctx4, "t.value").to_string() == "55") + js(ctx4, "document.activeElement = null;") + + # the vanishing value, exactly as it happened + js(ctx4, "r = fresh(); t = r.querySelector('.tgt'); updateTempRow(r, 24, 0, P);" + "P.set('e0', 200); t.value = '200';" + "updateTempRow(r, 24, 0, P);") + check("a sent target survives the push that still reports the old one", + js(ctx4, "t.value").to_string() == "200" + and js(ctx4, "r.dataset.pend").to_string() == "1", + "this is the bug: the machine's 0 was written back over the value just sent") + check("and the row says it is waiting on the printer", + "waiting for the printer" in js(ctx4, "r.title").to_string()) + + js(ctx4, "updateTempRow(r, 25, 200, P);") + check("the machine echoing the target ends the wait", + js(ctx4, "String(r.dataset.pend)").to_string() == "undefined" + and js(ctx4, "t.value").to_string() == "200") + + # a setpoint the printer accepted and then ignored + js(ctx4, "r = fresh(); t = r.querySelector('.tgt'); updateTempRow(r, 24, 0, P);" + "P.set('e0', 200); t.value = '200'; NOW += 11000; updateTempRow(r, 24, 0, P);") + check("a target the machine never echoes is given up on, not left standing", + js(ctx4, "r.dataset.pend").to_string() == "lost" + and js(ctx4, "t.value").to_string() == "", + "a value the printer never took must not keep sitting there looking applied") + check("and the row names the value that did not take", + js(ctx4, "r.title").to_string() + == "Toolhead 1 — the printer did not take 200 °C") + js(ctx4, "NOW += 9000; updateTempRow(r, 24, 0, P);") + check("the warning clears itself", + js(ctx4, "String(r.dataset.pend)").to_string() == "undefined") + + # the bar: how far along the ramp, from where it started when the target was set + js(ctx4, "r = fresh(); updateTempRow(r, 24, 0, P); updateTempRow(r, 24, 200, P);") + check("the heat bar starts empty at the foot of the ramp", + js(ctx4, "r.querySelector('.heat').style.width").to_string() == "0.0%") + js(ctx4, "updateTempRow(r, 112, 200, P);") + check("and tracks the climb", + js(ctx4, "r.querySelector('.heat').style.width").to_string() == "50.0%") + js(ctx4, "updateTempRow(r, 199, 200, P);") + check("arriving is a state of its own, and the bar stops", + js(ctx4, "r.dataset.heat").to_string() == "ready" + and js(ctx4, "r.querySelector('.heat').style.width").to_string() == "0") + + js(ctx4, "r = fresh(); updateTempRow(r, 200, 200, P); updateTempRow(r, 200, 0, P);" + "updateTempRow(r, 120, 0, P);") + check("cooling is shown while the hardware is still hot", + js(ctx4, "r.dataset.heat").to_string() == "cooling" + and js(ctx4, "r.querySelector('.heat').style.width").to_string() == "40.0%", + "a nozzle at 120 with the heater off is not the same as an idle one") + # measured on hardware: asked for 40, the nozzle went to 48 + js(ctx4, "r = fresh(); updateTempRow(r, 26, 0, P); updateTempRow(r, 26, 40, P);" + "updateTempRow(r, 33, 40, P); updateTempRow(r, 48, 40, P);") + check("an overshoot restarts the ramp instead of leaving the bar full", + js(ctx4, "r.dataset.heat").to_string() == "cooling" + and js(ctx4, "r.querySelector('.heat').style.width").to_string() == "0.0%", + "the bar was still measuring the climb, so it read 100% while falling") + js(ctx4, "updateTempRow(r, 44, 40, P);") + check("and then measures the fall", + js(ctx4, "r.querySelector('.heat').style.width").to_string() == "50.0%") + + js(ctx4, "updateTempRow(r, 30, 0, P);") + check("and stops once it is cool enough to touch", + js(ctx4, "r.dataset.heat").to_string() == "" + and js(ctx4, "r.title").to_string() == "Toolhead 1 — off") + + print(f"\n{checks - len(fails)}/{checks} checks passed") + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/libslic3r/AceMmuState.hpp b/src/libslic3r/AceMmuState.hpp new file mode 100644 index 00000000000..b5652c23f2d --- /dev/null +++ b/src/libslic3r/AceMmuState.hpp @@ -0,0 +1,322 @@ +#ifndef slic3r_AceMmuState_hpp_ +#define slic3r_AceMmuState_hpp_ + +// Pure, GUI-independent model of the multiACE printer-side state, parsed from the +// raw `/multiace/api/state` payload (see docs/ace-mmu/02-multiace-printer-api.md). +// +// This lives in libslic3r (not the GUI layer) so it can be unit-tested without +// pulling in wxWidgets or MachineObject; the GUI-side AceMmuProvider includes it +// and projects an AceSnapshot onto MachineObject::amsList. + +#include "nlohmann/json.hpp" + +#include +#include +#include +#include + +namespace Slic3r { namespace AceMmu { + +inline constexpr int SLOT_COUNT = 4; // slots per ACE unit; T = ace*4 + slot + +// One slot within an ACE unit -> maps to an Orca AmsTray. +struct AceSlot +{ + int idx = 0; // 0..3 -> AmsTray::id + bool occupied = false; // state != empty && raw != 0 + std::string state; // empty|ready|loading|unloading|error|feeding|assist|unknown + int raw = 0; // gate_status int (0 = empty) + int rfid = 0; // 0 = none, 2 = RFID read OK + std::string material; // "PLA" + std::string brand; // vendor + std::string sku; + std::string subtype; // e.g. Matte / Silk + std::string color_rrggbb; // lowercase "#rrggbb" or empty + std::string source; // rfid|override|derived|empty|null + + // Identity is trustworthy when it came from a spool tag or an explicit + // override (mirrors multiACE's own lookup_live_slots filter). + bool identity_trusted() const { return source == "rfid" || source == "override"; } +}; + +// One ACE unit -> maps to an Orca Ams. +struct AceUnit +{ + int idx = 0; // 0..3 -> Ams::id + bool connected = false; + std::string protocol; // "" | v1 | v2 (ACE Pro vs ACE 2 Pro) + std::string status; // raw ace status string + std::optional temp; // internal temperature, if reported + std::optional humidity; // raw humidity percent (0..100), if reported + std::optional dryer_remaining_minutes; + std::vector slots; +}; + +// One physical extruder/toolhead (the "loaded" view: which (ACE,slot) feeds it). +struct AceToolhead +{ + int idx = 0; // 0..3 -> "T1".."T4" + std::optional ace; // which ACE unit currently feeds this head + std::optional slot; // which slot + std::string material; + std::string color_rrggbb; // "#rrggbb" or empty + bool filament_detected = false; + bool manual = false; // hand-fed / TPU bypass + bool feeder = false; // stock feeder mode + std::string source; +}; + +struct AceSnapshot +{ + int device_count = 0; // authoritative "how many ACE units to expose" + int active_device = 0; + int ace_head = -1; // index of the active toolhead (-1 unknown) + std::string mode; // normal|multi|head + std::string printer_state; // idle|busy|printing|paused|complete|error + std::string ace_status; // top-level ACE status (string on real firmware) + std::optional ace_temp; + std::vector units; + std::vector toolheads; + + const AceUnit* find_unit(int idx) const + { + for (const AceUnit& u : units) + if (u.idx == idx) + return &u; + return nullptr; + } +}; + +// Orca's flat tray index: T = unit*4 + slot, identical to multiACE's toolhead +// numbering and to `MachineObject::tray_index = ams_id*4 + tray_id`. +inline int ace_tray_index(int unit_idx, int slot_idx) { return unit_idx * SLOT_COUNT + slot_idx; } + +// `ams_exist_bits`: bit `unit.idx` set for every connected ACE unit. +inline long ace_ams_exist_bits(const AceSnapshot& snap) +{ + long bits = 0; + for (const AceUnit& u : snap.units) + if (u.connected && u.idx >= 0) + bits |= (1L << u.idx); + return bits; +} + +// `tray_exist_bits`: bit `unit.idx*4 + slot.idx` set for every occupied slot. +inline long ace_tray_exist_bits(const AceSnapshot& snap) +{ + long bits = 0; + for (const AceUnit& u : snap.units) { + if (u.idx < 0) + continue; + for (const AceSlot& s : u.slots) + if (s.occupied && s.idx >= 0) + bits |= (1L << ace_tray_index(u.idx, s.idx)); + } + return bits; +} + +// "#83afff" -> "83AFFFFF" (Orca stores colour as 8-hex RRGGBBAA). Empty/invalid +// input yields an empty string so callers can fall back to a default. +inline std::string ace_color_to_rrggbbaa(const std::string& hash_rrggbb) +{ + std::string s = hash_rrggbb; + if (!s.empty() && s.front() == '#') + s.erase(s.begin()); + if (s.size() != 6) + return {}; + for (char& c : s) { + if (!std::isxdigit(static_cast(c))) + return {}; + c = static_cast(std::toupper(static_cast(c))); + } + return s + "FF"; +} + +namespace detail { + +// A "status-ish" JSON value may be a string ("ready") or an int on some +// firmware; normalise to a string. Null/absent -> empty. +inline std::string as_status_string(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || parent.at(key).is_null()) + return {}; + const nlohmann::json& v = parent.at(key); + if (v.is_string()) + return v.get(); + if (v.is_number_integer()) + return std::to_string(v.get()); + if (v.is_number_unsigned()) + return std::to_string(v.get()); + return {}; +} + +inline std::string opt_string(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || !parent.at(key).is_string()) + return {}; + return parent.at(key).get(); +} + +inline int opt_int(const nlohmann::json& parent, const char* key, int fallback) +{ + if (!parent.contains(key) || !parent.at(key).is_number_integer()) + return fallback; + return parent.at(key).get(); +} + +inline std::optional opt_number(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || !parent.at(key).is_number()) + return std::nullopt; + return parent.at(key).get(); +} + +inline std::optional opt_int_null(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || !parent.at(key).is_number_integer()) + return std::nullopt; + return parent.at(key).get(); +} + +inline bool opt_bool(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || !parent.at(key).is_boolean()) + return false; + return parent.at(key).get(); +} + +inline AceSlot parse_slot(const nlohmann::json& j, int index_fallback) +{ + AceSlot slot; + slot.idx = opt_int(j, "idx", index_fallback); + slot.state = opt_string(j, "state"); + slot.raw = opt_int(j, "raw", 0); + slot.rfid = opt_int(j, "rfid", 0); + slot.material = opt_string(j, "material"); + slot.brand = opt_string(j, "brand"); + slot.sku = opt_string(j, "sku"); + slot.subtype = opt_string(j, "subtype"); + slot.color_rrggbb = opt_string(j, "color"); + slot.source = opt_string(j, "source"); + + const bool has_raw = j.contains("raw") && j.at("raw").is_number_integer(); + slot.occupied = !slot.state.empty() && slot.state != "empty" && (!has_raw || slot.raw != 0); + return slot; +} + +inline std::optional parse_dryer_remaining(const nlohmann::json& unit) +{ + if (!unit.contains("dryer") || !unit.at("dryer").is_object()) + return std::nullopt; + const nlohmann::json& d = unit.at("dryer"); + // multiACE reports the dryer countdown under "remain_time" in SECONDS; expose it + // as whole minutes. (ACE_DRY's DURATION param, by contrast, is in minutes.) + if (auto v = opt_int_null(d, "remain_time")) + return std::optional(*v / 60); + if (auto v = opt_int_null(d, "remaining")) + return std::optional(*v / 60); + return std::nullopt; +} + +inline AceUnit parse_unit(const nlohmann::json& j, int index_fallback) +{ + AceUnit unit; + unit.idx = opt_int(j, "idx", index_fallback); + unit.connected = j.contains("connected") && j.at("connected").is_boolean() ? j.at("connected").get() : false; + unit.protocol = opt_string(j, "protocol"); + unit.status = as_status_string(j, "status"); + unit.temp = opt_number(j, "temp"); + unit.humidity = opt_int_null(j, "humidity"); + unit.dryer_remaining_minutes = parse_dryer_remaining(j); + + if (j.contains("slots") && j.at("slots").is_array()) { + int i = 0; + for (const nlohmann::json& sj : j.at("slots")) { + if (sj.is_object()) + unit.slots.emplace_back(parse_slot(sj, i)); + ++i; + } + } + return unit; +} + +inline AceToolhead parse_toolhead(const nlohmann::json& j, int index_fallback) +{ + AceToolhead th; + th.idx = opt_int(j, "idx", index_fallback); + th.ace = opt_int_null(j, "ace"); + th.slot = opt_int_null(j, "slot"); + th.material = opt_string(j, "material"); + th.color_rrggbb = opt_string(j, "color"); + th.filament_detected = opt_bool(j, "filament_detected"); + th.manual = opt_bool(j, "manual"); + th.feeder = opt_bool(j, "feeder"); + th.source = opt_string(j, "source"); + return th; +} + +} // namespace detail + +// Parse a raw multiACE `/api/state` (or `/api/aces`) document. Tolerant of +// missing/extra fields and nulls: unknown shapes degrade to empty/defaults +// rather than throwing, so a partial payload never clears good inventory. +inline AceSnapshot parse_ace_state(const nlohmann::json& j) +{ + AceSnapshot snap; + if (!j.is_object()) + return snap; + + snap.device_count = detail::opt_int(j, "device_count", 0); + snap.active_device = detail::opt_int(j, "active_device", 0); + snap.ace_head = detail::opt_int(j, "ace_head", -1); + snap.mode = detail::opt_string(j, "mode"); + snap.printer_state = detail::opt_string(j, "printer_state"); + snap.ace_status = detail::as_status_string(j, "ace_status"); + snap.ace_temp = detail::opt_number(j, "ace_temp"); + + if (j.contains("aces") && j.at("aces").is_array()) { + int i = 0; + for (const nlohmann::json& uj : j.at("aces")) { + if (uj.is_object()) + snap.units.emplace_back(detail::parse_unit(uj, i)); + ++i; + } + } + + if (j.contains("toolheads") && j.at("toolheads").is_array()) { + int i = 0; + for (const nlohmann::json& tj : j.at("toolheads")) { + if (tj.is_object()) + snap.toolheads.emplace_back(detail::parse_toolhead(tj, i)); + ++i; + } + } + + // "head_ace" maps head index -> feeding ACE unit (object with string keys, e.g. + // {"0":0,"3":0}). Fill any toolhead whose own "ace" was null so an ACE-fed head + // knows which unit feeds it (the per-head "ace"/"slot" fields are often null). + if (j.contains("head_ace") && j.at("head_ace").is_object()) { + const nlohmann::json& ha = j.at("head_ace"); + for (AceToolhead& th : snap.toolheads) { + if (th.ace.has_value()) + continue; + const std::string key = std::to_string(th.idx); + if (ha.contains(key) && ha.at(key).is_number_integer()) + th.ace = ha.at(key).get(); + } + } + + // device_count is authoritative; if absent, fall back to the array length. + if (snap.device_count == 0) + snap.device_count = static_cast(snap.units.size()); + return snap; +} + +inline AceSnapshot parse_ace_state(const std::string& text) +{ + return parse_ace_state(nlohmann::json::parse(text, nullptr, /*allow_exceptions=*/false)); +} + +}} // namespace Slic3r::AceMmu + +#endif // slic3r_AceMmuState_hpp_ diff --git a/src/libslic3r/AceMmuTopology.hpp b/src/libslic3r/AceMmuTopology.hpp new file mode 100644 index 00000000000..4245370578a --- /dev/null +++ b/src/libslic3r/AceMmuTopology.hpp @@ -0,0 +1,104 @@ +#ifndef slic3r_AceMmuTopology_hpp_ +#define slic3r_AceMmuTopology_hpp_ + +// The mapping between what the printer reports about its ACE wiring and what the printer preset +// stores about it - `ace_mode`, `ace_head_unit`, `ace_head_capacity`. +// +// Pure, and deliberately out of the GUI: the sidebar's Sync info writes these values, the sidebar's +// corner ticks diff against them, and TabPrinter's Multimaterial page reads the same thing. One +// function, so a tick cannot claim an agreement the next sync would undo. + +#include "AceMmuState.hpp" +#include "Config.hpp" +#include "PrintConfig.hpp" + +#include +#include + +namespace Slic3r { namespace AceMmu { + +// What the preset would hold if it were made to agree with the machine. +struct AceTopology +{ + AceMode mode = amNormal; + std::vector unit; // per head; -1 for a head on its own stock feeder + std::vector cap; // per head; 1 for a stock feeder +}; + +// The firmware's own three words, through the same map the preset value is stored with, so "head" +// means amHead here and in the gcode. An unknown word is Normal: no ACE claimed. +inline AceMode ace_mode_from_string(const std::string &mode) +{ + const auto &values = ConfigOptionEnum::get_enum_values(); + const auto it = values.find(mode); + return it == values.end() ? amNormal : AceMode(it->second); +} + +// How many slots a unit offers. Four unless the machine says otherwise - SLOT_COUNT is a constant +// in the protocol, but a unit that reported a shorter list is believed over the constant. +inline int ace_unit_capacity(const AceSnapshot &snap, int unit_idx) +{ + for (const AceUnit &u : snap.units) + if (u.idx == unit_idx && !u.slots.empty()) + return int(u.slots.size()); + return SLOT_COUNT; +} + +inline AceTopology ace_topology_of(const AceSnapshot &snap, size_t head_count) +{ + AceTopology topo; + topo.mode = ace_mode_from_string(snap.mode); + + for (size_t h = 0; h < head_count; ++h) { + int cap = 1, unit = -1; // no ACE reported for this head means its own feeder + for (const AceToolhead &th : snap.toolheads) { + if (size_t(th.idx) != h) + continue; + // `feeder` is the flag to trust, not `ace`. head_ace carries a default unit for every + // head, so a machine with one ACE still reports {0:0, 1:1, 2:2, 3:0} there; on the live + // U1 heads 1-3 are feeders, and reading their head_ace as wiring would invent two units + // that are not plugged in. + if (!th.feeder && th.ace.has_value()) { + unit = *th.ace; + cap = ace_unit_capacity(snap, unit); + } + break; + } + topo.cap.push_back(cap); + topo.unit.push_back(unit); + } + return topo; +} + +// Whether head `h` in the preset says what the printer says. The mode decides whether per-head +// wiring means anything at all, so it is compared first; a head on its own feeder carries no unit, +// so the unit is only compared when there is one. +inline bool ace_head_agrees(const ConfigBase &cfg, const AceTopology &topo, size_t h) +{ + const auto *ace_mode = cfg.option>("ace_mode"); + if (!ace_mode || AceMode(ace_mode->value) != topo.mode) + return false; + if (h >= topo.cap.size()) + return false; + + const auto *head_unit = cfg.option("ace_head_unit"); + const auto *head_cap = cfg.option("ace_head_capacity"); + const int was_cap = (head_cap && h < head_cap->values.size()) ? head_cap->values[h] : 1; + const int was_unit = (head_unit && h < head_unit->values.size()) ? head_unit->values[h] : -1; + return was_cap == topo.cap[h] && (topo.cap[h] <= 1 || was_unit == topo.unit[h]); +} + +// How the printer names a unit: protocol v2 is an ACE 2 Pro, v1 an ACE Pro. The same mapping +// resources/web/multiace/index.html uses, so the panel and the machine's own page agree. +inline std::string ace_unit_model(const AceUnit &unit) +{ + if (unit.protocol == "v2") + return "ACE 2 Pro"; + if (unit.protocol == "v1") + return "ACE Pro"; + return {}; +} + +}} // namespace Slic3r::AceMmu + +#endif // slic3r_AceMmuTopology_hpp_ diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 440ac33387c..aed0f6b3d69 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -247,6 +247,12 @@ void AppConfig::set_defaults() if (get("developer_mode").empty()) set_bool("developer_mode", false); + // Serve the Snapmaker U1 Device tab and print-processing popup from the + // reconstructions in resources/web/ rather than the shipped Flutter bundle. + // See docs/u1-webui/. Set false to restore the shipped pages. + if (get("u1_reconstructed_ui").empty()) + set_bool("u1_reconstructed_ui", true); + if (get("enable_ssl_for_mqtt").empty()) set_bool("enable_ssl_for_mqtt", true); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 95dfc829ebc..774f41b0f47 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -91,6 +91,14 @@ set(SLIC3R_GUI_SOURCES GUI/DailyTips.hpp GUI/DesktopIntegrationDialog.cpp GUI/DesktopIntegrationDialog.hpp + GUI/AceMmuProvider.cpp + GUI/AceMmuProvider.hpp + GUI/AceBadge.cpp + GUI/AceBadge.hpp + GUI/AceAssignPopup.cpp + GUI/AceAssignPopup.hpp + GUI/SMAccountPersist.cpp + GUI/SMAccountPersist.hpp GUI/DeviceManager.cpp GUI/DeviceManager.hpp GUI/Downloader.cpp diff --git a/src/slic3r/GUI/AceAssignPopup.cpp b/src/slic3r/GUI/AceAssignPopup.cpp new file mode 100644 index 00000000000..e10b6c1355a --- /dev/null +++ b/src/slic3r/GUI/AceAssignPopup.cpp @@ -0,0 +1,256 @@ +#include "AceAssignPopup.hpp" + +#include "AceBadge.hpp" +#include "GUI.hpp" +#include "GUI_App.hpp" +#include "I18N.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/StaticBox.hpp" +#include "libslic3r/AceMmuTopology.hpp" + +#include +#include + +namespace Slic3r { namespace GUI { + +static const wxColour ROW_HOVER = wxColour(0xF2, 0xF7, 0xF4); +static const wxColour ROW_INK = wxColour(0x4A, 0x52, 0x58); +static const wxColour ROW_DIM = wxColour(0x6B, 0x72, 0x76); +static const wxColour ORCA_GREEN = wxColour(0x00, 0xAE, 0x42); +static const wxColour TICK_OFF = wxColour(0xCE, 0xCE, 0xCE); + +// The stock feeder's mark: one spool loaded straight at the head. Deliberately not an ACE form - +// the whole point of the row is that no unit addresses it. +class FeederMark : public wxWindow +{ +public: + explicit FeederMark(wxWindow *parent, int size_dip = 24) : wxWindow(parent, wxID_ANY) + { + const int h = FromDIP(size_dip); + m_size = wxSize(h, h); // square, to sit in the same column as the S4 glyph + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetMinSize(m_size); + SetMaxSize(m_size); + Bind(wxEVT_PAINT, [this](wxPaintEvent &) { + wxAutoBufferedPaintDC dc(this); + dc.SetBackground(wxBrush(GetParent()->GetBackgroundColour())); + dc.Clear(); + const wxSize sz = GetSize(); + const int r = std::min(sz.x, sz.y) / 2 - FromDIP(3); + const wxPoint c(sz.x / 2, sz.y / 2); + dc.SetPen(wxPen(ROW_DIM, std::max(1, FromDIP(2)))); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawCircle(c, r); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(ROW_DIM)); + dc.DrawCircle(c, std::max(1, r / 3)); + }); + } + wxSize DoGetBestSize() const override { return m_size; } + +private: + wxSize m_size; +}; + +// The tick: a filled disc with a check when chosen, an empty ring when not. Same grammar as the +// mode dropdown, so one reading covers both lists. +class ChoiceTick : public wxWindow +{ +public: + ChoiceTick(wxWindow *parent, bool on) : wxWindow(parent, wxID_ANY), m_on(on) + { + const int d = FromDIP(19); + m_size = wxSize(d, d); + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetMinSize(m_size); + SetMaxSize(m_size); + Bind(wxEVT_PAINT, [this](wxPaintEvent &) { + wxAutoBufferedPaintDC dc(this); + dc.SetBackground(wxBrush(GetParent()->GetBackgroundColour())); + dc.Clear(); + const wxSize sz = GetSize(); + const int r = std::min(sz.x, sz.y) / 2 - 1; + const wxPoint c(sz.x / 2, sz.y / 2); + if (m_on) { + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(ORCA_GREEN)); + dc.DrawCircle(c, r); + wxPen pen(*wxWHITE, std::max(1, FromDIP(2))); + pen.SetCap(wxCAP_ROUND); + pen.SetJoin(wxJOIN_ROUND); + dc.SetPen(pen); + const wxPoint check[3] = {{c.x - r / 2, c.y}, {c.x - r / 6, c.y + r / 2}, {c.x + r / 2, c.y - r / 2}}; + dc.DrawLines(3, check); + } else { + dc.SetPen(wxPen(TICK_OFF, std::max(1, FromDIP(1)))); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawCircle(c, r); + } + }); + } + wxSize DoGetBestSize() const override { return m_size; } + +private: + bool m_on; + wxSize m_size; +}; + +AceAssignPopup::AceAssignPopup(wxWindow *parent, + size_t head_idx, + const std::vector &head_unit, + const std::vector &head_cap, + const AceMmu::AceSnapshot &snap, + bool snap_valid) + : PopupWindow(parent, wxBORDER_NONE), m_head_idx(head_idx) +{ + SetBackgroundColour(*wxWHITE); + + const int cur_unit = head_idx < head_unit.size() ? head_unit[head_idx] : -1; + const int cur_cap = head_idx < head_cap.size() ? head_cap[head_idx] : 1; + const bool on_feeder = cur_cap <= 1; + + wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL); + sizer->AddSpacer(FromDIP(10)); + + const wxString title = wxString::Format(_L("Which ACE feeds Toolhead %d?"), int(head_idx) + 1); + auto *question = new Label(this, Label::Head_14, title); + question->SetForegroundColour(ROW_INK); + // Label applies the font after wxStaticText has already cached a best size for the default + // one, so a heading font measures short and the sizer clips the tail - here, the head number. + // Measure it again through the label's own font and say so. + question->SetMinSize(wxSize(question->GetTextExtent(title).x + FromDIP(2), -1)); + sizer->Add(question, 0, wxLEFT | wxRIGHT, FromDIP(14)); + sizer->AddSpacer(FromDIP(8)); + + add_row(this, sizer, -1, 1, _L("Stock feeder"), _L("One spool, loaded at the head"), on_feeder, nullptr); + + // With a reading, the units the machine reports. Without one, the four `ace_head_unit` accepts: + // the preset has to be settable with the printer switched off, and inventing detail for a unit + // nobody has spoken to would be worse than offering it plainly. + std::vector unit_indices; + if (snap_valid) { + for (const AceMmu::AceUnit &u : snap.units) + if (u.idx >= 0) + unit_indices.push_back(u.idx); + } + if (unit_indices.empty()) + for (int i = 0; i < 4; ++i) + unit_indices.push_back(i); + + for (int idx : unit_indices) { + const AceMmu::AceUnit *live = snap_valid ? snap.find_unit(idx) : nullptr; + const int cap = live ? AceMmu::ace_unit_capacity(snap, idx) : AceMmu::SLOT_COUNT; + + wxString title = wxString::Format(_L("ACE %d"), idx + 1); + if (live) { + const std::string model = AceMmu::ace_unit_model(*live); + if (!model.empty()) + title += " " + wxString::FromUTF8("\xC2\xB7") + " " + wxString::FromUTF8(model); + } + + // What the row can honestly say about this unit. + wxString detail = wxString::Format(_L("%d slots"), cap); + if (live) { + detail += " " + wxString::FromUTF8("\xC2\xB7") + " " + + (live->connected ? _L("connected") : _L("not answering")); + if (live->humidity) + detail += " " + wxString::FromUTF8("\xC2\xB7") + " " + wxString::Format(_L("%d%% RH"), *live->humidity); + } else if (snap_valid) { + detail += " " + wxString::FromUTF8("\xC2\xB7") + " " + _L("not reported by the printer"); + } + + // A unit may feed more than one head; naming the others is what makes the shared capacity + // legible before the choice rather than after it. + std::vector also; + for (size_t h = 0; h < head_unit.size(); ++h) + if (h != head_idx && head_unit[h] == idx && h < head_cap.size() && head_cap[h] > 1) + also.push_back(int(h) + 1); + for (size_t i = 0; i < also.size(); ++i) + detail += (i == 0 ? "\n" + _L("Also feeds") + " " : ", ") + wxString::Format(_L("Toolhead %d"), also[i]); + + add_row(this, sizer, idx, cap, title, detail, !on_feeder && cur_unit == idx, live); + } + + sizer->AddSpacer(FromDIP(10)); + SetSizerAndFit(sizer); +} + +void AceAssignPopup::add_row(wxWindow *parent, wxSizer *sizer, int unit, int cap, const wxString &title, + const wxString &detail, bool ticked, const AceMmu::AceUnit *live) +{ + auto *row = new wxPanel(parent, wxID_ANY); + row->SetBackgroundColour(ticked ? ROW_HOVER : parent->GetBackgroundColour()); + row->SetCursor(wxCURSOR_HAND); + + wxWindow *mark = nullptr; + if (unit < 0) { + mark = new FeederMark(row); + } else { + // S4, the square front face - doc 16 gives the wide filled badge to a head box and the + // square line form to a menu, which is what this list is. It carries no slot colours by + // design: the row is choosing a unit, and what is loaded in it is the detail line's job. + auto *badge = new AceBadge(row, 24, AceBadge::Form::SquareFace); + badge->SetInk(ROW_INK); + if (live) + badge->SetUnit(*live); + else + badge->SetUnknown(); + mark = badge; + } + + auto *name = new Label(row, Label::Body_13, title, LB_PROPAGATE_MOUSE_EVENT); + name->SetForegroundColour(ROW_INK); + auto *sub = new Label(row, Label::Body_10, detail, LB_PROPAGATE_MOUSE_EVENT); + sub->SetForegroundColour(ROW_DIM); + + auto *tick = new ChoiceTick(row, ticked); + + wxBoxSizer *text = new wxBoxSizer(wxVERTICAL); + text->Add(name, 0, wxEXPAND); + text->Add(sub, 0, wxEXPAND | wxTOP, FromDIP(1)); + + wxBoxSizer *hs = new wxBoxSizer(wxHORIZONTAL); + hs->Add(mark, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(14)); + hs->Add(text, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); + hs->Add(tick, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(12)); + row->SetSizer(hs); + + // The whole row is the target, and every child that could swallow a click forwards it: a + // 19px tick is not a hit box, and the badge is the most obvious thing to aim at. + const auto choose = [this, unit, cap](wxMouseEvent &) { + auto cb = m_choice; + Dismiss(); + if (cb) + cb(unit, cap); + }; + row->Bind(wxEVT_LEFT_UP, choose); + mark->Bind(wxEVT_LEFT_UP, choose); + tick->Bind(wxEVT_LEFT_UP, choose); + + const wxColour base = ticked ? ROW_HOVER : parent->GetBackgroundColour(); + for (wxWindow *w : {static_cast(row), mark, static_cast(tick)}) { + w->Bind(wxEVT_ENTER_WINDOW, [row, base](wxMouseEvent &e) { e.Skip(); row->SetBackgroundColour(ROW_HOVER); row->Refresh(); }); + w->Bind(wxEVT_LEAVE_WINDOW, [row, base](wxMouseEvent &e) { e.Skip(); row->SetBackgroundColour(base); row->Refresh(); }); + } + + sizer->Add(row, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(1)); +} + +void AceAssignPopup::OnDismiss() { CallAfter([this]() { Destroy(); }); } + +void AceAssignPopup::popup_at(wxWindow *anchor) +{ + // Below the control that opened it, left edges aligned, pulled back onto the screen if the + // sidebar sits near the bottom. + wxPoint pos = anchor->ClientToScreen(wxPoint(0, anchor->GetSize().y + 2)); + const wxSize sz = GetSize(); + const wxRect area = wxDisplay(wxDisplay::GetFromWindow(anchor) == wxNOT_FOUND ? 0 : wxDisplay::GetFromWindow(anchor)).GetClientArea(); + if (pos.y + sz.y > area.GetBottom()) + pos.y = std::max(area.GetTop(), anchor->ClientToScreen(wxPoint(0, 0)).y - sz.y - 2); + if (pos.x + sz.x > area.GetRight()) + pos.x = std::max(area.GetLeft(), area.GetRight() - sz.x); + Position(pos, wxSize(0, 0)); + Popup(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AceAssignPopup.hpp b/src/slic3r/GUI/AceAssignPopup.hpp new file mode 100644 index 00000000000..0aa31a2496f --- /dev/null +++ b/src/slic3r/GUI/AceAssignPopup.hpp @@ -0,0 +1,58 @@ +#ifndef slic3r_GUI_AceAssignPopup_hpp_ +#define slic3r_GUI_AceAssignPopup_hpp_ + +// Which ACE feeds one toolhead. +// +// A choice, not a count. The firmware offers exactly two macros for this - +// +// ACE_SET_HEAD_FEEDER HEAD=0..3 ENABLE=0|1 (head mode only) +// ACE_SET_HEAD_ACE HEAD=0..3 ACE=0..3 "wired to exactly one ACE" +// +// - so the list has exactly two kinds of row and a tick, rather than the slot spinner the +// Multimaterial page offers. Between them the rows write exactly `ace_head_unit` and +// `ace_head_capacity`. See docs/ace-mmu/15-printer-panel.md. +// +// One unit may feed several heads: ACE_SET_HEAD_ACE binds a head to a unit and says nothing about +// the reverse. Ticking a unit already feeding another head is therefore legal, the row says so, +// and the panel states what the shared capacity really is. + +#include "Widgets/PopupWindow.hpp" +#include "libslic3r/AceMmuState.hpp" + +#include +#include + +namespace Slic3r { namespace GUI { + +class AceAssignPopup : public PopupWindow +{ +public: + // `snap_valid` is false until Sync info has read the machine. The popover still works then - + // the preset is what lets slicing run with the printer switched off - but the rows carry no + // live detail and the list falls back to the four units `ace_head_unit` accepts. + AceAssignPopup(wxWindow *parent, + size_t head_idx, + const std::vector &head_unit, + const std::vector &head_cap, + const AceMmu::AceSnapshot &snap, + bool snap_valid); + + // (unit, capacity) as the preset stores them: unit -1 with capacity 1 is the stock feeder. + void on_choice(std::function cb) { m_choice = std::move(cb); } + + void popup_at(wxWindow *anchor); + + // wxPopupTransientWindow does not own itself; the picker in filamentsync/ sets the precedent. + void OnDismiss() override; + +private: + void add_row(wxWindow *parent, wxSizer *sizer, int unit, int cap, const wxString &title, + const wxString &detail, bool ticked, const AceMmu::AceUnit *live); + + std::function m_choice; + size_t m_head_idx = 0; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_AceAssignPopup_hpp_ diff --git a/src/slic3r/GUI/AceBadge.cpp b/src/slic3r/GUI/AceBadge.cpp new file mode 100644 index 00000000000..e672b56e3e7 --- /dev/null +++ b/src/slic3r/GUI/AceBadge.cpp @@ -0,0 +1,141 @@ +#include "AceBadge.hpp" + +#include +#include + +namespace Slic3r { namespace GUI { + +// The neutrals are Orca's own AMS roles, so the badge sits in the same palette as every other +// filament surface rather than introducing a second one (docs/ace-mmu/16-ace-visuals.md). +static const wxColour ACE_HOOD = wxColour(0xE8, 0xE8, 0xE8); +static const wxColour ACE_BASE = wxColour(0xCF, 0xCF, 0xCF); +static const wxColour ACE_EMPTY = wxColour(0xFF, 0xFF, 0xFF); +static const wxColour ACE_DISABLE = wxColour(0xCE, 0xCE, 0xCE); // AMS_CONTROL_DISABLE_COLOUR +static const wxColour ACE_BLOCK = wxColour(0xEE, 0xEE, 0xEE); // AMS_CONTROL_DEF_BLOCK_BK_COLOUR + +// "#83AFFF" -> wxColour. Anything else is no colour at all, which draws as an empty bay. +static std::optional parse_hash_rgb(const std::string &s) +{ + if (s.size() != 7 || s.front() != '#') + return std::nullopt; + wxColour c(wxString::FromUTF8(s)); + if (!c.IsOk()) + return std::nullopt; + return c; +} + +static const wxColour ACE_INK = wxColour(0x6B, 0x6B, 0x6B); // the outlined forms' default stroke + +AceBadge::AceBadge(wxWindow *parent, int height_dip, Form form) + : wxWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize), m_form(form), m_ink(ACE_INK) +{ + const int h = FromDIP(height_dip); + const double box = m_form == Form::SquareFace ? 24.0 : 26.0; + m_scale = double(h) / box; + m_size = m_form == Form::SquareFace ? wxSize(h, h) + : wxSize(int(std::lround(44.0 * m_scale)), h); + + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetMinSize(m_size); + SetMaxSize(m_size); + SetSize(m_size); + Bind(wxEVT_PAINT, &AceBadge::paintEvent, this); +} + +void AceBadge::SetInk(const wxColour &ink) +{ + m_ink = ink; + Refresh(); +} + +void AceBadge::SetSlots(const std::vector &colors_rrggbb) +{ + m_unknown = false; + for (size_t i = 0; i < m_bays.size(); ++i) + m_bays[i] = i < colors_rrggbb.size() ? parse_hash_rgb(colors_rrggbb[i]) : std::nullopt; + Refresh(); +} + +void AceBadge::SetUnit(const AceMmu::AceUnit &unit) +{ + m_unknown = false; + m_bays.fill(std::nullopt); + for (const AceMmu::AceSlot &s : unit.slots) { + if (s.idx < 0 || size_t(s.idx) >= m_bays.size()) + continue; + // An unoccupied slot is empty whatever colour it last carried. + m_bays[s.idx] = s.occupied ? parse_hash_rgb(s.color_rrggbb) : std::nullopt; + } + Refresh(); +} + +void AceBadge::SetUnknown() +{ + m_unknown = true; + m_bays.fill(std::nullopt); + Refresh(); +} + +void AceBadge::render_cabinet(wxDC &dc) +{ + const double z = m_scale; + const auto S = [z](double v) { return int(std::lround(v * z)); }; + + dc.SetPen(*wxTRANSPARENT_PEN); + + // The hood: x 2..40, top corners rounded at 7. Its bottom corners are rounded too and then + // covered by the base, which is what gives the cabinet its stepped silhouette. + dc.SetBrush(wxBrush(m_unknown ? ACE_BLOCK : ACE_HOOD)); + dc.DrawRoundedRectangle(S(2), S(2), S(38), S(24), S(7)); + + // Four bays, 5x14 capsules at x 6/15/24/33 - padding 4 equals gap 4. + for (int i = 0; i < AceMmu::SLOT_COUNT; ++i) { + const wxColour fill = m_unknown ? ACE_DISABLE : (m_bays[i] ? *m_bays[i] : ACE_EMPTY); + dc.SetBrush(wxBrush(fill)); + dc.DrawRoundedRectangle(S(6 + i * 9), S(4.5), S(5), S(14), S(2.5)); + } + + // The base, drawn over the bays and slightly wider than the hood: that width difference is + // what makes the shape read as a cabinet rather than a bar chart. + dc.SetBrush(wxBrush(m_unknown ? ACE_DISABLE : ACE_BASE)); + dc.DrawRoundedRectangle(0, S(16), S(44), S(10), S(1.5)); +} + +// S4 - the front face alone: one outlined body and four solid bays, no hood and no base step. +// At 16-24px the hood is what disappears first, so dropping it is what keeps this legible where +// the wide drawing would not be. +void AceBadge::render_square(wxDC &dc) +{ + const double z = m_scale; + const auto S = [z](double v) { return int(std::lround(v * z)); }; + const wxColour ink = m_unknown ? ACE_DISABLE : m_ink; + + // The chassis is outlined; the stroke is 1.6 in the drawing's own units. + const int stroke = std::max(1, int(std::lround(1.6 * z))); + dc.SetPen(wxPen(ink, stroke)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRoundedRectangle(S(1.8), S(4.4), S(20.4), S(15.2), S(3.2)); + + // Bays solid, in the same ink: the family is carried by the bay treatment, not the outline. + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(ink)); + for (int i = 0; i < AceMmu::SLOT_COUNT; ++i) + dc.DrawRoundedRectangle(S(4.2 + i * 4.2), S(7.4), S(3.2), S(9.2), S(1.6)); +} + +void AceBadge::paintEvent(wxPaintEvent &) +{ + wxAutoBufferedPaintDC dc(this); + dc.SetBackground(wxBrush(GetParent() ? GetParent()->GetBackgroundColour() : *wxWHITE)); + dc.Clear(); +#ifdef __WXMSW__ + // GTK's wxDC is already Cairo-backed; MSW's is not, and a 2.5px capsule radius without + // antialiasing reads as a rectangle. + wxGCDC gdc(dc); + m_form == Form::SquareFace ? render_square(gdc) : render_cabinet(gdc); +#else + m_form == Form::SquareFace ? render_square(dc) : render_cabinet(dc); +#endif +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AceBadge.hpp b/src/slic3r/GUI/AceBadge.hpp new file mode 100644 index 00000000000..4b81bfcad8d --- /dev/null +++ b/src/slic3r/GUI/AceBadge.hpp @@ -0,0 +1,69 @@ +#ifndef slic3r_GUI_AceBadge_hpp_ +#define slic3r_GUI_AceBadge_hpp_ + +// The ACE unit, drawn. docs/ace-mmu/16-ace-visuals.md settles one treatment - outlined chassis, +// solid bays - in three proportions; this is the filled badge, form A, at 44x26. +// +// Drawn rather than shipped as an icon. It has to carry four live slot colours, so an asset would +// need one file per colour combination; and icon SVGs go through nanosvg (BitmapCache.cpp), which +// has no patterns and could not carry them anyway. +// +// Four bays is not a variable: /api/state returns exactly four slots and SLOT_COUNT is a constant. +// There is no single-bay form - an AMS Lite has one spool, an ACE never does. + +#include "libslic3r/AceMmuState.hpp" + +#include + +#include +#include +#include + +namespace Slic3r { namespace GUI { + +class AceBadge : public wxWindow +{ +public: + // The three forms doc 16 settles, under one rule: outlined chassis, solid bays. + enum class Form { + Cabinet, // A - 44x26 filled. A head box's ACE row; carries the slot colours. + SquareFace, // S4 - 24x24 line. A menu row, a tab, a ScalableButton. Deliberately NOT the + // same silhouette: it has a third of the width to say the same thing in, so + // the family is carried by the bay treatment and the stroke, not the hood. + }; + + // The nominal drawing is 44x26 for Cabinet and 24x24 for SquareFace; `height_dip` scales + // both axes of whichever form together. + explicit AceBadge(wxWindow *parent, int height_dip = 26, Form form = Form::Cabinet); + + // SquareFace draws in one ink rather than in slot colours; this is that ink. + void SetInk(const wxColour &ink); + + // Bay colours as "#rrggbb", empty for an empty bay. Fewer than four leaves the rest empty. + void SetSlots(const std::vector &colors_rrggbb); + + // Every bay from a unit the panel has read. Convenience over SetSlots. + void SetUnit(const AceMmu::AceUnit &unit); + + // A unit the preset names but the panel has not read, or one configured and not answering: + // drawn in the disabled greys rather than as four empty bays, because empty is a claim. + void SetUnknown(); + + wxSize DoGetBestSize() const override { return m_size; } + +private: + void render_cabinet(wxDC &dc); + void render_square(wxDC &dc); + void paintEvent(wxPaintEvent &evt); + + std::array, AceMmu::SLOT_COUNT> m_bays; + bool m_unknown = true; + Form m_form = Form::Cabinet; + wxColour m_ink; + wxSize m_size; + double m_scale = 1.0; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_AceBadge_hpp_ diff --git a/src/slic3r/GUI/AceMmuProvider.cpp b/src/slic3r/GUI/AceMmuProvider.cpp new file mode 100644 index 00000000000..e6d20683224 --- /dev/null +++ b/src/slic3r/GUI/AceMmuProvider.cpp @@ -0,0 +1,173 @@ +#include "AceMmuProvider.hpp" + +#include "slic3r/Utils/Http.hpp" +#include "slic3r/Utils/PrintHost.hpp" +#include "GUI_App.hpp" +#include "DeviceManager.hpp" +#include "libslic3r/PresetBundle.hpp" + +#include "nlohmann/json.hpp" +#include +#include + +#include +#include +#include + +namespace Slic3r { namespace GUI { + +// "http://192.168.2.242:7125/" / "192.168.2.242:7125" -> "192.168.2.242". +static std::string host_to_ip(std::string h) +{ + const auto scheme = h.find("://"); + if (scheme != std::string::npos) + h = h.substr(scheme + 3); + h = h.substr(0, h.find('/')); // strip any path + h = h.substr(0, h.find(':')); // strip any port + while (!h.empty() && (h.back() == ' ' || h.back() == '\t')) + h.pop_back(); + return h; +} + +std::string AceMmuProvider::resolve_connected_host() +{ + // 1) Selected MachineObject's IP (Bambu-style path). + if (auto* dev = wxGetApp().getDeviceManager()) { + if (MachineObject* obj = dev->get_selected_machine()) + if (!obj->dev_ip.empty()) { + BOOST_LOG_TRIVIAL(info) << "AceMmuProvider::resolve_connected_host: dev_ip=" << obj->dev_ip; + return obj->dev_ip; + } + } + + // 2) The connected PrintHost (set by SSWCP when the U1 connects). + std::shared_ptr host; + wxGetApp().get_connect_host(host); + if (host) { + const std::string ip = host_to_ip(host->get_host()); + if (!ip.empty()) { + BOOST_LOG_TRIVIAL(info) << "AceMmuProvider::resolve_connected_host: connect_host=" << ip; + return ip; + } + } + + // 3) The host config SSWCP stores on connect (this is where print_host actually + // lands for the U1 — it is set on a copy, not the edited preset). + if (DynamicPrintConfig* hc = wxGetApp().get_host_config()) { + if (hc->has("print_host")) { + const std::string ip = host_to_ip(hc->opt_string("print_host")); + if (!ip.empty()) { + BOOST_LOG_TRIVIAL(info) << "AceMmuProvider::resolve_connected_host: host_config print_host=" << ip; + return ip; + } + } + } + + // 4) Last resort: the edited printer preset's print_host, if any. + if (wxGetApp().preset_bundle) { + const auto& cfg = wxGetApp().preset_bundle->printers.get_edited_preset().config; + if (cfg.has("print_host")) { + const std::string ip = host_to_ip(cfg.opt_string("print_host")); + if (!ip.empty()) { + BOOST_LOG_TRIVIAL(info) << "AceMmuProvider::resolve_connected_host: preset print_host=" << ip; + return ip; + } + } + } + + BOOST_LOG_TRIVIAL(warning) << "AceMmuProvider::resolve_connected_host: no host found (dev_ip/connect_host/print_host all empty)"; + return {}; +} + +std::string AceMmuProvider::resolve_generic_filament_id(const std::string& material) +{ + if (material.empty() || !wxGetApp().preset_bundle) + return {}; + // Mirror PresetBundle::sync_ams_list's own fallback: a compatible, system + // "Generic " preset. Returning its filament_id lets the direct match + // in sync_ams_list succeed, so the spool isn't counted as "unknown". + const std::string want = "Generic " + material; + const auto& filaments = wxGetApp().preset_bundle->filaments; + for (auto it = filaments.begin(); it != filaments.end(); ++it) { + if (it->is_compatible && it->is_system && boost::algorithm::starts_with(it->name, want)) + return it->filament_id; + } + return {}; +} + +AceMmuProvider::AceMmuProvider(std::string host, int poll_interval_s) + : m_host(std::move(host)), m_base_url("http://" + m_host + "/multiace"), m_poll_interval_s(poll_interval_s < 1 ? 1 : poll_interval_s) +{} + +AceMmuProvider::~AceMmuProvider() { stop(); } + +void AceMmuProvider::start() +{ + if (m_running.exchange(true)) + return; // already running + m_worker = std::thread(&AceMmuProvider::run, this); +} + +void AceMmuProvider::stop() +{ + if (!m_running.exchange(false)) + return; // already stopped + m_wait_cv.notify_all(); + if (m_worker.joinable()) + m_worker.join(); +} + +AceMmu::AceSnapshot AceMmuProvider::snapshot() const +{ + std::lock_guard lock(m_mutex); + return m_snapshot; +} + +bool AceMmuProvider::fetch_once(int timeout_connect_s, int timeout_max_s) +{ + const std::string url = m_base_url + "/api/state"; + + bool well_formed = false; + AceMmu::AceSnapshot parsed; + + Http::get(url) + .timeout_connect(timeout_connect_s) + .timeout_max(timeout_max_s) + .on_error([&](std::string /*body*/, std::string error, unsigned status) { + BOOST_LOG_TRIVIAL(warning) << "AceMmuProvider: GET " << url << " failed: " << error << " (status " << status << ")"; + }) + .on_complete([&](std::string body, unsigned /*status*/) { + const nlohmann::json doc = nlohmann::json::parse(body, nullptr, /*allow_exceptions=*/false); + if (!doc.is_object()) { + BOOST_LOG_TRIVIAL(warning) << "AceMmuProvider: " << url << " returned a non-object body; keeping last good snapshot"; + return; + } + parsed = AceMmu::parse_ace_state(doc); + well_formed = true; + }) + .perform_sync(); + + if (!well_formed) + return false; // transient/failed/garbage: keep last good + + const int device_count = parsed.device_count; + { + std::lock_guard lock(m_mutex); + m_snapshot = std::move(parsed); + } + const uint64_t rev = m_revision.fetch_add(1) + 1; + BOOST_LOG_TRIVIAL(debug) << "AceMmuProvider: refreshed; device_count=" << device_count << " rev=" << rev; + return true; +} + +void AceMmuProvider::run() +{ + while (m_running.load()) { + fetch_once(); + + std::unique_lock lock(m_wait_mutex); + m_wait_cv.wait_for(lock, std::chrono::seconds(m_poll_interval_s), [this] { return !m_running.load(); }); + } +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AceMmuProvider.hpp b/src/slic3r/GUI/AceMmuProvider.hpp new file mode 100644 index 00000000000..3d7d1ad215a --- /dev/null +++ b/src/slic3r/GUI/AceMmuProvider.hpp @@ -0,0 +1,88 @@ +#ifndef slic3r_AceMmuProvider_hpp_ +#define slic3r_AceMmuProvider_hpp_ + +// Slicer-side provider that polls a Snapmaker U1's printer-side multiACE service +// (REST `/multiace/api/state`) and keeps a fresh AceSnapshot. +// +// Phase 1 (this file): connect, poll on a worker thread, and cache the latest +// good snapshot. Projecting the snapshot onto MachineObject::amsList and the +// GUI-thread refresh signalling come in Phase 2 (see docs/ace-mmu/). + +#include "libslic3r/AceMmuState.hpp" + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { + +class AceMmuProvider +{ +public: + // `host` is the printer IP/hostname, e.g. "192.168.2.242"; the base URL is + // built as http:///multiace. Plain HTTP mirrors multiACE's own + // post-processor and works unauthenticated on stock installs + // (see docs/ace-mmu/02-multiace-printer-api.md §2.1). + explicit AceMmuProvider(std::string host, int poll_interval_s = 3); + ~AceMmuProvider(); + + AceMmuProvider(const AceMmuProvider&) = delete; + AceMmuProvider& operator=(const AceMmuProvider&) = delete; + + // Start/stop the background poll worker. Both are idempotent; stop() joins. + void start(); + void stop(); + bool is_running() const { return m_running.load(); } + + // Thread-safe copy of the most recent good snapshot. + AceMmu::AceSnapshot snapshot() const; + + // Monotonic counter, bumped on every successful fetch; lets callers detect a + // refresh without comparing whole snapshots. + uint64_t revision() const { return m_revision.load(); } + + // Fetch and parse `/api/state` once, synchronously (this is what the worker + // calls each tick). Returns true and replaces the cached snapshot only on a + // well-formed response; a transient/failed/garbage read keeps the last good + // snapshot (docs 04 §4.7). + // The timeouts are the background poller's. Callers blocking the GUI on this - + // AcePlanDialog reads the ACE as it opens - should pass shorter ones: a printer + // that is configured but switched off would otherwise freeze the dialog. + bool fetch_once(int timeout_connect_s = 4, int timeout_max_s = 8); + + const std::string& base_url() const { return m_base_url; } + + // Best-effort IP/host of the currently-connected printer: the selected + // MachineObject's dev_ip if set, else parsed from the connected PrintHost + // (the U1 connects as a PrintHost via the webview, not a MachineObject). + // Empty if nothing is connected. + static std::string resolve_connected_host(); + + // filament_id of a compatible system "Generic " preset (e.g. PETG -> + // the Generic PETG preset's id), or empty if none. Used so ACE spools resolve to + // a real preset and PresetBundle::sync_ams_list doesn't flag them as "unknown". + static std::string resolve_generic_filament_id(const std::string& material); + +private: + void run(); + + std::string m_host; + std::string m_base_url; + int m_poll_interval_s; + + mutable std::mutex m_mutex; // guards m_snapshot + AceMmu::AceSnapshot m_snapshot; // last good + std::atomic m_revision{0}; + + std::thread m_worker; + std::atomic m_running{false}; + std::mutex m_wait_mutex; // pairs with m_wait_cv for the poll sleep + std::condition_variable m_wait_cv; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_AceMmuProvider_hpp_ diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 030eaadfe01..7b13214b241 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -9,6 +9,7 @@ #include "format.hpp" #include "common_func/common_func.hpp" #include "Downloader.hpp" +#include "SMAccountPersist.hpp" #include "slic3r/GUI/WebUrlDialog.hpp" #include "slic3r/GUI/WebPresetDialog.hpp" @@ -1003,6 +1004,11 @@ void GUI_App::post_init() m_open_method = "double_click"; bool switch_to_3d = false; + // The account was restored in init_app_config(), before the webview existed - so the notify() + // that went with it reached no subscribers. Say it again now the page is listening, or it + // keeps offering Login/Register over a live session. + sm_announce_login(); + if (!this->init_params->input_files.empty()) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", init with input files, size %1%, input_gcode %2%") @@ -2234,6 +2240,12 @@ void GUI_App::init_app_config() } // Save orig_version here, so its empty if no app_config existed before this run. m_last_config_version = app_config->orig_version();//parse_semver_from_ini(app_config->config_path()); + + // Restore the saved Snapmaker account here rather than in post_init(): the webview asks + // for the login state once, early, and post_init() runs after that. Restoring later left + // the account live in C++ while the home page still offered Login/Register - seen on a + // real restore, and the placement PR #715 arrived at independently. + sm_restore_login(); } else { #ifdef _WIN32 @@ -3912,7 +3924,7 @@ void GUI_App::recreate_GUI(const wxString &msg_name) if (!preset_bundle->is_bbl_vendor()) { if (is_snapmaker_u1) { - wxString url = wxString::FromUTF8(LOCALHOST_URL + std::to_string(get_page_http_port()) + "/web/flutter_web/index.html?path=2"); + wxString url = get_u1_surface_url(U1Surface::DeviceTab); auto real_url = wxGetApp().get_international_url(url); mainframe->load_printer_url(real_url); } else { @@ -4299,6 +4311,9 @@ void GUI_App::sm_request_user_logout() } catch (std::exception&) { ; } + // Forget the persisted session, after the revoke above has used the token. + m_login_userinfo.clear(); + sm_persist_login(); } //BBS @@ -4355,6 +4370,52 @@ wxString GUI_App::get_international_url(const wxString& origin_url) { } +// ---- Snapmaker U1 embedded web surfaces ------------------------------------ +// +// One place decides whether the Device tab and the print-processing popup are +// served by the shipped Flutter bundle or by the reconstructions in +// resources/web/. See docs/u1-webui/ for what the reconstructions cover, and +// note they are deliberately narrower than the bundle - flipping +// "u1_reconstructed_ui" back to false restores the shipped pages. +wxString GUI_App::get_u1_surface_url(U1Surface surface) const +{ + const std::string port = std::to_string(get_page_http_port()); + const bool reconstructed = app_config->get_bool("u1_reconstructed_ui"); + + std::string path; + if (reconstructed) { + switch (surface) { + case U1Surface::DeviceTab: path = "/web/device_page/index.html"; break; + case U1Surface::PrintAndUpload: path = "/web/print_processing/index.html?mode=print"; break; + case U1Surface::UploadOnly: path = "/web/print_processing/index.html?mode=upload"; break; + } + } else { + switch (surface) { + case U1Surface::DeviceTab: path = "/web/flutter_web/index.html?path=2"; break; + case U1Surface::PrintAndUpload: path = "/web/flutter_web/index.html?path=4"; break; + case U1Surface::UploadOnly: path = "/web/flutter_web/index.html?path=5"; break; + } + } + return wxString::FromUTF8(LOCALHOST_URL + port + path); +} + +bool GUI_App::is_u1_device_tab_url(const wxString& url) +{ + // The Flutter bundle distinguishes its surfaces by ?path=; the reconstruction + // uses a directory per surface. Accept either, so callers do not have to know + // which implementation is active. + if (url.find("device_page/index.html") != wxString::npos) + return true; + return url.find("flutter_web") != wxString::npos && url.find("path=2") != wxString::npos; +} + +bool GUI_App::is_u1_surface_url(const wxString& url) +{ + return url.find("flutter_web") != wxString::npos + || url.find("device_page/index.html") != wxString::npos + || url.find("print_processing/index.html") != wxString::npos; +} + bool GUI_App::is_user_login() { if (m_agent) { diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index edaf1b11101..5988bffe185 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -542,6 +542,29 @@ class GUI_App : public wxApp bool is_user_login(); wxString get_international_url(const wxString& origin_url); + + // ---- Snapmaker U1 embedded web surfaces -------------------------------- + // + // The Device tab and the print-processing popup are pages served from the + // loopback HTTP server. Two implementations exist: the shipped Flutter + // bundle (resources/web/flutter_web) and the reconstructions documented in + // docs/u1-webui (resources/web/device_page, resources/web/print_processing). + // + // Which one loads is decided in ONE place - get_u1_surface_url() - keyed on + // the "u1_reconstructed_ui" app config flag. Build the URLs through it + // rather than by hand, and test them with the helpers below, so the two + // implementations cannot drift apart. + enum class U1Surface { + DeviceTab, // Orca's Device tab + PrintAndUpload, // the popup, uploading and then printing + UploadOnly, // the popup, uploading without printing + }; + wxString get_u1_surface_url(U1Surface surface) const; + + // True for a URL that shows the Device tab, under either implementation. + static bool is_u1_device_tab_url(const wxString& url); + // True for any of the U1 embedded surfaces, under either implementation. + static bool is_u1_surface_url(const wxString& url); wxString flutter_web_base_url(const wxString& path); wxString build_flutter_web_url(const wxString& path); diff --git a/src/slic3r/GUI/HttpServer.cpp b/src/slic3r/GUI/HttpServer.cpp index 187404a2de0..dbdcce347f4 100644 --- a/src/slic3r/GUI/HttpServer.cpp +++ b/src/slic3r/GUI/HttpServer.cpp @@ -102,16 +102,41 @@ void session::stop() socket.close(ignored_ec); } +/* + * One header line, read on '\n' and not on '\r'. + * + * This used to read until '\r' and then consume the '\n' with a second getline. That + * desynchronises by one byte whenever a read stops with a '\r' as the last byte in the + * buffer: the '\n' is not there yet, the second getline consumes nothing, and every line + * after it arrives with a leading '\n'. `line.length() == 0` - which is how the end of + * the headers is recognised - then never matches, so the session waits for a delimiter + * that will never come and the request is never answered. + * + * asio reads in 512-byte chunks, so the boundary is real rather than theoretical: of the + * 515 places a 516-byte WebKit request can be split, exactly two hang the server, and one + * of them is byte 513. Loading the reconstructed Device page - ~48 ES modules over ~6 + * pooled connections - hit it about once per load, which left the page with its shell + * drawn, `.content` empty, `readyState` stuck at "interactive", and nothing in any log. + * + * '\n' is the line terminator that cannot be orphaned: a split between the CR and the LF + * now leaves the CR at the end of the line, where it is stripped. + */ +static inline void strip_cr(std::string& line) +{ + if (!line.empty() && line.back() == '\r') + line.pop_back(); +} + void session::read_first_line() { auto self(shared_from_this()); - async_read_until(socket, buff, '\r', [this, self](const boost::beast::error_code& e, std::size_t s) { + async_read_until(socket, buff, '\n', [this, self](const boost::beast::error_code& e, std::size_t s) { if (!e) { - std::string line, ignore; + std::string line; std::istream stream{&buff}; - std::getline(stream, line, '\r'); - std::getline(stream, ignore, '\n'); + std::getline(stream, line, '\n'); + strip_cr(line); headers.on_read_request_line(line); read_next_line(); } else if (e != boost::asio::error::operation_aborted) { @@ -141,6 +166,9 @@ void session::read_next_line() ssOut << "Access-Control-Allow-Origin: *\r\n"; // 允许所有源 ssOut << "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"; // 允许的方法 ssOut << "Access-Control-Allow-Headers: Content-Type, Authorization\r\n"; // 允许的请求头 + // This server answers one request per connection and closes the socket in the + // write handler below, so it has to SAY so - see ResponseFile::write_response. + ssOut << "Connection: close\r\n"; ssOut << "Content-Length: 0\r\n"; // 无响应体 ssOut << "\r\n"; // 头和主体之间的空行(必须) @@ -152,14 +180,16 @@ void session::read_next_line() return; // 提前返回,避免后续逻辑 } - async_read_until(socket, buff, '\r', [this, self](const boost::beast::error_code& e, std::size_t s) { + async_read_until(socket, buff, '\n', [this, self](const boost::beast::error_code& e, std::size_t s) { if (!e) { - std::string line, ignore; + std::string line; std::istream stream{&buff}; - std::getline(stream, line, '\r'); - std::getline(stream, ignore, '\n'); + std::getline(stream, line, '\n'); + strip_cr(line); headers.on_read_header(line); + // The empty line is the end of the headers, and it is only ever empty when + // the CR/LF pair is consumed as one - see read_first_line(). if (line.length() == 0) { if (headers.content_length() == 0) { std::cout << "Request received: " << headers.method << " " << headers.get_url(); @@ -802,6 +832,7 @@ void HttpServer::ResponseRedirect::write_response(std::stringstream& ssOut) size_t content_length = sHTML.size(); // 字节长度(与字符数相同,因无多字节字符) ssOut << "HTTP/1.1 302 Found\r\n"; + ssOut << "Connection: close\r\n"; // one request per connection - see ResponseFile ssOut << "Location: " << location_str << "\r\n"; ssOut << "Content-Type: text/html\r\n"; ssOut << "Content-Length: " << content_length << "\r\n"; // 正确计算长度 @@ -816,6 +847,7 @@ void HttpServer::ResponseNotFound::write_response(std::stringstream& ssOut) size_t content_length = sHTML.size(); // 字节长度 ssOut << "HTTP/1.1 404 Not Found\r\n"; + ssOut << "Connection: close\r\n"; // one request per connection - see ResponseFile ssOut << "Content-Type: text/html\r\n"; ssOut << "Content-Length: " << content_length << "\r\n"; // 正确计算长度 ssOut << "Access-Control-Allow-Origin: *\r\n"; // CORS头 @@ -876,6 +908,22 @@ void HttpServer::ResponseFile::write_response(std::stringstream& ssOut) // 构造响应头(严格使用\r\n,头结束后空行) ssOut << "HTTP/1.1 200 OK\r\n"; + /* + * One request per connection, and the response has to say so. + * + * session::read_next_line() writes this and then calls server.stop(self), which + * shuts the socket down - but HTTP/1.1 is persistent BY DEFAULT, so a reply with no + * `Connection` header tells the client the socket may be reused. WebKit believes it, + * keeps the socket in its pool, and sends a later request into a socket this server + * has already closed. That request is never answered and never retried. + * + * It cost a blank Device page: the reconstruction loads ~48 ES modules, WebKit + * fetches them over ~6 pooled connections, and one module request per page load was + * lost this way - `readyState` stuck at "interactive", `.content` empty, no console + * error, nothing in any log. The Flutter bundle survives it because it is one big + * script rather than a module graph, so a lost request is a lost image at worst. + */ + ssOut << "Connection: close\r\n"; ssOut << "Content-Type: " << content_type << "\r\n"; ssOut << "Content-Length: " << content_length << "\r\n"; // 必须与实际内容长度一致 ssOut << "Access-Control-Allow-Origin: *\r\n"; // CORS头 diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 036be9e2e07..47c02dae6c5 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -191,6 +191,10 @@ #include "StepMeshDialog.hpp" #include "CloneDialog.hpp" #include "WebPreprintDialog.hpp" +#include "AceMmuProvider.hpp" +#include "AceAssignPopup.hpp" +#include "AceBadge.hpp" +#include "libslic3r/AceMmuTopology.hpp" #include "filamentsync/SyncConfirmDialog.hpp" #include "filamentsync/SyncFilamentColorDialog.hpp" @@ -573,7 +577,7 @@ wxDEFINE_EVENT(EVT_DEL_FILAMENT, SimpleEvent); wxDEFINE_EVENT(EVT_ADD_CUSTOM_FILAMENT, ColorEvent); -#define PRINTER_THUMBNAIL_SIZE (wxSize(FromDIP(48), FromDIP(48))) +#define PRINTER_THUMBNAIL_SIZE (wxSize(FromDIP(68), FromDIP(68))) #define PRINTER_THUMBNAIL_SIZE_SMALL (wxSize(FromDIP(32), FromDIP(32))) #define PRINTER_PANEL_SIZE_SMALL (wxSize(FromDIP(98), FromDIP(68))) #define PRINTER_PANEL_SIZE_WIDEN (wxSize(FromDIP(136), FromDIP(68))) @@ -583,7 +587,7 @@ wxDEFINE_EVENT(EVT_ADD_CUSTOM_FILAMENT, ColorEvent); // Doc 17 measured the plate against a 44x40 slot; the card has room for more than that, and the // notches are what the silhouette exists to carry, so they get the pixels. The drawing still // letterboxes - the plate is taller than wide, and squashing it would change those proportions. -#define PLATE_SWATCH_SIZE (wxSize(FromDIP(56), FromDIP(51))) +#define PLATE_SWATCH_SIZE (wxSize(FromDIP(80), FromDIP(72))) // Nozzle diameter selection when multiple diameters are reported (e.g. U1 sync). // diameters_raw: list from device (may have duplicates or fewer than 4). Dedup and full-list logic inside. @@ -771,6 +775,110 @@ void build_machine_filament_list(PresetBundle* preset_bundle, std::vector& out_list, + const AceMmu::AceSnapshot* snap_in) +{ + AceMmu::AceSnapshot fetched; + if (!snap_in) { + const std::string host = AceMmuProvider::resolve_connected_host(); + if (host.empty()) + return; + AceMmuProvider prov(host); + if (!prov.fetch_once(2, 4)) + return; + fetched = prov.snapshot(); + snap_in = &fetched; + } + const AceMmu::AceSnapshot& snap = *snap_in; + + unsigned next_index = 0; + for (const auto& fd : out_list) + next_index = std::max(next_index, fd.m_index + 1); + + // Built as UTF-8 rather than typed: a narrow literal goes through the current locale on the + // way into wxString, and this string reaches the picker as a std::string. + const std::string kSep = " \xC2\xB7 "; + + // Relabel the toolhead rows with where their filament comes from, matching by head index. + for (auto& fd : out_list) { + const AceMmu::AceToolhead* th = nullptr; + for (const auto& t : snap.toolheads) + if (static_cast(t.idx) == fd.m_index) { + th = &t; + break; + } + + std::string label = "T" + std::to_string(static_cast(fd.m_index) + 1); + if (!th) { + if (!fd.m_type.empty()) + label += kSep + fd.m_type; + } else if (!th->filament_detected) { + label += kSep + "empty"; + fd.m_disabled = true; // nothing loaded: not a source, the way an empty slot is not + } else if (th->manual) { + label += kSep + th->material + kSep + "manual"; + } else if (!th->feeder) { + // Fed by an ACE: shown for context but not selectable. Mapping the head would + // address whichever slot happens to be loaded now; the slot rows below are the + // stable thing to map to, and they are all listed. + label += kSep + "from " + (th->ace.has_value() ? "A" + std::to_string(*th->ace + 1) : std::string("ACE")); + fd.m_disabled = true; + } else { + label += kSep + th->material; + } + fd.m_label = label; + } + + // Every slot of every unit, occupied or not - the same way an empty head still gets a row. + // "A-S", one-based, because every other surface numbers units from 1. + for (const auto& unit : snap.units) { + for (const auto& slot : unit.slots) { + const std::string tag = "A" + std::to_string(unit.idx + 1) + "-S" + std::to_string(slot.idx + 1); + FilamentData fd; + fd.m_index = next_index++; + fd.m_name = tag; + if (!slot.occupied) { + fd.m_type = ""; // NONE -> greyed and not selectable, like an empty head + fd.m_label = tag + kSep + "empty"; + fd.m_color = FilamentColor::FromColors({"#CCCCCC"}, FilamentColorMode::Segment); + out_list.push_back(std::move(fd)); + continue; + } + fd.m_type = slot.material; + fd.m_label = tag + kSep + slot.material; + std::vector colors; + if (!slot.color_rrggbb.empty()) + colors.push_back(slot.color_rrggbb); + fd.m_color = FilamentColor::FromColors(colors, FilamentColorMode::Segment); + out_list.push_back(std::move(fd)); + } + } + + // An explicit way back to unmapped. Empty-slot rows are NONE-typed and unclickable by + // design, so without this there is no way to say "this project filament has no source". + { + FilamentData none; + none.m_index = next_index++; + none.m_name = "NONE"; + none.m_type = "NONE"; + none.m_label = "None"; + none.m_assign_none = true; + none.m_color = FilamentColor::FromColors({"#CCCCCC"}, FilamentColorMode::Segment); + out_list.push_back(std::move(none)); + } +} + } // namespace bool Plater::has_illegal_filename_characters(const wxString& wxs_name) @@ -931,6 +1039,163 @@ static wxBitmap plate_bitmap(wxWindow *win, BedType bt, int height_dip) } } +// A card that can carry the green corner tick - the mark put on a card that agrees with +// the connected machine. StaticBox paints its own rounded border, so the tick goes on after it +// and follows the same corner arc: a plain right triangle would overhang a radius the border +// curves away from, which is what the mockup's `overflow:hidden` prevents. +// The sync mark: a green corner triangle with a white check, in the top-right of whatever it +// marks. Drawn into `win`'s own DC at its own top-right corner, so a card and a combo carry the +// identical mark rather than two that merely resemble each other. +// +// `radius` is the corner radius the thing being marked is drawn with; the mark follows that arc +// instead of overhanging it. `size_dip` lets a combo take a smaller triangle than a 106px card. +// One size wherever the mark appears. A combo is a third the height of a card, so this is a much +// larger share of a filament row than of the printer card - but the mark means the same thing in +// both places, and reading as the same mark matters more than sitting at the same proportion. +static constexpr int SYNC_MARK_DIP = 22; + +static void draw_sync_mark(wxWindow *win, wxDC &dc, double radius, int size_dip = SYNC_MARK_DIP) +{ + const wxSize sz = win->GetSize(); + const int s = win->FromDIP(size_dip); + if (sz.x < s || sz.y < s) + return; + + // Top edge, round the corner, down the right edge, back to the start. + std::vector pts; + pts.emplace_back(sz.x - s, 0); + for (int i = 0; i <= 6; ++i) { + const double a = M_PI / 2.0 * (double(i) / 6.0); + pts.emplace_back(int(std::lround(sz.x - radius + radius * std::sin(a))), + int(std::lround(radius - radius * std::cos(a)))); + } + pts.emplace_back(sz.x, s); + + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(wxColour(0x00, 0xAE, 0x42))); + dc.DrawPolygon(int(pts.size()), pts.data()); + + // The check, drawn rather than set as text: a glyph this small lands differently on every + // platform's font, and it has to sit inside the triangle on all of them. + // + // Clipped to the mark before it is drawn. The hypotenuse runs (0,0) to (s,s), so the interior + // is x >= y; a round cap that crosses that line puts white on the outside of the triangle, + // which does not read as a check hanging over an edge - it reads as a notch cut out of the + // triangle, because what is behind it is usually white too. The clip makes that impossible at + // any DPI rather than relying on the placement below to have enough margin. + wxRegion clip(int(pts.size()), pts.data()); + dc.SetDeviceClippingRegion(clip); + + // Placed in the corner's thick end, clearing the diagonal by ~0.2s at every point. + const int x0 = sz.x - s; + const wxPoint tick[3] = {{x0 + s * 50 / 100, s * 30 / 100}, + {x0 + s * 62 / 100, s * 42 / 100}, + {x0 + s * 86 / 100, s * 18 / 100}}; + wxPen pen(*wxWHITE, std::max(1, win->FromDIP(2))); + pen.SetCap(wxCAP_ROUND); + pen.SetJoin(wxJOIN_ROUND); + dc.SetPen(pen); + dc.DrawLines(3, tick); + dc.DestroyClippingRegion(); +} + +// A card that can carry the mark. +class SyncMarkBox : public StaticBox +{ +public: + explicit SyncMarkBox(wxWindow *parent) : StaticBox(parent) {} + + void SetSynced(bool synced) + { + if (m_synced == synced) + return; + m_synced = synced; + Refresh(); + } + +protected: + void doRender(wxDC &dc) override + { + StaticBox::doRender(dc); + if (m_synced) + draw_sync_mark(this, dc, radius); + } + +private: + bool m_synced = false; +}; + +// The mark on a filament row, laid over the combo's own top-right corner rather than set beside +// it, so a filament is marked the way a card is - the mark covers the thing it is about. +// +// An overlay window rather than a paint hook: the combo is a shared control (TextInput, and every +// preset combo in the app is one), and this mark belongs to six rows in one panel, not to the +// class. It forwards its clicks, because a corner that swallowed them would be a dead spot on a +// dropdown, and it is raised so the combo's own children cannot cover it. +class SyncMarkOverlay : public wxWindow +{ +public: + explicit SyncMarkOverlay(wxWindow *combo, double radius) + : wxWindow(combo, wxID_ANY), m_radius(radius) + { + const int d = FromDIP(SYNC_MARK_DIP); + SetSize(wxSize(d, d)); + SetBackgroundStyle(wxBG_STYLE_PAINT); + Hide(); + + Bind(wxEVT_PAINT, [this](wxPaintEvent &) { + wxAutoBufferedPaintDC dc(this); + dc.SetBackground(wxBrush(GetParent()->GetBackgroundColour())); + dc.Clear(); + draw_sync_mark(this, dc, m_radius); + }); + + for (auto evt : {wxEVT_LEFT_DOWN, wxEVT_LEFT_UP, wxEVT_LEFT_DCLICK}) + Bind(evt, [this](wxMouseEvent &e) { GetParent()->GetEventHandler()->ProcessEvent(e); }); + + // The combo's width is not known until it has been laid out, and changes with the panel. + combo->Bind(wxEVT_SIZE, [this](wxSizeEvent &e) { + e.Skip(); + reposition(); + }); + reposition(); + } + + void SetSynced(bool synced) + { + if (IsShown() == synced) + return; + Show(synced); + if (synced) { + Raise(); + reposition(); + } + GetParent()->Refresh(); + } + +private: + void reposition() + { + const wxSize parent_sz = GetParent()->GetSize(); + const wxSize sz = GetSize(); + // Inset by the border the combo draws, so the mark sits inside its outline rather than + // on top of it. + Move(parent_sz.x - sz.x - 1, 1); + } + + double m_radius; +}; + +// The middle dot, from its own bytes. +static wxString ace_sep() { return wxString::FromUTF8("\xc2\xb7"); } + +// "stock feeder" / "ACE 1, 4 slots" - the same words the head box uses, one-based as every other +// surface names units. +static wxString ace_wiring_label(int unit, int cap) +{ + return cap <= 1 ? _L("stock feeder") : wxString::Format(_L("ACE %d, %d slots"), unit + 1, cap); +} + struct Sidebar::priv { Plater *plater; @@ -938,6 +1203,8 @@ struct Sidebar::priv wxPanel *scrolled; PlaterPresetComboBox *combo_print; std::vector combos_filament; + // One per filament combo, shown when that filament is one the machine actually has loaded. + std::vector filament_sync_marks; int editing_filament = -1; wxBoxSizer *sizer_filaments; PlaterPresetComboBox *combo_sla_print; @@ -947,7 +1214,7 @@ struct Sidebar::priv // test wxStaticBitmap * image_printer = nullptr; - StaticBox* panel_printer_preset = nullptr; + SyncMarkBox* panel_printer_preset = nullptr; //BBS Sidebar widgets @@ -1032,6 +1299,16 @@ struct Sidebar::priv std::vector m_nozzle_diameter_lists; std::vector m_nozzle_edit_btns; + // What the machine said when Sync info was last pressed, and whether it answered at all. + // The corner ticks are a diff against this rather than against a live poll: the U1 connects + // as a PrintHost through the webview, so there is nothing pushing its state at us, and a + // panel that has not asked has no business claiming the machine agrees with it. + bool m_machine_read = false; // model and nozzles were read + bool m_ace_read = false; // the multiACE service answered too + std::string m_machine_model; + std::vector m_machine_nozzles; + AceMmu::AceSnapshot m_ace_snapshot; + ObjectList *m_object_list{ nullptr }; ObjectSettings *object_settings{ nullptr }; ObjectLayers *object_layers{ nullptr }; @@ -1939,10 +2216,9 @@ Sidebar::Sidebar(Plater *parent) std::pair(wxColour(0x00AE42), StateColor::Hovered), std::pair(wxColour(0xEEEEEE), StateColor::Normal)); - // Three cards across the top - printer, plate, sync - at equal height, the shape the - // panel design takes from Bambu Studio's. The plate card absorbs what used to be a - // full-width "Bed type" row, which is where the room for the head boxes comes from. - p->panel_printer_preset = new StaticBox(p->m_panel_printer_content); + // Three cards across the top - printer, plate, sync - at equal height. The plate card absorbs + // what used to be a full-width "Bed type" row, which is where the room for the head boxes comes from. + p->panel_printer_preset = new SyncMarkBox(p->m_panel_printer_content); p->panel_printer_preset->SetCornerRadius(8); p->panel_printer_preset->SetBorderColor(panel_bd_col); p->panel_printer_preset->SetMinSize(PRINTER_PANEL_SIZE); @@ -1952,7 +2228,7 @@ Sidebar::Sidebar(Plater *parent) combo_printer->SetWindowStyle(combo_printer->GetWindowStyle() & ~wxALIGN_MASK | wxALIGN_LEFT); combo_printer->SetBorderWidth(0); - ScalableBitmap bitmap_printer(p->panel_printer_preset, "printer_placeholder", 48); + ScalableBitmap bitmap_printer(p->panel_printer_preset, "printer_placeholder", 68); p->image_printer = new wxStaticBitmap(p->panel_printer_preset, wxID_ANY, bitmap_printer.bmp(), wxDefaultPosition, PRINTER_THUMBNAIL_SIZE, 0); p->image_printer->Bind(wxEVT_LEFT_DOWN, [this](auto& evt) { p->combo_printer->wxEvtHandler::ProcessEvent(evt); }); @@ -1994,7 +2270,11 @@ Sidebar::Sidebar(Plater *parent) // Thumbnail over the preset name, not beside it: the card is a picture of the machine // with its name under it, and the row only has to be one card tall. wxBoxSizer* vsizer_preset = new wxBoxSizer(wxVERTICAL); - vsizer_preset->AddSpacer(FromDIP(6)); + // The thumbnail takes the air rather than the gap under it: a card that is a picture of + // the machine should be mostly machine. Stretch spacers above and below centre what is + // left over instead of pooling it all beneath the image. + vsizer_preset->AddSpacer(FromDIP(2)); + vsizer_preset->AddStretchSpacer(); vsizer_preset->Add(p->image_printer, 0, wxALIGN_CENTER_HORIZONTAL); vsizer_preset->AddStretchSpacer(); vsizer_preset->Add(combo_printer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(4)); @@ -2023,6 +2303,7 @@ Sidebar::Sidebar(Plater *parent) // A texture that says smooth-vs-grained is all the card needs to say at this size. p->m_plate_swatch = new wxPanel(p->panel_plate_preset, wxID_ANY, wxDefaultPosition, PLATE_SWATCH_SIZE); wxPanel* plate_swatch = p->m_plate_swatch; + plate_swatch->SetMinSize(wxSize(-1, FromDIP(40))); plate_swatch->SetBackgroundStyle(wxBG_STYLE_PAINT); plate_swatch->SetCursor(wxCURSOR_HAND); plate_swatch->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& evt) { @@ -2035,10 +2316,23 @@ Sidebar::Sidebar(Plater *parent) // Read the bed type itself, never the combo's index: a U1 is offered a different and // shorter list (enum_values_u1) than every other printer, so position means nothing. const BedType bt = wxGetApp().preset_bundle->project_config.opt_enum("curr_bed_type"); - const wxBitmap bmp = plate_bitmap(plate_swatch, bt, 51); + wxBitmap bmp = plate_bitmap(plate_swatch, bt, 76); if (!bmp.IsOk()) return; const wxRect r = plate_swatch->GetClientRect(); + if (r.width <= 0 || r.height <= 0) + return; + // Fitted to the panel rather than drawn at the size that was asked for. The height the + // swatch actually gets is whatever the card has left after the combo and the margins, + // and hard-coding a number larger than that is what clipped the plate top and bottom - + // DrawBitmap centres, so an oversized drawing loses both ends at once. + if (bmp.GetWidth() > r.width || bmp.GetHeight() > r.height) { + const double k = std::min(double(r.width) / bmp.GetWidth(), double(r.height) / bmp.GetHeight()); + wxImage img = bmp.ConvertToImage(); + img.Rescale(std::max(1, int(bmp.GetWidth() * k)), std::max(1, int(bmp.GetHeight() * k)), + wxIMAGE_QUALITY_HIGH); + bmp = wxBitmap(img); + } dc.DrawBitmap(bmp, r.x + (r.width - bmp.GetWidth()) / 2, r.y + (r.height - bmp.GetHeight()) / 2, true); }); @@ -2079,17 +2373,26 @@ Sidebar::Sidebar(Plater *parent) m_bed_type_list->Select(bed_type_idx); refresh_plate_card(); - // The glyph gets the corner to itself, so the plate can sit centred in the card rather - // than pushed off-centre by whatever shares its row. + // The glyph is positioned, not laid out. As a sizer row it took about a fifth of a 106px + // card to show a 14px button, and that height is the plate's - the card exists to be a + // picture of a plate. Overlaid it keeps the same corner and costs nothing. Repositioned on + // size because the card's width is not known until it has been laid out, and raised so the + // larger swatch cannot come out on top of it. + plate_info_btn->Raise(); + p->panel_plate_preset->Bind(wxEVT_SIZE, [plate_info_btn](wxSizeEvent& evt) { + evt.Skip(); + const wxSize card = evt.GetSize(); + const wxSize btn = plate_info_btn->GetSize(); + plate_info_btn->Move(card.x - btn.x - plate_info_btn->FromDIP(4), plate_info_btn->FromDIP(4)); + }); + + // No stretch spacers around the swatch: they carry proportion 1 as well, so the leftover + // height was being split three ways and the drawing got a third of what the card had. + // The swatch takes all of it and the drawing centres itself inside. wxBoxSizer* vsizer_plate = new wxBoxSizer(wxVERTICAL); - wxBoxSizer* hsizer_plate_info = new wxBoxSizer(wxHORIZONTAL); - hsizer_plate_info->AddStretchSpacer(); - hsizer_plate_info->Add(plate_info_btn, 0, wxRIGHT, FromDIP(3)); - vsizer_plate->AddSpacer(FromDIP(3)); - vsizer_plate->Add(hsizer_plate_info, 0, wxEXPAND); - vsizer_plate->AddStretchSpacer(); - vsizer_plate->Add(plate_swatch, 0, wxALIGN_CENTER_HORIZONTAL); - vsizer_plate->AddStretchSpacer(); + vsizer_plate->AddSpacer(FromDIP(5)); + vsizer_plate->Add(plate_swatch, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(4)); + vsizer_plate->AddSpacer(FromDIP(2)); vsizer_plate->Add(m_bed_type_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(4)); p->panel_plate_preset->SetSizer(vsizer_plate); @@ -2107,13 +2410,13 @@ Sidebar::Sidebar(Plater *parent) p->m_panel_sync_info->SetMinSize(SYNC_PANEL_SIZE); p->m_panel_sync_info->SetMaxSize(SYNC_PANEL_SIZE); p->m_panel_sync_info->SetCursor(wxCURSOR_HAND); - p->m_panel_sync_info->SetToolTip(_L("Synchronize nozzle information")); + p->m_panel_sync_info->SetToolTip(_L("Synchronize printer information")); p->m_printerinfo_syncbtn = new ScalableButton(p->m_panel_sync_info, wxID_ANY, "nozzle_sync", wxEmptyString, wxDefaultSize, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, false, 24); p->m_printerinfo_syncbtn->SetCursor(wxCURSOR_HAND); - p->m_printerinfo_syncbtn->SetToolTip(_L("Synchronize nozzle information")); + p->m_printerinfo_syncbtn->SetToolTip(_L("Synchronize printer information")); p->m_printerinfo_syncbtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent &) { sync_printer_info(); }); // LB_PROPAGATE_MOUSE_EVENT hands the label's clicks to the card, so the card's own @@ -2788,6 +3091,14 @@ Sidebar::Sidebar(Plater *parent) }); combobox->edit_btn = edit_btn; + // The mark rides on the combo's own corner, so it joins no sizer. + { + auto* sync_mark = new SyncMarkOverlay(combobox, 4.0); + sync_mark->SetToolTip(_L("This filament is loaded on the printer")); + if (int(p->filament_sync_marks.size()) <= 0) + p->filament_sync_marks.resize(1, nullptr); + p->filament_sync_marks[0] = sync_mark; + } combo_and_btn_sizer->Add(edit_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::ElementSpacing()) - FromDIP(2)); // ElementSpacing - 2 (from combo box)) combo_and_btn_sizer->AddSpacer(FromDIP(SidebarProps::ContentMargin())); @@ -3216,6 +3527,14 @@ void Sidebar::init_filament_combo(PlaterPresetComboBox **combo, const int filame combobox->edit_btn = edit_btn; + // The mark rides on the combo's own corner, so it joins no sizer. + { + auto* sync_mark = new SyncMarkOverlay(combobox, 4.0); + sync_mark->SetToolTip(_L("This filament is loaded on the printer")); + if (int(p->filament_sync_marks.size()) <= filament_idx) + p->filament_sync_marks.resize(filament_idx + 1, nullptr); + p->filament_sync_marks[filament_idx] = sync_mark; + } combo_and_btn_sizer->Add(edit_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::ElementSpacing()) - FromDIP(2)); // ElementSpacing - 2 (from combo box)) combo_and_btn_sizer->AddSpacer(FromDIP(SidebarProps::ContentMargin())); @@ -3339,8 +3658,7 @@ void Sidebar::update_all_preset_comboboxes(bool reload_printer_view) if(hasOnlineMachine) p->combo_printer->set_show_machine_connecting_button(true); - wxString url = wxString::FromUTF8(LOCALHOST_URL + std::to_string(wxGetApp().get_page_http_port()) + - "/web/flutter_web/index.html?path=2"); + wxString url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::DeviceTab); auto real_url = wxGetApp().get_international_url(url); if (!is_sm_page && reload_printer_view) { @@ -3474,6 +3792,10 @@ void Sidebar::update_all_preset_comboboxes(bool reload_printer_view) p_mainframe->show_device(preset_bundle.use_bbl_device_tab() && !use_new_connection); p_mainframe->m_tabpanel->SetSelection(p_mainframe->m_tabpanel->GetSelection()); + + // A filament that has just been switched or recoloured may no longer be one the printer has; + // the mark is a diff, so it is recomputed wherever the filaments can have moved. + refresh_filament_sync_marks(); } void Sidebar::update_presets(Preset::Type preset_type) @@ -8881,7 +9203,22 @@ void Sidebar::show_sync_filament_dialog() } } - if (!host && !device_machine) { + // The ACE half of the inventory does not come through either of those: it is read from the + // machine's own service over plain HTTP. Sync info usually leaves a snapshot behind; without + // one, ask now, because whether that answers decides whether "not connected" is even true. + AceMmu::AceSnapshot ace_snap; + bool have_ace = p->m_ace_read; + if (have_ace) { + ace_snap = p->m_ace_snapshot; + } else if (const std::string ace_host = AceMmuProvider::resolve_connected_host(); !ace_host.empty()) { + AceMmuProvider prov(ace_host); + if (prov.fetch_once(2, 4)) { + ace_snap = prov.snapshot(); + have_ace = true; + } + } + + if (!host && !device_machine && !have_ace) { SyncRichConfirmDialog dlg(this, _L("No printer is connected. Please connect your U1 from the Device page before syncing."), wxYES_NO); @@ -8934,6 +9271,11 @@ void Sidebar::show_sync_filament_dialog() std::vector machineFilamentList; build_machine_filament_list(preset_bundle, machineFilamentList); + // Both sources, in one list: the toolheads the bridge reported, relabelled with where each + // one is fed from, then every ACE slot. Reuses the snapshot Sync info took when this dialog + // is the second step of that press, so the two halves cannot disagree about the machine. + if (have_ace) + append_ace_filament_list(machineFilamentList, &ace_snap); auto nonEmptyFilaments = [](const std::vector& filamentDatas) { for (const auto& filament : filamentDatas) { if (!is_none_filament(filament)) @@ -8976,7 +9318,10 @@ void Sidebar::show_sync_filament_dialog() dlg.setMixedFilamentInfos(mixedInfos); { if (wxGetApp().plater()->model().objects.empty()) { - dlg.setOverwriteMode(); + // Nothing on the plate: no object references a filament index, so the project can take + // the machine's whole inventory rather than the count it happened to be carrying. With + // objects present the mapping dialog opens instead, unchanged. + dlg.setOverwriteMode(/*whole_machine=*/true); } else { dlg.CentreOnScreen(); if (dlg.ShowModal() != wxID_OK) @@ -9056,6 +9401,8 @@ void Sidebar::show_sync_filament_dialog() NotificationType::CustomNotification, NotificationManager::NotificationLevel::RegularNotificationLevel, _u8L("Filament types and colors have been successfully synced from the printer.")); + + refresh_filament_sync_marks(); } } @@ -9095,24 +9442,19 @@ void Sidebar::sync_printer_info() hasConnectDevice = true; } - if (!hasConnectDevice) - { - // showdialog tips no connect device - wxTheApp->CallAfter([this]() { - MessageDialog dlg(wxGetApp().mainframe, - _L("Printer not connected. Please go to the home page or the device page to connect the printer."), - _L("Note"), wxOK); - dlg.ShowModal(); - }); - return; - } - + // No early return on a missing bridge. The nozzle half of this press goes through the webview + // connection; the ACE half only needs the machine to be on the network, and refusing to ask a + // printer that is answering on its own address would be a gate on the wrong thing. Not being + // connected is one of the outcomes this press reports, not a reason to skip it. std::string machine_type = ""; std::vector nozzle_diameters; std::string device_name = ""; std::shared_ptr host = nullptr; - wxGetApp().get_connect_host(host); - const bool got_machine_info = SSWCP::query_machine_info(host, machine_type, nozzle_diameters, device_name); + bool got_machine_info = false; + if (hasConnectDevice) { + wxGetApp().get_connect_host(host); + got_machine_info = SSWCP::query_machine_info(host, machine_type, nozzle_diameters, device_name); + } const auto& sync_nozzle_slots = wxGetApp().preset_bundle->m_connect_machine_info_list; if (!sync_nozzle_slots.empty()) { @@ -9128,6 +9470,31 @@ void Sidebar::sync_printer_info() nozzle_diameters.push_back(nd); } } + // One press, two reads, and they are independent. The nozzles came back over the webview + // bridge above; the ACE mode and wiring live in the printer's own multiACE service, which + // answers plain HTTP and is the only thing that knows which unit feeds which head. Reading it + // outside the branch below means a machine on the LAN can still describe its wiring when the + // bridge has not connected - which is exactly when the panel has least to go on. + p->m_machine_model = machine_type; + p->m_machine_nozzles = nozzle_diameters; + p->m_machine_read = got_machine_info && !nozzle_diameters.empty(); + p->m_ace_read = false; + const std::string ace_host = AceMmuProvider::resolve_connected_host(); + if (ace_host.empty()) { + BOOST_LOG_TRIVIAL(info) << "sync_printer_info: no host to read the multiACE state from"; + } else { + // Short timeouts, because this blocks the GUI thread: a machine that answered the webview + // may still have its multiACE service off, and the press must not hang on it. + AceMmuProvider prov(ace_host); + if (prov.fetch_once(2, 4)) { + p->m_ace_snapshot = prov.snapshot(); + p->m_ace_read = true; + BOOST_LOG_TRIVIAL(info) << "sync_printer_info: multiACE state read from " << ace_host + << "; mode=" << p->m_ace_snapshot.mode + << " device_count=" << p->m_ace_snapshot.device_count; + } + } + if (got_machine_info && machine_type == "Snapmaker U1") { if (nozzle_diameters.size() <= 0) @@ -9185,6 +9552,11 @@ void Sidebar::sync_printer_info() wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); wxGetApp().plater()->sidebar().update_nozzle_settings(true); + + // After the preset switch, never before: the ACE keys belong to the + // preset being switched to, and writing them first would follow the + // old one out. + wxTheApp->CallAfter([this]() { finish_printer_sync(); }); } } } @@ -9214,14 +9586,211 @@ void Sidebar::sync_printer_info() wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); wxGetApp().plater()->sidebar().update_nozzle_settings(true); - wxTheApp->CallAfter([this]() { - MessageDialog dlg_Ex(wxGetApp().mainframe, _L("Nozzle settings synchronized successfully"), - _L("Note"), wxOK); - dlg_Ex.ShowModal(); - }); + wxTheApp->CallAfter([this]() { finish_printer_sync(); }); }); } } + else if (p->m_ace_read) { + // The bridge did not name a U1 - it may not have connected at all - but the machine's own + // multiACE service answered, so the half of the press that does not need the bridge still + // runs rather than the card doing nothing. + wxTheApp->CallAfter([this]() { finish_printer_sync(); }); + } + else { + // Neither read worked. Pressing a card and getting nothing back is the one outcome that + // reads as a broken button, so say which half failed. + wxTheApp->CallAfter([this]() { + MessageDialog dlg(wxGetApp().mainframe, + _L("Nothing could be read from the printer.\n\n" + "Check that it is switched on and on the same network. Nozzle sizes and " + "filaments also need it connected from the home page or the Device page."), + _L("Sync info"), wxOK); + dlg.ShowModal(); + }); + } +} + +// The second half of one press. The nozzle half has finished - possibly by switching the printer +// preset - so the ACE mode and wiring read from the machine go into whichever preset is now +// selected, and the press ends by offering the filament sync rather than stopping at an OK. +// +// The machine is authoritative: SET_ACE_MODE and ACE_SET_HEAD_ACE persist the wiring there, and +// the preset is a cache that lets slicing work with the printer switched off. So divergence is +// resolved towards the machine, and named line by line rather than corrected quietly - a wrong +// unit here becomes a wrong ACE= argument in the gcode. +void Sidebar::finish_printer_sync() +{ + wxString changes; + + if (p->m_ace_read) { + auto& printers = wxGetApp().preset_bundle->printers; + const DynamicConfig& cfg = printers.get_edited_preset().config; + + const auto* nozzle_diameter = cfg.option("nozzle_diameter"); + const size_t head_count = nozzle_diameter ? nozzle_diameter->values.size() : 0; + const AceMmu::AceTopology topo = AceMmu::ace_topology_of(p->m_ace_snapshot, head_count); + + const auto* ace_mode_opt = cfg.option>("ace_mode"); + const AceMode was_mode = ace_mode_opt ? AceMode(ace_mode_opt->value) : amNormal; + if (was_mode != topo.mode) { + const ConfigOptionDef* def = print_config_def.get("ace_mode"); + const auto label = [def](AceMode m) { + return def && size_t(m) < def->enum_labels.size() ? _L(def->enum_labels[m]) : wxString(); + }; + changes += wxString::Format(_L(" ACE mode: %s -> %s\n"), label(was_mode), label(topo.mode)); + } + + const auto* head_unit = cfg.option("ace_head_unit"); + const auto* head_cap = cfg.option("ace_head_capacity"); + for (size_t h = 0; h < head_count; ++h) { + const int was_cap = (head_cap && h < head_cap->values.size()) ? head_cap->values[h] : 1; + const int was_unit = (head_unit && h < head_unit->values.size()) ? head_unit->values[h] : -1; + if (was_cap != topo.cap[h] || (topo.cap[h] > 1 && was_unit != topo.unit[h])) + changes += wxString::Format(_L(" Toolhead %d: %s -> %s\n"), int(h) + 1, + ace_wiring_label(was_unit, was_cap), + ace_wiring_label(topo.unit[h], topo.cap[h])); + } + + if (!changes.IsEmpty()) { + // Written through the edited preset the ACE mode combo already writes to - that is the + // same object the printer Tab holds as m_config - then the Tab is told to reread it, so + // the Multimaterial page and the dirty markers do not keep the old values on screen. + DynamicConfig& edited = printers.get_edited_preset().config; + edited.set_key_value("ace_mode", new ConfigOptionEnum(topo.mode)); + edited.set_key_value("ace_head_unit", new ConfigOptionInts(topo.unit)); + edited.set_key_value("ace_head_capacity", new ConfigOptionInts(topo.cap)); + if (Tab* tab = wxGetApp().get_tab(Preset::TYPE_PRINTER)) { + tab->reload_config(); + tab->update_dirty(); + } + } + } + + // Repaint with what was just read: the ACE rows, the corner ticks that are now a diff against + // a machine this panel has actually spoken to, and the filament rows' own marks. + update_nozzle_settings(); + refresh_filament_sync_marks(); + + // Say which halves ran. Both can fail independently - the nozzles come over the webview + // bridge, the ACE state over the machine's own HTTP service - and a message claiming the one + // that did not is what makes an absent corner tick look like a bug rather than an answer. + wxString msg; + if (p->m_ace_read && p->m_machine_read) + msg = _L("Successfully synchronized nozzle, ACE mode and ACE unit information."); + else if (p->m_ace_read) + msg = _L("Synchronized the ACE mode and ACE unit information.") + "\n\n" + + _L("The nozzle sizes could not be read: the printer is not connected on the Device page."); + else + msg = _L("Nozzle settings synchronized successfully.") + "\n\n" + + _L("The printer's multiACE service did not answer, so the ACE mode and wiring were " + "left as they are."); + + if (p->m_ace_read) + msg += "\n\n" + (changes.IsEmpty() ? _L("The preset already matched the printer's ACE wiring.") + : _L("Read from the printer:") + "\n" + changes); + + // RichMessageDialog, not MessageDialog: only the rich one carries SetYesNoLabels, and the + // step has to name what it continues to rather than offering a bare Yes. + RichMessageDialog dlg(wxGetApp().mainframe, msg, _L("Sync info"), wxYES_NO); + dlg.SetYesNoLabels(_L("Continue to sync filaments"), _L("Cancel")); + if (dlg.ShowModal() == wxID_YES) + show_sync_filament_dialog(); +} + +// The assign popover. Its two kinds of row write exactly `ace_head_unit` and `ace_head_capacity`, +// which is all "which ACE feeds this toolhead" is in the preset - and the printer is not told: +// the machine is authoritative for its own wiring, so this records what the user believes it to be +// and Sync info is what reconciles the two. +// Which project filaments the machine actually has loaded. +// +// The same claim the corner ticks make, one row lower: this is a diff against what was read, not +// a record that a sync once happened. Edit a filament's colour or preset afterwards and its mark +// goes, because it no longer names anything on the printer. +// +// Matched on type and colour, which is all the sync itself writes - it says so on its own dialog: +// "Only filament types and colors are synchronized". Nothing here fetches; a machine that has not +// been read marks nothing. +void Sidebar::refresh_filament_sync_marks() +{ + if (p->filament_sync_marks.empty()) + return; + + std::vector machine; + if (p->m_ace_read || !wxGetApp().preset_bundle->m_connect_machine_info_list.empty()) { + build_machine_filament_list(wxGetApp().preset_bundle, machine); + if (p->m_ace_read) + append_ace_filament_list(machine, &p->m_ace_snapshot); + } + + std::vector project; + build_design_filament_list(wxGetApp().preset_bundle, project); + + for (size_t i = 0; i < p->filament_sync_marks.size(); ++i) { + SyncMarkOverlay* mark = p->filament_sync_marks[i]; + if (!mark) + continue; + + bool synced = false; + if (i < project.size()) { + const wxColour pc = getMainColor(project[i].m_color); + for (const FilamentData& m : machine) { + // A row the user could not have picked is not something to claim agreement with: + // an empty slot, an ACE-fed head, or the Assign None action. + if (is_none_filament(m) || m.m_disabled) + continue; + if (m.m_type != project[i].m_type) + continue; + const wxColour mc = getMainColor(m.m_color); + if (mc.Red() == pc.Red() && mc.Green() == pc.Green() && mc.Blue() == pc.Blue()) { + synced = true; + break; + } + } + } + mark->SetSynced(synced); + } +} + +void Sidebar::show_ace_assign_popup(size_t head_idx, wxWindow* anchor) +{ + auto& printers = wxGetApp().preset_bundle->printers; + const DynamicConfig& cfg = printers.get_edited_preset().config; + + const auto* nozzle_diameter = cfg.option("nozzle_diameter"); + const size_t head_count = nozzle_diameter ? nozzle_diameter->values.size() : 0; + if (head_idx >= head_count) + return; + + // Read into full-length vectors first: a preset that has never been told about ACE carries the + // one-element default, and writing a short vector back would leave three heads unaddressable. + std::vector units(head_count, -1), caps(head_count, 1); + if (const auto* opt = cfg.option("ace_head_unit")) + for (size_t h = 0; h < head_count && h < opt->values.size(); ++h) + units[h] = opt->values[h]; + if (const auto* opt = cfg.option("ace_head_capacity")) + for (size_t h = 0; h < head_count && h < opt->values.size(); ++h) + caps[h] = opt->values[h]; + + // Parented to the sidebar, not to the head box: choosing rebuilds the head boxes, and a popup + // whose parent is destroyed under it takes the app with it. + auto* popup = new AceAssignPopup(this, head_idx, units, caps, p->m_ace_snapshot, p->m_ace_read); + popup->on_choice([this, head_idx, units, caps](int unit, int cap) mutable { + if (units[head_idx] == unit && caps[head_idx] == cap) + return; // nothing moved; leave the preset clean rather than marking it dirty + units[head_idx] = unit; + caps[head_idx] = cap; + + DynamicConfig& edited = wxGetApp().preset_bundle->printers.get_edited_preset().config; + edited.set_key_value("ace_head_unit", new ConfigOptionInts(units)); + edited.set_key_value("ace_head_capacity", new ConfigOptionInts(caps)); + if (Tab* tab = wxGetApp().get_tab(Preset::TYPE_PRINTER)) { + tab->reload_config(); + tab->update_dirty(); + } + // After the popup has finished dismissing itself: this rebuilds the box the click came from. + CallAfter([this]() { update_nozzle_settings(); }); + }); + popup->popup_at(anchor); } void Sidebar::update_nozzle_settings(bool switch_machine) @@ -9262,6 +9831,26 @@ void Sidebar::update_nozzle_settings(bool switch_machine) } } + // The corner ticks, computed only from what Sync info actually read. A machine nobody has + // spoken to gets no marks at all - the absence is the honest answer, not a defect. + if (p->panel_printer_preset) { + // The card agrees when the selected preset is the machine's own model at the nozzle size + // every head reports. A mixed set never agrees: the preset cannot hold one. + bool printer_agrees = p->m_machine_read && printer_model && + boost::iequals(printer_model->value, p->m_machine_model) && + !p->m_machine_nozzles.empty(); + if (printer_agrees) { + const auto* pv = cfg.option("printer_variant"); + for (const std::string& nd : p->m_machine_nozzles) + if (!pv || nd != pv->value) { + printer_agrees = false; + break; + } + } + p->panel_printer_preset->SetSynced(printer_agrees); + } + const AceMmu::AceTopology machine_topo = p->m_ace_read ? AceMmu::ace_topology_of(p->m_ace_snapshot, head_count) : AceMmu::AceTopology(); + p->m_heads_sizer->Clear(true); p->m_nozzle_diameter_lists.clear(); p->m_nozzle_edit_btns.clear(); @@ -9273,9 +9862,12 @@ void Sidebar::update_nozzle_settings(bool switch_machine) std::pair(wxColour(0xEEEEEE), StateColor::Normal)); for (size_t i = 0; i < head_count; i++) { - StaticBox* head_box = new StaticBox(p->m_panel_heads); + SyncMarkBox* head_box = new SyncMarkBox(p->m_panel_heads); head_box->SetCornerRadius(8); head_box->SetBorderColor(head_bd_col); + // Marked when this head's wiring is what the printer last reported. Only a U1 has wiring + // to be right about; every other machine's boxes hold a diameter and nothing to check. + head_box->SetSynced(is_snapmaker_u1 && p->m_ace_read && AceMmu::ace_head_agrees(cfg, machine_topo, i)); wxString head_name; if (is_snapmaker_u1) @@ -9298,21 +9890,48 @@ void Sidebar::update_nozzle_settings(bool switch_machine) // assign popover, which arrives with the ACE row's live contents. In Normal mode no head // is wired to anything, so the row says so rather than showing a stale unit. if (is_snapmaker_u1) { - const int unit = (head_unit && i < head_unit->values.size()) ? head_unit->values[i] : -1; - const int cap = (head_cap && i < head_cap->values.size()) ? head_cap->values[i] : 1; - - wxString fed_by = _L("Stock feeder"); - if (ace_mode != amNormal && unit >= 0) - fed_by = wxString::Format(_L("ACE %d"), unit + 1) + - (cap > 1 ? wxString::Format(" · %d", cap) + " " + _L("slots") : wxString()); + const int unit = (head_unit && i < head_unit->values.size()) ? head_unit->values[i] : -1; + const int cap = (head_cap && i < head_cap->values.size()) ? head_cap->values[i] : 1; + const bool wired = ace_mode != amNormal && unit >= 0 && cap > 1; auto* ace_key = new Label(head_box, Label::Body_12, _L("ACE")); ace_key->SetForegroundColour(row_label_colour); - auto* ace_val = new Label(head_box, Label::Body_12, fed_by); - ace_val->SetForegroundColour(ace_mode == amNormal ? wxColour(0x9A, 0x9A, 0x9A) : wxColour(0x4A, 0x52, 0x58)); + ace_key->SetMinSize(wxSize(FromDIP(34), -1)); + + // One adjust button per head, in the same place whether a badge or the words Stock + // feeder follow it, so it can be found without reading the row first. Disabled in + // Normal mode, where no head is wired to anything and the choice would mean nothing. + auto* ace_edit = new ScalableButton(head_box, wxID_ANY, "edit", wxEmptyString, wxDefaultSize, + wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, false, 14); + ace_edit->SetToolTip(_L("Which ACE feeds this toolhead")); + ace_edit->SetCursor(wxCURSOR_HAND); + ace_edit->Enable(ace_mode != amNormal); + ace_edit->Bind(wxEVT_BUTTON, [this, i, ace_edit](wxCommandEvent&) { show_ace_assign_popup(i, ace_edit); }); + + // The badge carries the unit's own slot colours when the panel has read the machine, + // and the disabled greys when it has not - four empty bays would be a claim. + wxWindow* ace_val = nullptr; + if (wired) { + auto* badge = new AceBadge(head_box, 22); + const AceMmu::AceUnit* live = p->m_ace_read ? p->m_ace_snapshot.find_unit(unit) : nullptr; + if (live) + badge->SetUnit(*live); + else + badge->SetUnknown(); + badge->SetToolTip(wxString::Format(_L("ACE %d"), unit + 1) + " " + ace_sep() + " " + + wxString::Format("%d ", cap) + _L("slots")); + badge->SetCursor(wxCURSOR_HAND); + badge->Bind(wxEVT_LEFT_UP, [this, i, ace_edit](wxMouseEvent&) { show_ace_assign_popup(i, ace_edit); }); + ace_val = badge; + } else { + auto* txt = new Label(head_box, Label::Body_12, _L("Stock feeder")); + txt->SetForegroundColour(ace_mode == amNormal ? wxColour(0x9A, 0x9A, 0x9A) : wxColour(0x4A, 0x52, 0x58)); + ace_val = txt; + } wxBoxSizer* ace_row = new wxBoxSizer(wxHORIZONTAL); ace_row->Add(ace_key, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + ace_row->Add(ace_edit, 0, wxALIGN_CENTER_VERTICAL); ace_row->AddStretchSpacer(); ace_row->Add(ace_val, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); box_sizer->Add(ace_row, 0, wxEXPAND | wxTOP, FromDIP(4)); @@ -9602,17 +10221,17 @@ void Sidebar::update_printer_thumbnail() std::string printer_type = selected_preset.get_current_printer_type(preset_bundle); try { - p->image_printer->SetBitmap(create_scaled_bitmap(png_name, this, 48)); + p->image_printer->SetBitmap(create_scaled_bitmap(png_name, this, 68)); } catch (std::exception& e) { - p->image_printer->SetBitmap(create_scaled_bitmap("printer_placeholder", this, 48)); + p->image_printer->SetBitmap(create_scaled_bitmap("printer_placeholder", this, 68)); } /*if (printer_thumbnails.find(printer_type) != printer_thumbnails.end()) p->image_printer->SetBitmap(create_scaled_bitmap(, this, 48)); else - p->image_printer->SetBitmap(create_scaled_bitmap("printer_placeholder", this, 48));*/ + p->image_printer->SetBitmap(create_scaled_bitmap("printer_placeholder", this, 68));*/ } void Sidebar::auto_calc_flushing_volumes(const int modify_id) diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index b2ed57669ef..d5dfb08ab19 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -188,6 +188,15 @@ class Sidebar : public wxPanel void update_nozzle_settings(bool switch_machine = false); void sync_printer_info(); + // Second half of one press: write the ACE mode and wiring just read into the printer preset, + // then offer the filament sync. Split out because the nozzle half may have to open a picker + // first, and both of its paths end here. + void finish_printer_sync(); + // The assign popover behind a head box's ACE row: which unit feeds this toolhead. + void show_ace_assign_popup(size_t head_idx, wxWindow* anchor); + // Mark the filaments the machine has loaded. A diff, like the corner ticks - not a record + // that a sync once ran. + void refresh_filament_sync_marks(); void refresh_plate_card(); ObjectList* obj_list(); diff --git a/src/slic3r/GUI/PrinterWebView.cpp b/src/slic3r/GUI/PrinterWebView.cpp index e56f647c08c..5aa6ad3559c 100644 --- a/src/slic3r/GUI/PrinterWebView.cpp +++ b/src/slic3r/GUI/PrinterWebView.cpp @@ -28,7 +28,12 @@ PrinterWebView::PrinterWebView(wxWindow *parent) wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL); - wxString url = wxString::FromUTF8(LOCALHOST_URL + std::to_string(wxGetApp().get_page_http_port()) + "/web/flutter_web/index.html?path=2"); + // Which implementation the tab opens on; the switcher can change it live. + m_reconstructed = wxGetApp().app_config->get_bool("u1_reconstructed_ui"); + + build_switcher(topsizer); + + wxString url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::DeviceTab); auto real_url = wxGetApp().get_international_url(url); // Create the webview m_browser = WebView::CreateWebView(this, real_url); @@ -67,13 +72,68 @@ PrinterWebView::~PrinterWebView() } +// A slim bar above the webview: two buttons, one per implementation. +void PrinterWebView::build_switcher(wxSizer* topsizer) +{ + m_switcher = new wxPanel(this, wxID_ANY); + auto* row = new wxBoxSizer(wxHORIZONTAL); + + m_btn_original = new wxButton(m_switcher, wxID_ANY, _L("Original"), + wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); + m_btn_rebuilt = new wxButton(m_switcher, wxID_ANY, _L("Rebuilt"), + wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); + m_btn_original->SetToolTip(_L("The Snapmaker Device page as shipped")); + m_btn_rebuilt->SetToolTip(_L("The reconstruction in resources/web/device_page")); + + m_btn_original->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { show_surface(false); }); + m_btn_rebuilt->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { show_surface(true); }); + + row->AddSpacer(FromDIP(8)); + row->Add(m_btn_original, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, FromDIP(4)); + row->AddSpacer(FromDIP(4)); + row->Add(m_btn_rebuilt, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, FromDIP(4)); + row->AddStretchSpacer(1); + + m_switcher->SetSizer(row); + topsizer->Add(m_switcher, 0, wxEXPAND); + update_switcher(); +} + +// The selected one is disabled, which reads as "you are here" without needing a +// custom toggle widget. +void PrinterWebView::update_switcher() +{ + if (!m_btn_original || !m_btn_rebuilt) + return; + m_btn_original->Enable(m_reconstructed); + m_btn_rebuilt->Enable(!m_reconstructed); +} + +void PrinterWebView::show_surface(bool reconstructed) +{ + if (m_browser == nullptr) + return; + + m_reconstructed = reconstructed; + wxGetApp().app_config->set_bool("u1_reconstructed_ui", reconstructed); + update_switcher(); + + // Drop the outgoing page's bridge subscriptions before the new one loads; + // otherwise its pushes would arrive at a document that never asked for them. + SSWCP::on_webview_delete(m_browser); + + wxString url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::DeviceTab); + wxString real_url = wxGetApp().get_international_url(url); + load_url(real_url, m_apikey); +} + void PrinterWebView::load_url(wxString& url, wxString apikey) { if (m_browser == nullptr) return; m_apikey = apikey; - if (url.find("path=2") != std::string::npos) { + if (GUI_App::is_u1_device_tab_url(url)) { wxGetApp().fltviews().add_printer_view(this, url, apikey); } else { wxGetApp().fltviews().remove_printer_view(this); @@ -95,7 +155,7 @@ bool PrinterWebView::isSnapmakerPage() if (m_browser == nullptr) return false; auto url = m_browser->GetCurrentURL(); - return (url.find("flutter_web") != std::string::npos); + return GUI_App::is_u1_surface_url(url); } void PrinterWebView::sendMessage(const std::string& msg) { diff --git a/src/slic3r/GUI/PrinterWebView.hpp b/src/slic3r/GUI/PrinterWebView.hpp index c88b4011b6d..1ad0529c2b6 100644 --- a/src/slic3r/GUI/PrinterWebView.hpp +++ b/src/slic3r/GUI/PrinterWebView.hpp @@ -22,6 +22,7 @@ #include "wx/fs_mem.h" #include "wx/stdpaths.h" #include +#include #include #include "wx/textctrl.h" #include @@ -37,6 +38,13 @@ class PrinterWebView : public wxPanel{ virtual ~PrinterWebView(); void load_url(wxString& url, wxString apikey = ""); + + // The Device tab can show either implementation: the shipped Flutter page + // or the reconstruction in resources/web/device_page. Both are reachable + // from a switcher above the webview; only one is loaded at a time, because + // two live Device pages would open two MQTT sessions to the same printer. + void show_surface(bool reconstructed); + bool showing_reconstructed() const { return m_reconstructed; } void UpdateState(); void OnClose(wxCloseEvent& evt); void OnError(wxWebViewEvent& evt); @@ -51,7 +59,14 @@ class PrinterWebView : public wxPanel{ private: void SendAPIKey(); + void build_switcher(wxSizer* topsizer); + void update_switcher(); + wxWebView* m_browser; + wxPanel* m_switcher = nullptr; + wxButton* m_btn_original = nullptr; + wxButton* m_btn_rebuilt = nullptr; + bool m_reconstructed = false; long m_zoomFactor; wxString m_apikey; diff --git a/src/slic3r/GUI/SMAccountPersist.cpp b/src/slic3r/GUI/SMAccountPersist.cpp new file mode 100644 index 00000000000..84299f8dbdf --- /dev/null +++ b/src/slic3r/GUI/SMAccountPersist.cpp @@ -0,0 +1,59 @@ +#include "SMAccountPersist.hpp" +#include "GUI_App.hpp" +#include "libslic3r/AppConfig.hpp" + +#include + +namespace Slic3r { namespace GUI { + +static const char* SM_SECTION = "sm_account"; + +void sm_persist_login() +{ + auto* ui = wxGetApp().sm_get_userinfo(); + AppConfig* cfg = wxGetApp().app_config; + if (!ui || !cfg) + return; + + cfg->set(SM_SECTION, "token", ui->get_user_token()); + cfg->set(SM_SECTION, "id", ui->get_user_id()); + cfg->set(SM_SECTION, "account", ui->get_user_account()); + cfg->set(SM_SECTION, "name", ui->get_user_name()); + cfg->set(SM_SECTION, "icon", ui->get_user_icon_url()); + cfg->set(SM_SECTION, "login", ui->is_user_login() ? std::string("1") : std::string("0")); + cfg->save(); + BOOST_LOG_TRIVIAL(info) << "sm_persist_login: saved account (login=" << ui->is_user_login() << ", has_token=" << !ui->get_user_token().empty() << ")"; +} + +void sm_announce_login() +{ + auto* ui = wxGetApp().sm_get_userinfo(); + if (!ui || !ui->is_user_login()) + return; + ui->notify(); + BOOST_LOG_TRIVIAL(info) << "sm_announce_login: re-announced " << ui->get_user_account(); +} + +void sm_restore_login() +{ + auto* ui = wxGetApp().sm_get_userinfo(); + AppConfig* cfg = wxGetApp().app_config; + if (!ui || !cfg) + return; + + const std::string token = cfg->get(SM_SECTION, "token"); + if (token.empty()) { + BOOST_LOG_TRIVIAL(info) << "sm_restore_login: no saved token"; + return; + } + + ui->set_user_id(cfg->get(SM_SECTION, "id")); + ui->set_user_account(cfg->get(SM_SECTION, "account")); + ui->set_user_name(cfg->get(SM_SECTION, "name")); + ui->set_user_icon_url(cfg->get(SM_SECTION, "icon")); + ui->set_user_token(token); + ui->set_user_login(true); // also notify()s any login-state subscribers + BOOST_LOG_TRIVIAL(info) << "sm_restore_login: restored account " << ui->get_user_account(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/SMAccountPersist.hpp b/src/slic3r/GUI/SMAccountPersist.hpp new file mode 100644 index 00000000000..70a30d3e837 --- /dev/null +++ b/src/slic3r/GUI/SMAccountPersist.hpp @@ -0,0 +1,33 @@ +#ifndef slic3r_GUI_SMAccountPersist_hpp_ +#define slic3r_GUI_SMAccountPersist_hpp_ + +// Persist the Snapmaker account across restarts. The account (token/id/account/ +// name/icon) is otherwise kept only in memory (GUI_App::m_login_userinfo) and is +// never written to disk, so every launch starts logged out. These helpers save it +// to app_config on login, restore it at startup, and clear it on logout. +// +// Deliberately a tiny standalone header (not GUI_App.hpp, which ~hundreds of TUs +// include) so touching login persistence doesn't trigger a full rebuild. +// +// NOTE: stores the bearer token in plaintext app_config (matches how this fork +// stores other settings). An OS keychain would be more secure — future work. + +namespace Slic3r { namespace GUI { + +// Write the current in-memory Snapmaker account to app_config (and save()). +void sm_persist_login(); + +// Restore the Snapmaker account from app_config into memory at startup. No-op if +// no token was saved. Does not re-validate the token against the server; a stale +// token simply fails on the next API call and the user re-logs in. +void sm_restore_login(); + +// Re-announce the current account to whatever has subscribed since. sm_restore_login() +// runs before the webview exists, so the notify() inside it reaches nobody; this is +// called once the webview is up so the page learns the session it was too early to hear +// about. No-op when logged out. +void sm_announce_login(); + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_SMAccountPersist_hpp_ diff --git a/src/slic3r/GUI/SSWCP.cpp b/src/slic3r/GUI/SSWCP.cpp index f7c61032629..8a204015bce 100644 --- a/src/slic3r/GUI/SSWCP.cpp +++ b/src/slic3r/GUI/SSWCP.cpp @@ -2121,6 +2121,8 @@ void SSWCP_MachineOption_Instance::process() sw_UploadAsyncTimelapseInstance(); } else if (m_cmd == "sw_DeleteCameraTimelapse") { sw_DeleteCameraTimelapse(); + } else if (m_cmd == "sw_GetPrintHistory") { + sw_GetPrintHistory(); } else if (m_cmd == "sw_GetCameraTimelapseInstance") { sw_GetCameraTimelapseInstance(); } else if (m_cmd == "sw_ServerClientManagerSetUserinfo") { @@ -3764,6 +3766,28 @@ void SSWCP_MachineOption_Instance::sw_GetCameraTimelapseInstance() }); } } +// Completed jobs, from Moonraker's own history store. Params are passed through +// verbatim: {limit, start, since, before, order} are what server.history.list takes. +void SSWCP_MachineOption_Instance::sw_GetPrintHistory() +{ + { + std::shared_ptr host = nullptr; + wxGetApp().get_connect_host(host); + + if (!host) { + handle_general_fail(-1, "Connection lost!"); + return; + } + + auto weak_self = std::weak_ptr(shared_from_this()); + host->async_get_print_history(m_param_data, [weak_self](const json& response) { + auto self = weak_self.lock(); + if (self) { + SSWCP_Instance::on_mqtt_msg_arrived(self, response); + } + }); + } +} void SSWCP_MachineOption_Instance::sw_GetDeviceDataStorageSpace() { { @@ -6931,14 +6955,12 @@ void SSWCP_MqttAgent_Instance::sw_mqtt_set_engine() wxGetApp().mainframe->update_slice_print_status(MainFrame::eEventPlateUpdate); if (!wxGetApp().mainframe->m_printer_view->isSnapmakerPage()) { - wxString url = wxString::FromUTF8(LOCALHOST_URL + std::to_string(wxGetApp().get_page_http_port()) + - "/web/flutter_web/index.html?path=2"); + wxString url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::DeviceTab); auto real_url = wxGetApp().get_international_url(url); wxGetApp().mainframe->load_printer_url(real_url); } else { if (reload_device_view) { - wxString url = wxString::FromUTF8(LOCALHOST_URL + std::to_string(wxGetApp().get_page_http_port()) + - "/web/flutter_web/index.html?path=2"); + wxString url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::DeviceTab); auto real_url = wxGetApp().get_international_url(url); wxGetApp().mainframe->load_printer_url(real_url); @@ -7164,6 +7186,7 @@ std::unordered_set SSWCP::m_machine_option_cmd_list = { "sw_UploadAsyncTimelapseInstance", "sw_DeleteCameraTimelapse", "sw_GetCameraTimelapseInstance", + "sw_GetPrintHistory", "sw_ServerClientManagerSetUserinfo", "sw_DefectDetactionConfig", "sw_PrinterDefectDetection", diff --git a/src/slic3r/GUI/SSWCP.hpp b/src/slic3r/GUI/SSWCP.hpp index 6a5e61c6951..e4ac535f7b3 100644 --- a/src/slic3r/GUI/SSWCP.hpp +++ b/src/slic3r/GUI/SSWCP.hpp @@ -436,6 +436,7 @@ class SSWCP_MachineOption_Instance : public SSWCP_Instance void sw_UploadAsyncTimelapseInstance(); void sw_DeleteCameraTimelapse(); void sw_GetCameraTimelapseInstance(); + void sw_GetPrintHistory(); void sw_DefectDetactionConfig(); void sw_PrinterDefectDetection(); diff --git a/src/slic3r/GUI/WebPreprintDialog.cpp b/src/slic3r/GUI/WebPreprintDialog.cpp index 78cb142eb4b..81880ba261f 100644 --- a/src/slic3r/GUI/WebPreprintDialog.cpp +++ b/src/slic3r/GUI/WebPreprintDialog.cpp @@ -17,11 +17,8 @@ END_EVENT_TABLE() WebPreprintDialog::WebPreprintDialog() : wxDialog((wxWindow*)(wxGetApp().mainframe), wxID_ANY, _L("Print preset")) { - m_prePrint_url = wxString::FromUTF8(LOCALHOST_URL + std::to_string(wxGetApp().get_page_http_port()) + - "/web/flutter_web/index.html?path=4"); - - m_preSend_url = wxString::FromUTF8(LOCALHOST_URL + std::to_string(wxGetApp().get_page_http_port()) + - "/web/flutter_web/index.html?path=5"); + m_prePrint_url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::PrintAndUpload); + m_preSend_url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::UploadOnly); SetBackgroundColour(*wxWHITE); // Create the webview with about:blank; the actual page will be loaded by run() diff --git a/src/slic3r/GUI/WebSMUserLoginDialog.cpp b/src/slic3r/GUI/WebSMUserLoginDialog.cpp index 9b23e17535b..dc3e76bb99c 100644 --- a/src/slic3r/GUI/WebSMUserLoginDialog.cpp +++ b/src/slic3r/GUI/WebSMUserLoginDialog.cpp @@ -26,6 +26,7 @@ #include #include #include "sentry_wrapper/SentryWrapper.hpp" +#include "SMAccountPersist.hpp" using namespace std; using namespace nlohmann; @@ -221,6 +222,7 @@ void SMUserLogin::OnNavigationRequest(wxWebViewEvent &evt) sentryReportLog(SENTRY_LOG_TRACE, userInfo, BP_LOGIN); wxGetApp().sm_get_userinfo()->set_user_token(token); wxGetApp().sm_get_userinfo()->set_user_login(true); + Slic3r::GUI::sm_persist_login(); // remember the session across restarts } }) .on_error([&](std::string body, std::string error, unsigned status) { diff --git a/src/slic3r/GUI/filamentsync/FilamentColorMapBox.cpp b/src/slic3r/GUI/filamentsync/FilamentColorMapBox.cpp index 9cc80205592..8a7e2970922 100644 --- a/src/slic3r/GUI/filamentsync/FilamentColorMapBox.cpp +++ b/src/slic3r/GUI/filamentsync/FilamentColorMapBox.cpp @@ -249,9 +249,11 @@ void FilamentColorMapBox::onPaint(wxPaintEvent&) { gdc.SetTextForeground(g_bodyTextColor); gdc.SetFont(Label::Body_10); - const wxString type = m_belowFilament.m_type.empty() - ? wxString("NONE") - : wxString(m_belowFilament.m_type); + const wxString type = !m_belowFilament.m_label.empty() + ? wxString::FromUTF8(m_belowFilament.m_label) + : (m_belowFilament.m_type.empty() + ? wxString("NONE") + : wxString(m_belowFilament.m_type)); const wxSize te = gdc.GetTextExtent(type); gdc.DrawText(type, (w - te.x) / 2, splitY + FromDIP(g_nameTextY)); diff --git a/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.cpp b/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.cpp index a7813f2e470..1ea8b79e460 100644 --- a/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.cpp +++ b/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.cpp @@ -25,9 +25,6 @@ constexpr int g_containerBorderW = 1; // border width constexpr int g_labelGap = 20; // gap between labels and cards constexpr int g_cardGap = 20; // gap between cards (Figma: gap-[20px]) -// Label vertical positioning: align with card top-bar text (y=7) -constexpr int g_labelDesignTopMargin = 6; // align "Source Filament" with top bar text -constexpr int g_labelVerticalGap = 24; // gap between "Source Filament" and "Printer Filament" // ============================================================ // Colours @@ -61,10 +58,9 @@ namespace GUI int FilamentColorMapBoxGroup::GetGridCols() { - const wxString lang = wxGetApp().app_config->get_language_code(); - if (lang.StartsWith("zh") || lang.StartsWith("ja") || lang.StartsWith("ko")) - return 5; - return 4; + // 6 columns wide: with the left label column gone the dialog width fits 6 cards, + // so the conceivable maximum of 16 spools (4 ACE units x 4 slots) lands in 3 rows. + return 6; } FilamentColorMapBoxGroup::FilamentColorMapBoxGroup(wxWindow* parent, @@ -79,42 +75,20 @@ FilamentColorMapBoxGroup::FilamentColorMapBoxGroup(wxWindow* parent, SetBackgroundColour(g_containerBg); Bind(wxEVT_PAINT, &FilamentColorMapBoxGroup::onPaint, this); - // ---- Outer horizontal sizer (after padding) ---- - auto* rowSizer = new wxBoxSizer(wxHORIZONTAL); - - // ---- Left label column ---- - auto* labelSizer = new wxBoxSizer(wxVERTICAL); - - m_pLabelDesign = new Label(this, _L("Source Filament")); - m_pLabelDesign->SetFont(Label::Body_14); - m_pLabelDesign->SetForegroundColour(g_labelTextColor); - m_pLabelDesign->SetBackgroundStyle(wxBG_STYLE_TRANSPARENT); - m_pLabelDesign->SetBackgroundColour(g_containerBg); - labelSizer->AddSpacer(FromDIP(g_labelDesignTopMargin)); - labelSizer->Add(m_pLabelDesign, 0, wxEXPAND); - labelSizer->AddSpacer(FromDIP(g_labelVerticalGap)); - - m_pLabelMachine = new Label(this, _L("Printer Filament")); - m_pLabelMachine->SetFont(Label::Body_14); - m_pLabelMachine->SetForegroundColour(g_labelTextColor); - m_pLabelMachine->SetBackgroundStyle(wxBG_STYLE_TRANSPARENT); - m_pLabelMachine->SetBackgroundColour(g_containerBg); - labelSizer->Add(m_pLabelMachine, 0, wxEXPAND); - - // Constrain both labels to the same width so the label column has a - // predictable size regardless of which translation ends up longer. - { - int w1 = m_pLabelDesign->GetTextExtent(m_pLabelDesign->GetLabel()).GetWidth(); - int w2 = m_pLabelMachine->GetTextExtent(m_pLabelMachine->GetLabel()).GetWidth(); - int maxW = std::max(w1, w2); - m_pLabelDesign->SetMinSize(wxSize(maxW, -1)); - m_pLabelMachine->SetMinSize(wxSize(maxW, -1)); - } - labelSizer->AddStretchSpacer(1); - - rowSizer->Add(labelSizer, 0, wxEXPAND | wxRIGHT, FromDIP(g_labelGap)); - - // ---- Right card grid — 5 columns, auto rows ---- + // ---- Caption ---- + // Replaces the old left "Source Filament" / "Printer Filament" label column, which only lined + // up with the first card row and read as broken once the cards wrapped to a second row (more + // than four filaments - which a U1 reaches as soon as the ACE is in the list). A single caption + // above the grid scales to any number of filaments; each card already shows the project + // filament on top and the mapped printer filament below. + m_pCaption = new Label( + this, _L("Map each project filament (top) to a printer filament (below)")); + m_pCaption->SetFont(Label::Body_12); + m_pCaption->SetForegroundColour(g_labelTextColor); + m_pCaption->SetBackgroundStyle(wxBG_STYLE_TRANSPARENT); + m_pCaption->SetBackgroundColour(g_containerBg); + + // ---- Card grid - N columns, auto rows ---- auto* cardGridSizer = new wxFlexGridSizer(0, GetGridCols(), FromDIP(g_cardGap), FromDIP(g_cardGap)); int boxIndex = 0; @@ -131,11 +105,12 @@ FilamentColorMapBoxGroup::FilamentColorMapBoxGroup(wxWindow* parent, ++boxIndex; } - rowSizer->Add(cardGridSizer, 0, wxALIGN_TOP); - - // ---- Padding around the row ---- + // ---- Caption above the grid, padded ---- auto* outerSizer = new wxBoxSizer(wxVERTICAL); - outerSizer->Add(rowSizer, 1, wxEXPAND | wxALL, FromDIP(g_containerPadding)); + outerSizer->Add(m_pCaption, 0, wxLEFT | wxTOP | wxRIGHT, FromDIP(g_containerPadding)); + outerSizer->AddSpacer(FromDIP(g_labelGap)); + outerSizer->Add(cardGridSizer, 0, + wxLEFT | wxRIGHT | wxBOTTOM | wxALIGN_TOP, FromDIP(g_containerPadding)); SetSizer(outerSizer); Layout(); } @@ -255,7 +230,10 @@ int FilamentColorMapBoxGroup::getHeightForRowCount(int rows) const int vGap = FromDIP(g_cardGap); int gridH = rows * cardH + std::max(0, rows - 1) * vGap; int pad = FromDIP(g_containerPadding); - return gridH + 2 * pad; + // Account for the caption above the grid (and the gap below it), or the container is sized + // too short and clips the last card row. + int header = m_pCaption ? m_pCaption->GetBestSize().y + FromDIP(g_labelGap) : 0; + return header + gridH + 2 * pad; } void FilamentColorMapBoxGroup::bindMappingChangedCallback(std::function cb) diff --git a/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.hpp b/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.hpp index 6cf1ccb73dc..306fe238693 100644 --- a/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.hpp +++ b/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.hpp @@ -61,8 +61,7 @@ class FilamentColorMapBoxGroup : public wxPanel MachineFilamentPicker* m_pPicker = nullptr; - Label* m_pLabelDesign = nullptr; - Label* m_pLabelMachine = nullptr; + Label* m_pCaption = nullptr; std::function m_mappingChangedCallback = nullptr; }; diff --git a/src/slic3r/GUI/filamentsync/FilamentData.hpp b/src/slic3r/GUI/filamentsync/FilamentData.hpp index 41733da77a9..f50b8ecf89f 100644 --- a/src/slic3r/GUI/filamentsync/FilamentData.hpp +++ b/src/slic3r/GUI/filamentsync/FilamentData.hpp @@ -27,6 +27,18 @@ struct FilamentData std::string m_name; std::string m_type; FilamentColor m_color; + // Optional display label (e.g. "T1 · PLA", "A1-S2 · PETG"); when set, the picker + // shows this instead of the bare type. Does not affect matching (which uses + // m_type/m_name). Used to surface the U1-toolhead / ACE-slot source. + std::string m_label; + // When true, this is an explicit, selectable "Assign None" action (unmap the + // project filament). The picker normally ignores clicks on NONE-typed rows + // (they represent empty machine slots); this flag opts a row back into being + // clickable without changing that behaviour for other printers. + bool m_assign_none = false; + // When true, the row is shown for context but greyed and not selectable (e.g. a + // U1 toolhead that is fed by the ACE — map the ACE slot instead of the head). + bool m_disabled = false; }; struct MixedFilamentPreviewInfo diff --git a/src/slic3r/GUI/filamentsync/FilamentSyncAlgorithm.cpp b/src/slic3r/GUI/filamentsync/FilamentSyncAlgorithm.cpp index 03fa374cac5..2acfca5683c 100644 --- a/src/slic3r/GUI/filamentsync/FilamentSyncAlgorithm.cpp +++ b/src/slic3r/GUI/filamentsync/FilamentSyncAlgorithm.cpp @@ -90,6 +90,17 @@ float delta_e_ciede2000(uint8_t r1, uint8_t g1, uint8_t b1, return DeltaE00(L1, a1, b1v, L2, a2, b2v); } +// A row the user cannot pick is not a candidate for the matcher either. Two kinds qualify: +// NONE-typed rows (an empty slot or head, and the explicit "Assign None" action) and disabled +// ones - a U1 toolhead fed by an ACE, which carries whatever slot is loaded at this moment. The +// picker greys both; auto-matching to one anyway is how a mapping ends up on a row the user is +// then not allowed to change, and on the U1 it duplicates a spool that is already in the list +// under its own slot. +static bool is_selectable_source(const GUI::FilamentData& d) +{ + return !is_none_filament(d) && !d.m_disabled; +} + std::vector compute_color_match( const std::vector& design_data, const std::vector& machine_data) @@ -125,7 +136,7 @@ std::vector compute_color_match( // Pass 1: same filament type for (size_t j = 0; j < machineCount; ++j) { - if (is_none_filament(machine_data[j])) + if (!is_selectable_source(machine_data[j])) continue; if (machine_data[j].m_type != designType) continue; @@ -140,7 +151,7 @@ std::vector compute_color_match( // Pass 2 (fallback): any non-NONE machine filament if (bestIdx < 0) { for (size_t j = 0; j < machineCount; ++j) { - if (is_none_filament(machine_data[j])) + if (!is_selectable_source(machine_data[j])) continue; float dist = DeltaE00(designL, designA, designB, machineLab[j].L, machineLab[j].a, machineLab[j].b); @@ -165,7 +176,7 @@ std::vector compute_direct_override( std::vector validPos; for (size_t j = 0; j < machine_data.size(); ++j) { - if (!is_none_filament(machine_data[j])) + if (is_selectable_source(machine_data[j])) validPos.push_back(j); } diff --git a/src/slic3r/GUI/filamentsync/MachineFilamentPicker.cpp b/src/slic3r/GUI/filamentsync/MachineFilamentPicker.cpp index d2d8cd6061e..2e98505622b 100644 --- a/src/slic3r/GUI/filamentsync/MachineFilamentPicker.cpp +++ b/src/slic3r/GUI/filamentsync/MachineFilamentPicker.cpp @@ -104,7 +104,7 @@ class PickerContentPanel : public wxPanel wxClientDC dc(this); dc.SetFont(Label::Body_10); for (const auto& data : m_dataList) { - wxString typeStr = wxString::FromUTF8(data.m_type); + wxString typeStr = wxString::FromUTF8(data.m_label.empty() ? data.m_type : data.m_label); int tw = dc.GetTextExtent(typeStr).x; if (tw > maxTextWidthPx) { maxTextWidthPx = tw; @@ -115,7 +115,18 @@ class PickerContentPanel : public wxPanel int contentWidthPx = FromDIP(g_textX + g_contentOffsetX) + maxTextWidthPx + FromDIP(g_textRightPadding); int actualWidthPx = std::max(minWidthPx, contentWidthPx); - wxSize sz(actualWidthPx, FromDIP(g_popupHeight)); + // Height grows with the row count so every entry is visible: the popup has no + // scrolling, and a U1 lists four toolheads plus every ACE slot, which is well past + // the fixed height this was written for. Matches the row layout used in + // render()/hitTestRow - symmetric g_firstRowY padding top and bottom - and keeps + // the old fixed height for an empty list. Capped as a sanity bound. + const int rowCount = std::max(1, static_cast(m_dataList.size())); + const int cappedRows = std::min(rowCount, 16); + int heightDip = g_firstRowY * 2 + (cappedRows - 1) * g_itemStepY + g_itemRowH; + if (m_dataList.empty()) + heightDip = g_popupHeight; + + wxSize sz(actualWidthPx, FromDIP(heightDip)); SetSize(sz); SetMinSize(sz); SetMaxSize(sz); @@ -199,7 +210,8 @@ class PickerContentPanel : public wxPanel drawCheckmark(dc, cx, cy, cw, ch); } - bool isNone = isNoneEntry(data); + // Disabled rows (e.g. an ACE-fed toolhead) are greyed like NONE entries. + bool isNone = isNoneEntry(data) || data.m_disabled; // ---- Colour circle (bitmap) ---- int circleCxPx = FromDIP(g_circleCx + g_contentOffsetX); @@ -224,7 +236,8 @@ class PickerContentPanel : public wxPanel // ---- Filament type text ---- dc.SetFont(labelFont); dc.SetTextForeground(isNone ? wxColour(0xBB, 0xBB, 0xBB) : g_textColor); - wxString typeStr = wxString::FromUTF8(data.m_type.empty() ? "NONE" : data.m_type); + wxString typeStr = !data.m_label.empty() ? wxString::FromUTF8(data.m_label) + : wxString::FromUTF8(data.m_type.empty() ? "NONE" : data.m_type); int textX = FromDIP(g_textX + g_contentOffsetX); int textH = dc.GetTextExtent(typeStr).y; int textY = FromDIP(y) + static_cast(std::round((FromDIP(g_itemRowH) - textH) / 2.0)); @@ -264,8 +277,9 @@ class PickerContentPanel : public wxPanel return; } - // Ignore clicks on NONE (empty slot) entries - if (isNoneEntry(m_dataList[row])) { + // Ignore clicks on NONE (empty slot) and disabled (e.g. ACE-fed toolhead) + // entries, unless the row is an explicit "Assign None" action. + if ((isNoneEntry(m_dataList[row]) || m_dataList[row].m_disabled) && !m_dataList[row].m_assign_none) { return; } diff --git a/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.cpp b/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.cpp index a4d10684c28..fafa684313f 100644 --- a/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.cpp +++ b/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.cpp @@ -94,16 +94,30 @@ constexpr const char* g_block3BorderColor = "#F0F0F0"; constexpr const char* g_block3SeparatorColor = "#F3F4F6"; constexpr const char* g_secondaryHoverBg = "#F3F4F6"; +// Overwrite mode's result: the machine's own filaments, in machine order. +// +// `wholeMachine` decides how many. Normally it is the count the project already had, because +// overwriting a project that has objects in it must not renumber the filaments those objects +// reference. With nothing on the plate there is no such constraint and no design to preserve, so +// the project takes the machine's whole inventory - which is the only way a four-slot ACE ever +// shows four filaments. +// +// Skipped either way: NONE-typed rows (an empty slot, or the explicit "Assign None" action) and +// disabled rows. A disabled row is a toolhead fed by an ACE; it carries whatever slot is loaded +// at this moment, so taking it as well as that slot would list the same spool twice. std::vector collectVisibleOverwriteMachineFilaments( const std::vector& machineDataList, - size_t designCount) + size_t designCount, + bool wholeMachine) { std::vector visibleMachine; - size_t visibleCount = std::min(designCount, machineDataList.size()); - visibleMachine.reserve(visibleCount); - for (size_t i = 0; i < visibleCount; ++i) { - if (!Slic3r::GUI::is_none_filament(machineDataList[i])) - visibleMachine.push_back(machineDataList[i]); + visibleMachine.reserve(machineDataList.size()); + for (const auto& data : machineDataList) { + if (Slic3r::GUI::is_none_filament(data) || data.m_disabled) + continue; + if (!wholeMachine && visibleMachine.size() >= designCount) + break; + visibleMachine.push_back(data); } return visibleMachine; } @@ -170,7 +184,7 @@ SyncFilamentColorDialog::SyncFilamentColorDialog(wxWindow* parent, loadCoverPreview(); }); - m_bNeedScroll = m_pFilamentColorMapBoxGroup->exceedsRowCount(2); + m_bNeedScroll = m_pFilamentColorMapBoxGroup->exceedsRowCount(3); // --- Preview wrapper (was Block 3) --- auto* previewWrapper = new wxPanel(block, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); @@ -232,7 +246,7 @@ SyncFilamentColorDialog::SyncFilamentColorDialog(wxWindow* parent, // Assemble: scroll widgets are always created so the scrollbar // can be shown / hidden when the mode changes. // ============================================================ - m_maxViewportHeight = m_pFilamentColorMapBoxGroup->getHeightForRowCount(2); + m_maxViewportHeight = m_pFilamentColorMapBoxGroup->getHeightForRowCount(3); { int boxCount = m_pFilamentColorMapBoxGroup->getVisibleBoxCount(); int gridCols = FilamentColorMapBoxGroup::GetGridCols(); @@ -397,7 +411,8 @@ std::vector SyncFilamentColorDialog::getSyncDataList() const return dataList; if (!m_bMappingMode) { - return collectVisibleOverwriteMachineFilaments(m_machineDataList, m_designDataList.size()); + return collectVisibleOverwriteMachineFilaments(m_machineDataList, m_designDataList.size(), + m_bOverwriteWholeMachine); } dataList = m_pFilamentColorMapBoxGroup->getCurFilamentList(); @@ -418,8 +433,9 @@ std::vector SyncFilamentColorDialog::getSyncDataList() const return dataList; } -void SyncFilamentColorDialog::setOverwriteMode() +void SyncFilamentColorDialog::setOverwriteMode(bool whole_machine) { + m_bOverwriteWholeMachine = whole_machine; if (m_pModeToggle) m_pModeToggle->setSelected(g_modeOverwrite); onModeChanged(g_modeOverwrite); @@ -625,7 +641,9 @@ void SyncFilamentColorDialog::loadCoverPreview() std::vector filamentMapping; if (!m_bMappingMode) { - filamentMapping = collectVisibleOverwriteMachineFilaments(m_machineDataList, m_designDataList.size()); + // Same list the sync will apply, so the preview cannot promise something else. + filamentMapping = collectVisibleOverwriteMachineFilaments(m_machineDataList, m_designDataList.size(), + m_bOverwriteWholeMachine); } else if (m_pFilamentColorMapBoxGroup) { filamentMapping = m_pFilamentColorMapBoxGroup->getCurFilamentList(); } @@ -816,11 +834,11 @@ void SyncFilamentColorDialog::updateScrollState() if (!m_pFilamentColorMapBoxGroup || !m_pScrollBar || !m_pScrollGap || !m_pScrollViewport) return; - bool needScroll = m_pFilamentColorMapBoxGroup->exceedsRowCount(2); + bool needScroll = m_pFilamentColorMapBoxGroup->exceedsRowCount(3); m_bNeedScroll = needScroll; // Recalculate heights (visible count may have changed) - m_maxViewportHeight = m_pFilamentColorMapBoxGroup->getHeightForRowCount(2); + m_maxViewportHeight = m_pFilamentColorMapBoxGroup->getHeightForRowCount(3); { int boxCount = m_pFilamentColorMapBoxGroup->getVisibleBoxCount(); int gridCols = FilamentColorMapBoxGroup::GetGridCols(); diff --git a/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.hpp b/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.hpp index 18e47d2f093..ac5de6925e2 100644 --- a/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.hpp +++ b/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.hpp @@ -44,7 +44,10 @@ class SyncFilamentColorDialog : public wxDialog void setMixedFilamentInfos(const std::vector& infos); bool shouldDeleteMixedFilaments() const; - void setOverwriteMode(); + // `whole_machine` is for the case where the plate is empty: the project then takes every + // filament the machine reports rather than the count it happened to have. With objects on the + // plate it must stay false - they reference filaments by index. + void setOverwriteMode(bool whole_machine = false); bool Layout() override; @@ -102,6 +105,7 @@ class SyncFilamentColorDialog : public wxDialog std::vector m_filamentIdRemap; std::vector m_mixedFilamentInfos; bool m_bMappingMode = true; + bool m_bOverwriteWholeMachine = false; bool m_hasMixedFilaments = false; bool m_shouldDeleteMixedFilaments = false; }; diff --git a/src/slic3r/Utils/MQTT.cpp b/src/slic3r/Utils/MQTT.cpp index be03d2613d3..f8cda903633 100644 --- a/src/slic3r/Utils/MQTT.cpp +++ b/src/slic3r/Utils/MQTT.cpp @@ -9,6 +9,31 @@ namespace { +/* + * 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. Every method below is documented to answer `false` rather than throw - and only + * Disconnect() actually did. So a printer that was simply switched off took the whole + * application down at startup: + * + * terminate called after throwing an instance of 'mqtt::exception' + * what(): MQTT error [-1]: TCP/TLS connect failure + * + * Both callers of Connect() in MoonRaker.cpp already branch on the `false`; nothing about + * the contract needed to change, only its being kept. This is that guard, in one place so + * the four methods cannot report a failure four different ways. + */ +bool mqtt_failed(const char* op, const std::string& server, const std::exception& e, + std::string& msg) +{ + BOOST_LOG_TRIVIAL(error) << "[MQTT_INFO] " << op << " failed: " << e.what() + << ", Server: " << server; + msg = std::string(op) + " failed: " + e.what(); + return false; +} + // Paho throws when disconnect() is called while already disconnected / handle gone. bool is_soft_disconnect_error(const mqtt::exception& e) { @@ -142,7 +167,7 @@ bool MqttClient::Connect(std::string& msg) return true; } - { + try { { auto ssl_opts = connOpts_.get_ssl_options(); BOOST_LOG_TRIVIAL(debug) << "[MQTT_INFO] SSL config info:" @@ -194,6 +219,11 @@ bool MqttClient::Connect(std::string& msg) BOOST_LOG_TRIVIAL(info) << "[MQTT_INFO] Successfully connected to MQTT server"; msg = "success"; return true; + } catch (const std::exception& e) { + // An unreachable host arrives here, and it is the ordinary case rather than an + // exceptional one: the printer is off, or on another network. + connected_.store(false, std::memory_order_release); + return mqtt_failed("connect", server_address_, e, msg); } } @@ -247,7 +277,7 @@ bool MqttClient::Subscribe(const std::string& topic, int qos, std::string& msg) return false; } - { + try { BOOST_LOG_TRIVIAL(info) << "[MQTT_INFO] Subscribing to MQTT topic '" << topic << "' with QoS " << qos; mqtt::token_ptr subtok = client_->subscribe(topic, qos, nullptr, subListener_); if (!subtok->wait_for(std::chrono::seconds(5))) { @@ -258,6 +288,8 @@ bool MqttClient::Subscribe(const std::string& topic, int qos, std::string& msg) add_topic_to_resubscribe(topic, qos); msg = "success"; return true; + } catch (const std::exception& e) { + return mqtt_failed("subscribe", server_address_, e, msg); } } @@ -272,7 +304,7 @@ bool MqttClient::Unsubscribe(const std::string& topic, std::string& msg) return false; } - { + try { BOOST_LOG_TRIVIAL(info) << "[MQTT_INFO] Unsubscribing from MQTT topic '" << topic << "'"; mqtt::token_ptr unsubtok = client_->unsubscribe(topic); if (!unsubtok->wait_for(std::chrono::seconds(5))) { @@ -283,6 +315,8 @@ bool MqttClient::Unsubscribe(const std::string& topic, std::string& msg) remove_topic_from_resubscribe(topic); msg = "success"; return true; + } catch (const std::exception& e) { + return mqtt_failed("unsubscribe", server_address_, e, msg); } } @@ -301,7 +335,7 @@ bool MqttClient::Publish(const std::string& topic, const std::string& payload, i mqtt::message_ptr pubmsg = mqtt::make_message(topic, payload); pubmsg->set_qos(qos); - { + try { BOOST_LOG_TRIVIAL(debug) << "[MQTT_INFO] Publishing message to topic '" << topic << "' with QoS " << qos; mqtt::token_ptr pubtok = client_->publish(pubmsg); /*if (!pubtok->wait_for(std::chrono::seconds(5))) { @@ -310,7 +344,11 @@ bool MqttClient::Publish(const std::string& topic, const std::string& payload, i }*/ msg = "success"; return true; - } + } catch (const std::exception& e) { + // CheckConnected() above is a snapshot, not a lock: the link can drop between + // that answer and this call, and publish() then throws MQTTASYNC_DISCONNECTED. + return mqtt_failed("publish", server_address_, e, msg); + } } // Set callback function for handling incoming messages @@ -354,7 +392,16 @@ bool MqttClient::CheckConnected() return false; } - if (!check_future.get()) { + // .get() rethrows anything the probe threw, and this is called from the destructor's + // path as well as the panel's - so it answers the same way the others do. + try { + if (!check_future.get()) { + connected_.store(false, std::memory_order_release); + return false; + } + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << "[MQTT_INFO] connection check failed: " << e.what() + << ", Server: " << server_address_; connected_.store(false, std::memory_order_release); return false; } diff --git a/src/slic3r/Utils/MoonRaker.cpp b/src/slic3r/Utils/MoonRaker.cpp index 456086555ee..634d617828f 100644 --- a/src/slic3r/Utils/MoonRaker.cpp +++ b/src/slic3r/Utils/MoonRaker.cpp @@ -2294,6 +2294,33 @@ void Moonraker_Mqtt::async_get_timelapse_instance(const nlohmann::json& targets, } } +// completed print jobs, newest first - Moonraker's own history store +void Moonraker_Mqtt::async_get_print_history(const nlohmann::json& targets, std::function callback) +{ + auto& wcp_loger = GUI::WCP_Logger::getInstance(); + BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] Starting request print history"; + wcp_loger.add_log("Starting request print history", false, "", "Moonraker_Mqtt", "info"); + std::string method = "server.history.list"; + + json params = json::object(); + + params = targets; + + if (!send_to_request(method, params, true, callback, + [callback, &wcp_loger]() { + BOOST_LOG_TRIVIAL(warning) << "[Moonraker_Mqtt] request print history timed out"; + wcp_loger.add_log("request print history timed out", false, "", "Moonraker_Mqtt", "warning"); + json res; + res["error"] = "timeout"; + callback(res); + }) && + callback) { + BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] failed to send request print history"; + wcp_loger.add_log("failed to send request print history", false, "", "Moonraker_Mqtt", "error"); + callback(json::value_t::null); + } +} + //get the mechine local storage space void Moonraker_Mqtt::async_get_userdata_space(const nlohmann::json& targets, std::function callback) { diff --git a/src/slic3r/Utils/MoonRaker.hpp b/src/slic3r/Utils/MoonRaker.hpp index e26c0f54be3..ea0df9f09de 100644 --- a/src/slic3r/Utils/MoonRaker.hpp +++ b/src/slic3r/Utils/MoonRaker.hpp @@ -141,6 +141,7 @@ class Moonraker : public PrintHost virtual void async_delete_camera_timelapse(const nlohmann::json& targets, std::function) {} virtual void async_get_timelapse_instance(const nlohmann::json& targets, std::function) {} + virtual void async_get_print_history(const nlohmann::json& targets, std::function) {} virtual void async_defect_detaction_config(const nlohmann::json& targets, std::function) {} @@ -285,6 +286,7 @@ class Moonraker_Mqtt : public Moonraker virtual void async_delete_camera_timelapse(const nlohmann::json& targets, std::function) override; virtual void async_get_timelapse_instance(const nlohmann::json& targets, std::function) override; + virtual void async_get_print_history(const nlohmann::json& targets, std::function) override; virtual void async_get_userdata_space(const nlohmann::json& targets, std::function) override; diff --git a/src/slic3r/Utils/PrintHost.hpp b/src/slic3r/Utils/PrintHost.hpp index 039b3ee7efa..4c30e009f62 100644 --- a/src/slic3r/Utils/PrintHost.hpp +++ b/src/slic3r/Utils/PrintHost.hpp @@ -178,6 +178,7 @@ class PrintHost virtual void async_delete_camera_timelapse(const nlohmann::json& targets, std::function) {} virtual void async_get_timelapse_instance(const nlohmann::json& targets, std::function) {} + virtual void async_get_print_history(const nlohmann::json& targets, std::function) {} virtual void async_get_userdata_space(const nlohmann::json& targets, std::function) {} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 9c958028511..b278ff90725 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -3,6 +3,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests.cpp test_3mf.cpp + test_ace_mmu_topology.cpp test_aabbindirect.cpp test_clipper_offset.cpp test_clipper_utils.cpp diff --git a/tests/libslic3r/test_ace_mmu_topology.cpp b/tests/libslic3r/test_ace_mmu_topology.cpp new file mode 100644 index 00000000000..2f95dadfc42 --- /dev/null +++ b/tests/libslic3r/test_ace_mmu_topology.cpp @@ -0,0 +1,194 @@ +#include + +#include "libslic3r/AceMmuTopology.hpp" + +using namespace Slic3r; +using namespace Slic3r::AceMmu; + +// The live U1 at 192.168.2.242, read 22 Aug 2026: mode "head", one connected ACE 2 Pro at 38% RH +// feeding Toolhead 4 from slot 2, three heads on their own feeders, four PETG spools. +// +// head_ace is the trap this fixture exists for. The machine reports {"0":0,"1":1,"2":2,"3":0} with +// exactly ONE unit plugged in, so reading it as wiring invents units 1 and 2. Only head 3 is +// actually ACE-fed, and `feeder` is what says so. +static const char *LIVE_HEAD_MODE = R"({ + "device_count": 1, + "active_device": 0, + "ace_head": 3, + "mode": "head", + "printer_state": "idle", + "ace_status": "ready", + "ace_temp": 29, + "head_ace": { "0": 0, "1": 1, "2": 2, "3": 0 }, + "aces": [ + { + "idx": 0, "connected": true, "protocol": "v2", "status": "ready", + "temp": 29, "humidity": 38, + "dryer": { "status": "stop", "target_temp": 0, "duration": 0, "remain_time": 0 }, + "slots": [ + { "idx": 0, "state": "ready", "raw": 1, "rfid": 0, "material": "PETG", + "brand": "Kingroon", "sku": "", "subtype": "Basic", "color": "#83AFFF", "source": "override" }, + { "idx": 1, "state": "ready", "raw": 1, "rfid": 0, "material": "PETG", + "brand": "Kingroon", "sku": "", "subtype": "Basic", "color": "#8FA7C8", "source": "override" }, + { "idx": 2, "state": "ready", "raw": 1, "rfid": 0, "material": "PETG", + "brand": "Generic", "sku": "", "subtype": "Basic", "color": "#632c2c", "source": "override" }, + { "idx": 3, "state": "ready", "raw": 1, "rfid": 0, "material": "PETG", + "brand": "Kingroon", "sku": "", "subtype": "Basic", "color": "#C47053", "source": "override" } + ] + } + ], + "toolheads": [ + { "idx": 0, "ace": null, "slot": null, "material": "PLA", "color": "#f44336", + "filament_detected": true, "manual": false, "feeder": true, "source": null }, + { "idx": 1, "ace": null, "slot": null, "material": "PLA", "color": "#ffffdc", + "filament_detected": true, "manual": false, "feeder": true, "source": null }, + { "idx": 2, "ace": null, "slot": null, "material": "", "color": "#ffffff", + "filament_detected": false, "manual": false, "feeder": true, "source": null }, + { "idx": 3, "ace": 0, "slot": 2, "material": "PETG", "color": "#632c2c", + "filament_detected": true, "manual": false, "feeder": false, "source": null } + ] +})"; + +// A printer preset carries these three keys and nothing else this code reads. +static DynamicConfig preset_with(AceMode mode, std::vector units, std::vector caps) +{ + DynamicConfig cfg; + cfg.set_key_value("ace_mode", new ConfigOptionEnum(mode)); + cfg.set_key_value("ace_head_unit", new ConfigOptionInts(std::move(units))); + cfg.set_key_value("ace_head_capacity", new ConfigOptionInts(std::move(caps))); + return cfg; +} + +TEST_CASE("ace_mode_from_string takes the firmware's own words", "[AceMmuTopology]") +{ + REQUIRE(ace_mode_from_string("normal") == amNormal); + REQUIRE(ace_mode_from_string("head") == amHead); + REQUIRE(ace_mode_from_string("multi") == amMulti); + // A word we do not know claims no ACE rather than guessing one. + REQUIRE(ace_mode_from_string("") == amNormal); + REQUIRE(ace_mode_from_string("something-new") == amNormal); +} + +TEST_CASE("the live U1 maps to one ACE-fed head and three feeders", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + REQUIRE(snap.mode == "head"); + REQUIRE(snap.device_count == 1); + REQUIRE(snap.toolheads.size() == 4); + + const AceTopology topo = ace_topology_of(snap, 4); + REQUIRE(topo.mode == amHead); + REQUIRE(topo.unit == std::vector{-1, -1, -1, 0}); + REQUIRE(topo.cap == std::vector{1, 1, 1, 4}); +} + +TEST_CASE("head_ace never turns a stock feeder into a unit", "[AceMmuTopology]") +{ + // The regression this file exists for: head_ace names a unit for all four heads, but three of + // them are feeders and only one ACE is plugged in. + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + for (size_t h = 0; h < 3; ++h) + REQUIRE(snap.toolheads[h].ace.has_value()); // parse_ace_state did fill it in from head_ace + + const AceTopology topo = ace_topology_of(snap, 4); + for (size_t h = 0; h < 3; ++h) { + REQUIRE(topo.unit[h] == -1); + REQUIRE(topo.cap[h] == 1); + } +} + +TEST_CASE("capacity follows the unit's own slot list", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + REQUIRE(ace_unit_capacity(snap, 0) == 4); + // A unit the machine never mentioned still offers the protocol's four. + REQUIRE(ace_unit_capacity(snap, 3) == SLOT_COUNT); +} + +TEST_CASE("a head with more heads than the machine reports falls back to its feeder", "[AceMmuTopology]") +{ + // A preset for a four-head machine read against a snapshot that only described two. + const AceSnapshot snap = parse_ace_state(std::string(R"({"mode":"head","toolheads":[ + {"idx":0,"ace":1,"feeder":false},{"idx":1,"feeder":true}]})")); + const AceTopology topo = ace_topology_of(snap, 4); + REQUIRE(topo.unit == std::vector{1, -1, -1, -1}); + REQUIRE(topo.cap == std::vector{4, 1, 1, 1}); +} + +TEST_CASE("normal mode wires nothing", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(R"({"mode":"normal","device_count":1, + "toolheads":[{"idx":0,"feeder":true},{"idx":1,"feeder":true}]})")); + const AceTopology topo = ace_topology_of(snap, 2); + REQUIRE(topo.mode == amNormal); + REQUIRE(topo.unit == std::vector{-1, -1}); + REQUIRE(topo.cap == std::vector{1, 1}); +} + +TEST_CASE("one unit may feed two heads", "[AceMmuTopology]") +{ + // ACE_SET_HEAD_ACE binds a head to a unit and says nothing about the reverse, so this is legal + // and both heads read the same unit at its full capacity. + const AceSnapshot snap = parse_ace_state(std::string(R"({"mode":"head","device_count":1, + "aces":[{"idx":0,"connected":true,"protocol":"v2","slots":[ + {"idx":0,"state":"ready","raw":1},{"idx":1,"state":"ready","raw":1}, + {"idx":2,"state":"ready","raw":1},{"idx":3,"state":"ready","raw":1}]}], + "toolheads":[{"idx":0,"ace":0,"feeder":false},{"idx":1,"ace":0,"feeder":false}]})")); + const AceTopology topo = ace_topology_of(snap, 2); + REQUIRE(topo.unit == std::vector{0, 0}); + REQUIRE(topo.cap == std::vector{4, 4}); +} + +TEST_CASE("ace_head_agrees is the corner tick's whole claim", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + const AceTopology topo = ace_topology_of(snap, 4); + + SECTION("a preset written by the sync agrees on every head") { + const DynamicConfig cfg = preset_with(amHead, topo.unit, topo.cap); + for (size_t h = 0; h < 4; ++h) + REQUIRE(ace_head_agrees(cfg, topo, h)); + } + + SECTION("the wrong mode disagrees on every head, whatever the wiring says") { + const DynamicConfig cfg = preset_with(amNormal, topo.unit, topo.cap); + for (size_t h = 0; h < 4; ++h) + REQUIRE_FALSE(ace_head_agrees(cfg, topo, h)); + } + + SECTION("only the head that moved loses its tick") { + const DynamicConfig cfg = preset_with(amHead, {-1, -1, -1, 1}, {1, 1, 1, 4}); + REQUIRE(ace_head_agrees(cfg, topo, 0)); + REQUIRE(ace_head_agrees(cfg, topo, 1)); + REQUIRE(ace_head_agrees(cfg, topo, 2)); + REQUIRE_FALSE(ace_head_agrees(cfg, topo, 3)); // preset says ACE 2, printer says ACE 1 + } + + SECTION("a stock feeder carries no unit, so a stale one does not break agreement") { + // ace_head_unit keeps whatever it last held when a head goes back to its feeder; the + // capacity is what says "feeder", and comparing the unit there would mark a false change. + const DynamicConfig cfg = preset_with(amHead, {2, 0, 3, 0}, {1, 1, 1, 4}); + for (size_t h = 0; h < 4; ++h) + REQUIRE(ace_head_agrees(cfg, topo, h)); + } + + SECTION("a preset shorter than the machine disagrees rather than reading past its end") { + const DynamicConfig cfg = preset_with(amHead, {-1, -1}, {1, 1}); + REQUIRE(ace_head_agrees(cfg, topo, 0)); + REQUIRE_FALSE(ace_head_agrees(cfg, topo, 3)); + } +} + +TEST_CASE("units are named as the printer names them", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + REQUIRE(snap.units.size() == 1); + REQUIRE(ace_unit_model(snap.units[0]) == "ACE 2 Pro"); + + AceUnit v1; + v1.protocol = "v1"; + REQUIRE(ace_unit_model(v1) == "ACE Pro"); + + AceUnit unknown; + REQUIRE(ace_unit_model(unknown).empty()); +} From c98be0ded6de3daed7e09fb6b01ca8ac8485c2a1 Mon Sep 17 00:00:00 2001 From: physicsG Date: Wed, 2 Sep 2026 15:08:47 +0200 Subject: [PATCH 4/5] The Device page is the only Device page (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing loads `flutter_web?path=2` any more and the Original / Rebuilt switcher is gone. Getting there was not a matter of deleting a button: the shipped page was doing one job for Orca that nothing else does. `sw_UpdateMachineFilamentInfo` reads like "set the filament on the printer" and never leaves the host. It writes `machine_filaments` and `m_connect_machine_info_list` — the only source the Prepare sidebar's filament combos have, one writer, three clearers. The reconstruction was not calling it, so retiring the shipped page silently deleted the sidebar's filament sync. `core/orcasync.js` does it now, on every state change, guarded by a comparison and re-armed on reconnect. Two controls WERE calling it, for something else: `setFilament` and Print Preferences sent a flat `print_task_config` patch, which fails the host's first `if` and never reaches the printer. Neither awaits its own request, so both did nothing, silently, on a real machine. What writes the printer is `SET_PRINT_FILAMENT_CONFIG` and `SET_PRINT_PREFERENCES`, recovered verbatim from the shipped bundle — including that the first is single-quoted, the second is not, and that Auto Leveling goes out as BED_LEVEL though the machine reports `auto_bed_leveling`. The print popup had the same two calls. `nozzle_diameters` is not in `print_task_config` at all; it comes from `machine.system_info`. Three bugs that only running the app could show: - A side effect must not ride on the repaint. The sync was inside render()'s requestAnimationFrame; WebKit fires none into a view that is not composited, which the Device tab is not at startup. It ran in every suite and never once in Orca. - 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. The Home tab showed signed-out over a live session that was in the config the whole time. - Finishing the path lit up code that had never run. With `m_connect_machine_info_list` non-empty for the first time, `refresh_filament_sync_marks()` segfaulted at address 0: SyncMarkOverlay is a child of the filament combo, both removal sites Destroy() the combo without dropping the pointer, and SetSynced() reaches the virtual IsShown() through a freed vtable. The invariant was a comment on the member and nothing else. 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. Also: `load_current_presets()` no longer runs on the stack of a webview message handler, and `handle_web_message` catches rather than letting a malformed command unwind through wxWidgets. The Snapmaker login is untouched — sw_UserLogin still opens Orca's own SMUserLogin on id.snapmaker.com, and no reconstructed page has a login form. The rail can now ask for that dialog rather than offering one. Verified in Orca against 811002511261022618B3. Suites: unit 208/208, conformance 165/165 (7 new), coverage clean, WebKit 61/61 and 54/54, drive/orca-sync.js 23/23 (new). --- CLAUDE.md | 39 ++ docs/ace-mmu/printer-panel-mockup.html | 157 ++++++++ docs/u1-webui/00-shared/03-serving-in-orca.md | 80 ++-- .../02-device-page/12-orca-integration.md | 343 ++++++++++++++++++ docs/u1-webui/STATUS.md | 99 ++++- docs/u1-webui/tools/check_coverage.py | 5 +- resources/web/device_page/js/app.js | 42 ++- resources/web/device_page/js/core/orcasync.js | 225 ++++++++++++ resources/web/device_page/js/core/session.js | 13 + resources/web/device_page/js/core/store.js | 10 + resources/web/device_page/js/page-commands.js | 31 +- .../control/control-commands.js | 23 +- .../filament/filament-commands.js | 49 ++- .../device_page/js/widgets/rail-commands.js | 48 ++- resources/web/print_processing/js/app.js | 34 +- resources/web/shared/js/mockhost.js | 203 ++++++++++- resources/web/shared/js/protocol.js | 86 ++++- .../web/shared/tests/conformance_test.py | 58 +++ resources/web/shared/tests/drive/README.md | 2 + resources/web/shared/tests/drive/orca-sync.js | 167 +++++++++ src/slic3r/GUI/GUI_App.cpp | 50 +-- src/slic3r/GUI/GUI_App.hpp | 13 +- src/slic3r/GUI/Plater.cpp | 19 +- src/slic3r/GUI/PrinterWebView.cpp | 60 --- src/slic3r/GUI/PrinterWebView.hpp | 13 - src/slic3r/GUI/SSWCP.cpp | 60 ++- src/slic3r/GUI/SSWCP.hpp | 6 + 27 files changed, 1749 insertions(+), 186 deletions(-) create mode 100644 docs/u1-webui/02-device-page/12-orca-integration.md create mode 100644 resources/web/device_page/js/core/orcasync.js create mode 100644 resources/web/shared/tests/drive/orca-sync.js diff --git a/CLAUDE.md b/CLAUDE.md index 707e3009c4d..f739efc831d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,22 @@ The reconstructed U1 web UI needs **no rebuild** — `build/resources` is a syml `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; @@ -279,6 +295,29 @@ 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, diff --git a/docs/ace-mmu/printer-panel-mockup.html b/docs/ace-mmu/printer-panel-mockup.html index c5948ef50fd..280d9f599e9 100644 --- a/docs/ace-mmu/printer-panel-mockup.html +++ b/docs/ace-mmu/printer-panel-mockup.html @@ -21,6 +21,12 @@ --sb-text:#26292c; --sb-dim:#7a8288; --sb-label:#7d8b96; --sb-inset:#f6f7f8; --ace-hood:#e8e8e8; --ace-base:#cfcfcf; --ace-bay:#ffffff; --orca:#00ae42; + /* Candidates for the agreement mark - see "What colour the mark is". The live panel + above still draws --orca; these are what the swatches below are painted with. */ + --mk-a:#0C63E2; /* the Device page's --accent, as it is */ + --mk-b:#0C63E2; /* same blue, but only on the MARK - see option B */ + --mk-c:#0C63E2; /* light half of the paired option */ + --mk-c-dark:#4D93F0; --view:#dfe1e3; --toast:#2b2f33; --toast-ink:#f1f3f4; } @media (prefers-color-scheme: dark){ @@ -38,6 +44,7 @@ --sb-text:#e6eaed; --sb-dim:#98a1a8; --sb-label:#8fa0ac; --sb-inset:#1f2428; --ace-hood:#3d444a; --ace-base:#2f353a; --ace-bay:#59616a; --orca:#00c24b; + --mk-c:var(--mk-c-dark); --view:#15191c; --toast:#31363b; --toast-ink:#f1f3f4; } } @@ -55,6 +62,7 @@ --sb-text:#e6eaed; --sb-dim:#98a1a8; --sb-label:#8fa0ac; --sb-inset:#1f2428; --ace-hood:#3d444a; --ace-base:#2f353a; --ace-bay:#59616a; --orca:#00c24b; + --mk-c:var(--mk-c-dark); --view:#15191c; --toast:#31363b; --toast-ink:#f1f3f4; } @@ -131,6 +139,29 @@ border-left:17px solid transparent;} .corner::after{content:"\2713";position:absolute;top:-16px;right:1px;font-size:9px;line-height:1;color:#fff;} + /* ---- the mark-colour comparison ------------------------------------------ + Same geometry as .corner above, painted with a variable so the four swatches differ + in exactly one thing. Drawn on both grounds because the mark has two contrast jobs: + the triangle against the card, and the white check against the triangle. */ + .mkgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:14px 0 4px;} + .mkopt h5{margin:0 0 8px;font-size:13px;font-weight:600;color:var(--text);} + .mkopt .pair{display:flex;gap:8px;} + .mkcard{position:relative;flex:1 1 0;height:62px;border-radius:8px;overflow:hidden; + border:1px solid var(--sb-field-line);background:var(--sb-panel);} + .mkcard.on-dark{background:#181c20;border-color:#272d33;} + .mkcard .corner{--orca:var(--mk);} + .mkcard .lab{position:absolute;left:8px;bottom:6px;font-size:11px;color:var(--sb-label);} + .mkcard.on-dark .lab{color:#98a2aa;} + /* B is the only option whose point is not the mark's own colour but what sits NEXT to + it, so its swatch carries the green affordance too - two accents in one card, which + is the thing being chosen. */ + .mkcard .aff{position:absolute;left:8px;top:8px;font-size:10px;font-weight:600; + padding:2px 6px;border-radius:4px;background:var(--orca);color:#fff;} + .mknum{margin:8px 0 0;font-size:12px;color:var(--dim);line-height:1.5;} + .mknum b{color:var(--text);} + .mknum .bad{color:var(--bad);font-weight:600;} + .mknum .good{color:var(--text);font-weight:600;} + /* per-head boxes — Bambu's Left/Right Nozzle panes, wrapped to 2x2 */ .heads{display:grid;grid-template-columns:1fr 1fr;gap:7px;} .hbox{position:relative;border:1px solid var(--sb-field-line);border-radius:8px;padding:7px 9px 9px; @@ -490,6 +521,132 @@

One unit, several heads — and the two bugs i 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

diff --git a/docs/u1-webui/00-shared/03-serving-in-orca.md b/docs/u1-webui/00-shared/03-serving-in-orca.md index 8bdd600c7bc..45a131e52d4 100644 --- a/docs/u1-webui/00-shared/03-serving-in-orca.md +++ b/docs/u1-webui/00-shared/03-serving-in-orca.md @@ -1,29 +1,46 @@ # Serving the reconstructions in Orca -Orca now loads the reconstructed Device tab and print-processing popup instead of the -shipped Flutter bundle. This page covers the wiring, how to switch back, and what you -lose by running the reconstructions. +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 switch +## The Device tab has one implementation -One config key decides it, read in one function: +**`?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 -wxString GUI_App::get_u1_surface_url(U1Surface surface) const -{ - const bool reconstructed = app_config->get_bool("u1_reconstructed_ui"); - ... -} +case U1Surface::DeviceTab: + path = "/web/device_page/index.html"; + break; ``` -| `u1_reconstructed_ui` | Device tab | Print popup | -|---|---|---| -| `true` *(default)* | `/web/device_page/index.html` | `/web/print_processing/index.html?mode=print` \| `?mode=upload` | -| `false` | `/web/flutter_web/index.html?path=2` | `…?path=4` \| `…?path=5` | +`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. +restart. No rebuild needed. The key no longer affects the Device tab. ## What changed in the C++ @@ -53,7 +70,7 @@ active. Untouched, because they are different surfaces we did not reconstruct: `?path=0` (`WebViewDialog`), `?path=3` (`deviceControlOld`), `?path=discovery` -(`WebDeviceDialog`). +(`WebDeviceDialog`). See "The bundle is still shipped" below. ## Why the URLs resolve @@ -77,20 +94,35 @@ modules would refuse to execute. and symlinked into the build tree during development, so the new directories are picked up with no CMake change. -## What you lose +## 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. -The reconstructions are deliberately narrower than the shipped bundle. With -`u1_reconstructed_ui` on, these are **not available**: +## What the reconstructions still do not do -- Device discovery, pairing and authorisation -- Cloud login and the account surface -- The machine file browser, camera and timelapse - 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 -The Device tab covers live machine control; the popup covers the filament-mapping and -preferences flow. For anything else, switch back. +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 diff --git a/docs/u1-webui/02-device-page/12-orca-integration.md b/docs/u1-webui/02-device-page/12-orca-integration.md new file mode 100644 index 00000000000..c930d2e0a84 --- /dev/null +++ b/docs/u1-webui/02-device-page/12-orca-integration.md @@ -0,0 +1,343 @@ +# The Device page as the only Device page + +What had to be true before `flutter_web?path=2` could stop being loaded, and the three +things that were not. + +The reconstruction had been the default Device tab for a while, with an Original / +Rebuilt switcher above it. That switcher is gone and the tab is +`/web/device_page/index.html` unconditionally. Getting there was not a matter of deleting +a button: **the shipped Device page was doing work for Orca that nothing else does**, and +while both existed it was possible for that work to happen by accident. + +## The load-bearing thing the shipped page was doing + +`sw_UpdateMachineFilamentInfo` reads like a command that sets the filament on the printer. +It is not. It never leaves the host — +[`bridge-methods.json`](../data/bridge-methods.json) has it as *answered inside Orca* — +and what it does there is: + +```cpp +// SSWCP.cpp, update_filament_info() +auto& filaments = wxGetApp().preset_bundle->machine_filaments; +auto& machine_nozzles = wxGetApp().preset_bundle->m_connect_machine_info_list; +... +if (need_load_preset) { tmp_filaments = filaments; wxGetApp().load_current_presets(); } +``` + +Those two containers are what the sidebar's filament combo boxes are built from +(`PresetComboBoxes.cpp:330`, `:1396`, `:1968`). They have **exactly one writer** — that +function — and three places that clear them (`SSWCP.cpp:4186`, `:6618`, +`GUI_App.cpp:7531`). So the chain is: + +``` +printer → print_task_config → the Device page → sw_UpdateMachineFilamentInfo + → machine_filaments → the Prepare page's filament rows +``` + +with the Device page in the middle and nothing else able to stand there. The shipped page +also mirrored the same payload into Orca's in-memory cache under `deviceFilamentInfo` +(`sw_SetCache`), which `sw_ConnectOtherMachine` replays on reconnect — a second path to +the same one function. + +**The reconstruction was not calling it.** So on a branch with only the rebuilt page, the +Prepare sidebar never learned what was loaded in the machine. + +### And the two panels that thought they were calling it + +Worse than not calling it: two controls *were* calling it, for something else entirely. + +```js +// filament-commands.js, before +send(CMD.UPDATE_MACHINE_FILAMENT_INFO, + { filament_type: types, filament_color: argb, filament_color_rgba: rgba }, + 'set filament'); +``` + +The host's first line is: + +```cpp +if (!m_param_data.count("objects") || !m_param_data["objects"].is_array()) { + handle_general_fail(-1, "param [objects] required or wrong type!"); +``` + +A flat `print_task_config` patch has no `objects`, so every one of those clicks failed +before doing anything — and neither control awaits its own request, by design, so nothing +on screen ever said so. Naming a filament from the panel, and applying the print +preferences, did nothing at all. The print-processing popup had the same two calls with +the same shape. + +This was invisible to every suite. `u1_bridge.py` **refuses** the command in as many words +("writes Orca's per-device filament record"), which is correct and is why `--real` never +caught it; the simulator had no handler and answered a generic ok; and +`check_coverage.py` counts a command as implemented when a panel's module references it, +which this did. + +## What writes the printer + +Four macros, none of which appears in `printer.gcode.help` — recovered from the shipped +bundle, where the strings survive dart2js: + +```dart +// A.aPV: setPrintFilamentConfig +n = A.x(["CONFIG_EXTRUDER",c,"FILAMENT_TYPE",d,"FILAMENT_SUBTYPE",e, + "FILAMENT_COLOR_RGBA",b,"SAVE",g,"VENDOR",a0], j, t.X) +m = J.iE(n).i1(0,new A.aPW()) // drop null values + .dN(0,new A.aPX(),j) // A.aPX: `KEY='value'` + .bM(0," ") +l = "SET_PRINT_FILAMENT_CONFIG " + m + +// A.a8Z: setPrePrintConfiguration +"SET_PRINT_EXTRUDER_MAP CONFIG_EXTRUDER=" + k.a + " MAP_EXTRUDER=" + k.b + "\n" +"SET_PRINT_USED_EXTRUDERS EXTRUDERS=" + values.join(",") + "\n" +"SET_PRINT_PREFERENCES " + prefs.map(A.aPU).join(" ") // A.aPU: `KEY=value`, upper-cased +``` + +| | | +|---|---| +| `SET_PRINT_FILAMENT_CONFIG` | `KEY='value'` — **single-quoted**. That is what lets a vendor with a space in it be one argument | +| `SET_PRINT_PREFERENCES` | `KEY=value` — bare, key upper-cased from a field name | +| `SET_PRINT_EXTRUDER_MAP`, `SET_PRINT_USED_EXTRUDERS` | `KEY=value` — bare too, and built by plain concatenation rather than through the quoting map. Integers and a comma list; nothing needs quoting | +| `SAVE='1'` | the bundle's own; without it the machine forgets on the next restart | +| a null argument | **dropped**, not sent empty. Absent means *leave this field alone*; empty means *clear it* | + +Only `SET_PRINT_FILAMENT_CONFIG` is in `printer.gcode.help` (as a multiACE wrapper). The +other three are registered from Python without help strings, exactly as `T0`–`T3`, +`PARK_EXTRUDER` and the pick/park pair are. **Absence from the help table is not +evidence**, and reading it as evidence has now cost time twice. + +### The preference whose name is not its name + +```dart +m = A.x(["bed_level",!1,"flow_calibrate",!1,"time_lapse_camera",!1], n, t.y) // checkboxes +l = A.x(["bed_level", 0,"flow_calibrate", 0,"time_lapse_camera", 0], n, p) // the wire +``` + +The machine **reports** `auto_bed_leveling`; the macro **takes** `BED_LEVEL`. The bundle +keeps two parallel maps of the same three toggles — bools for the checkboxes, ints for the +line — and the int one is what the macro is built from, so the values go out as `1`/`0` +and not `true`/`false`. + +`PRINT_PREFERENCES` in `protocol.js` therefore carries `key` *and* `arg`, because for one +of the three they are different words. Sending `AUTO_BED_LEVELING=1` is an argument the +macro does not have, and a G-code macro answers `ok` to those. + +## What the payload to Orca has to be + +`core/orcasync.js`. The key set is the shipped page's own `PrintTaskConfig.O()`, verbatim: + +```dart +["filament_vendor","filament_type","filament_sub_type","filament_color", + "filament_color_rgba","filament_official","filament_sku","extruder_map_table", + "extruders_used","filament_edit","filament_exist","time_lapse_camera", + "auto_bed_leveling","flow_calibrate","shaper_calibrate","auto_replenish_filament", + "can_auto_replenish","auto_replenish_index","filament_color_multi","nozzle_diameters"] +``` + +Three constraints, all read out of the C++, and all three fatal to get wrong because +**`handle_web_message` had no try/catch above any of it**: + +| | | +|---|---| +| `objects: [{key, value}]` | `key` is the serial; `value` is a JSON **string**. Both type-checked | +| the serial must be connected | `get_device_info(sn)` then `info.connected`, else `"sn does not exist!"` / `"The machine is not connected!"` | +| every array as long as `filament_official` | the loop runs over its size and indexes the rest with the same `i` — `nozzle_diameters[i]` and `filament_color_rgba[i]` included. A short array is an out-of-range **throw inside a wxWidgets event handler** | + +That last one is now also fixed on the C++ side: `handle_web_message` catches, logs and +carries on. A page that sends a malformed command must not be able to terminate the app — +the same shape as the Paho fix, where an unreachable printer aborted Orca at launch +because a documented `return false` was a `throw`. + +### `nozzle_diameters` comes from somewhere else entirely + +`update_filament_info` requires the key and reads each entry with `get()`. +`print_task_config` **has no such field** on this firmware — measured on +`811002511261022618B3`, which is why the bundle's model defaults it to `[]`. + +It is published in one place: + +``` +GET /machine/system_info + → system_info.product_info.nozzle_diameter = [0.4, 0.4, 0.4, 0.4] +``` + +which is `sw_GetMachineSystemInfo`. The page was already making that call — in +`refreshAll`, with the reply thrown away. It is kept now (`store.systemInfo`), and the +numbers are stringified on the way out, because a number there is a `nlohmann` type_error +rather than a value Orca can use. + +The simulator was answering that command with a flat `{firmware_version, model, sn}` that +no U1 has ever sent, which is exactly why the one field only this call carries went +unnoticed for so long. It answers the measured shape now — and two more shapes were wrong +in the same object: + +| | mock said | machine says | +|---|---|---| +| `extruders_used` | `[0, 1, 2, 3]` | `[false, false, false, false]` — one flag per toolhead | +| `extruder_map_table` | `{0:0, 1:1, 2:2, 3:3}` | a flat array of **32** — one entry per possible tool | + +## When it is sent, and the one that only Orca could show + +On every **state change**, guarded by a comparison — `state.onChange`, not `render()`. + +It was on the repaint first, which looked right: `print_task_config` only moves on a state +push, and a push is what causes the frame. Every suite agreed. **In Orca it never ran +once.** + +`render()` defers its work to a `requestAnimationFrame`, and **WebKit does not fire +animation frames into a view that is not being composited** — which the Device tab is not, +at startup, while the Home tab is the selected page. Everything else carried on exactly as +it should: the session connected, the snapshot arrived with 24 objects, the log said so. +A page nobody is looking at does not need to repaint, and every other thing render() does +is drawing. + +This one is not drawing. Its consequence is on **another tab**, so it may not wait for +someone to look at this one. That is the general rule the bug leaves behind: + +> A side effect must not ride on the repaint. Drawing may be deferred until someone is +> looking; telling another part of the application something may not. + +Nothing offline can see it. `run_webkit.py` shows the page in a real window, so its frames +fire; `--real` does too. It took starting the app and reading `[rebuilt-device]` in Orca's +own log, which said nothing at all where it should have said either "synced" or a reason. +That silence is why the decline path names itself now — `filament sync not sent: ` — +and why `conformance_test.py` holds the wiring: the check reads `render()`'s +`requestAnimationFrame` body and fails if the sync is inside it. + +Verified in Orca, against `811002511261022618B3`, 2026-09-01: + +``` +[WCP] [rebuilt-device] filament inventory synced to Orca (state) +[WCP] [rebuilt-device] filament inventory synced to Orca (stream) +``` + +It costs one `JSON.stringify` on the two pushes a minute an idle machine makes. +`load_current_presets()` rebuilds every combo box in the sidebar, so re-sending an +unchanged inventory is not free and is not done. The one case where an unchanged inventory +*must* be re-sent is a **reconnect** — Orca clears `machine_filaments` when a machine +disconnects — so `startStateStream()` calls `resyncOrca()`, which forgets what was sent and +re-reads the nozzle sizes. Every fresh stream ('boot', 'after connect', 'refresh') goes +through it. + +## What making it work switched on + +Telling Orca what is in the printer had a consequence nobody had had to think about: it +made `m_connect_machine_info_list` non-empty for the first time on this firmware, and +several things are gated on exactly that. Code that has never run is code that has never +been tested, and two of the three things below had been wrong the whole time without +anyone being able to see it. + +### The Prepare page's sync marks crashed the app + +``` +#0 0x0000000000000000 +#1 Slic3r::GUI::Sidebar::refresh_filament_sync_marks() +#2 Slic3r::GUI::Sidebar::show_sync_filament_dialog() +#3 Slic3r::GUI::Sidebar::finish_printer_sync() +``` + +A jump to address zero. `SyncMarkOverlay` is a **child window of the filament combo** it +rides on, and `Sidebar::priv::filament_sync_marks` keeps a raw pointer to it — with the +invariant written in a comment on the member, *"One per filament combo"*, and enforced +nowhere. Both places that shrink the row list do this: + +```cpp +(*p->combos_filament[last]).Destroy(); // takes the child mark with it +p->combos_filament.pop_back(); // and filament_sync_marks keeps the address +``` + +`refresh_filament_sync_marks()` then calls `mark->SetSynced()`, whose first statement is +`IsShown()` — `virtual` in `wxWindow`. A virtual call through a freed vtable is a call to +0, which is frame #0. + +It could not fire before, because the function returns early unless +`m_ace_read || !m_connect_machine_info_list.empty()`, and that list had exactly one writer: +the sync this page now does. Fixed at both removal sites (`resize()` to match the combos) +and again in the loop itself, which now runs only as far as there are combos — the cost of +being wrong there is a null jump rather than a wrong mark, so it is worth both. + +### The "Machine Filament" section had never been drawn + +Both places that build it filter with + +```cpp +if (currentNozzleInfo != machine_nozzles) continue; +``` + +comparing the printer preset's `0.4` against `ConnectMachineInfo::nozzle_info`. That field +comes from `nozzle_diameters`, which **this firmware does not publish in +`print_task_config`** — so the shipped page sent `[]`, every `nozzle_info` was `""`, the +comparison never matched and the section stayed empty. Reading the real diameters out of +`machine.system_info` is what fills it. + +### And a preset reload was running inside the webview's message handler + +`update_filament_info()` called `load_current_presets()` directly — on the stack of a +`wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED` handler. That is `update_pages()`, +`force_print_bed_update()`, `load_current_preset()` on every tab and `rebuild_page_tree()` +on the model tabs: widgets destroyed and rebuilt while GTK is still dispatching the +webview's message. It is a `CallAfter` now, like every other GUI action in `SSWCP.cpp`. + +It was also happening **twice, 5.6 ms apart**, because `resyncOrca()` ran after the +snapshot and its `forget()` landed on a push the snapshot had just made. See "When it is +sent" above. + +## The login is Orca's, and stays Orca's + +`sw_UserLogin` opens `SMUserLogin`: a native wxDialog that loads +`https://id.snapmaker.com?from=orca` over the network. It has nothing to do with the +Flutter bundle, and nothing about the dialog changed here. + +**One thing next to it did**, reported as "it shows my name, then a popup, then I am +logged out". The account was never lost — `Snapmaker_Orca.conf` still held the token and +`login: 1`, and every start logged `sm_restore_login: restored account …`. What was lost +was the *announcement*: + +```cpp +void SSWCP_UserLogin_Instance::sw_SubscribeUserLoginState() +{ + wxGetApp().m_user_login_subscribers[m_webview] = weak_ptr; // and that was all +} +``` + +A subscription that answers nothing reports only **changes**, and the change a page most +needs to hear happened before the page existed: the account is restored in +`init_app_config()`, whose `notify()` reaches no subscribers. `post_init()` re-announces — +but that runs when the frame is built, and the measured gap to the Flutter bundle's first +bridge call is **4.1 seconds** (22:16:39.7 → 22:16:43.8), because 5 MB of `main.dart.js` +has to parse first. The page subscribed into the silence after the announcement and sat on +its default, which is signed out. + +The subscription replies with the state as it stands now, built from the same fields +`SMUserInfo::notify()` sends. No timing can lose it after that. Same shape of bug as the +one above, from the other direction: **a push-only channel needs an initial value**, or +whoever attaches late never learns what is already true. + +What changed is that the Device page can now **ask for it**. The rail's device menu ends +with `Sign in to Snapmaker…` when signed out and `Signed in as ` when not. That is +the whole of the account surface on this page, and deliberately so: there is no login form +here, there are no password inputs anywhere in the reconstruction, and a page that +collected a Snapmaker credential itself would be the wrong answer to a requirement that +says to use the original screen. + +The reply to `sw_UserLogin` arrives **before** the modal is shown, so it says nothing about +whether anyone signed in — the state is re-read on a clock for six seconds afterwards and +then left alone. Someone who cancelled the dialog is not signed in, and a page that keeps +asking never settles. + +## What is checked + +`drive/orca-sync.js`, 22 checks, and it is the only thing that can see any of this: +everything here is about what *leaves* the page rather than what is drawn. + +``` +python3 resources/web/shared/tests/run_webkit.py --size 1920x1080 \ + --drive resources/web/shared/tests/drive/orca-sync.js +``` + +The simulator's `sw_UpdateMachineFilamentInfo` validates **exactly** what the C++ +validates, including the array-length rule, so a payload that would throw inside a +wxWidgets event handler fails in WebKitGTK instead. Being lax there is what let a flat +patch look like a working call for as long as it did. + +`check_coverage.py` has `sw_UpdateMachineFilamentInfo` in `OWNED_ELSEWHERE` rather than +attributed to a panel, because nobody presses it — the state stream does. diff --git a/docs/u1-webui/STATUS.md b/docs/u1-webui/STATUS.md index 2b5cfadabc3..634e2d4740d 100644 --- a/docs/u1-webui/STATUS.md +++ b/docs/u1-webui/STATUS.md @@ -24,8 +24,14 @@ study — the drawings for all three modes. | `resources/web/device_page/` | Reconstructed **Device tab** — see below | | `resources/web/print_processing/` | Reconstructed **print-processing popup** | -Orca shows either implementation. `PrinterWebView` has an **Original / Rebuilt** switcher; -the choice persists in the `u1_reconstructed_ui` app-config key. +**The Device tab is the reconstruction, and only the reconstruction.** Nothing loads +`flutter_web?path=2` any more and the Original / Rebuilt switcher is gone. The print +popup still has both, on the `u1_reconstructed_ui` app-config key; the bundle also stays +for the Home tab, the preset dialog and the Add-Device dialog, and the Snapmaker login is +Orca's own `SMUserLogin` on `id.snapmaker.com` and never was the bundle's. What had to be +true first — chiefly that this page, and not the shipped one, tells Orca what filament is +in the machine — is +[02-device-page/12-orca-integration.md](02-device-page/12-orca-integration.md). ## How the Device page is put together @@ -141,6 +147,95 @@ that says the operation finished. simulator holds three. `drive/storage-real.js` is the witness, `drive/storage-paging.js` poses the collision offline. +## Retiring the shipped Device page (2026-09-01) + +Three defects, all of them invisible to every suite, all of them from **one command whose +name reads like the opposite of what it does**. `sw_UpdateMachineFilamentInfo` never +reaches the printer: it writes ORCA's filament record, which is the only source the +Prepare sidebar's filament combo boxes have. + +- **Nothing was calling it.** The shipped Device page was, and it was the only caller. So + with the reconstruction as the tab, Orca never learned what was loaded — a whole feature + lost by switching pages, and nothing on either side said so. +- **Two controls were calling it for the wrong thing.** Naming a filament, and applying the + print preferences, sent a flat `print_task_config` patch to it. The host wants + `{objects:[{key,value}]}` and fails at its first `if`; neither control awaits its own + request; so both did nothing, silently, on a real machine. The print popup had the same + two calls. What writes the printer is `SET_PRINT_FILAMENT_CONFIG` and + `SET_PRINT_PREFERENCES`, recovered verbatim from the shipped bundle — including that the + first is single-quoted, the second is not, and that Auto Leveling goes out as + **`BED_LEVEL`** while the machine reports it as `auto_bed_leveling`. +- **A malformed payload could take the app down.** `handle_web_message` had no try/catch + above 129 commands' worth of JSON indexing, and `update_filament_info` indexes five + arrays with one loop counter. Fixed at the top rather than at the instance. + +And two more that only **starting the app** could show, both about a message that never +arrived rather than a message that was wrong: + +- **A side effect must not ride on the repaint.** The sync was called from `render()`, + inside its `requestAnimationFrame` — which looked right, since `print_task_config` only + moves on a state push and a push is what causes the frame. It ran in every suite and + **never once in Orca**: WebKit does not fire animation frames into a view that is not + being composited, and the Device tab is not the selected page at startup. Everything + else worked, because everything else render() does is drawing, and a page nobody is + looking at does not need to draw. This one's consequence is on *another tab*. It hangs + off `state.onChange` now, and `conformance_test.py` reads the rAF body and fails if it + goes back. +- **A push-only channel needs an initial value.** Reported as "it shows my name, then a + popup, then I am logged out". The account was never lost — the token and `login: 1` were + still in the config, and every start logged `sm_restore_login: restored account …`. + `sw_SubscribeUserLoginState` registered the subscriber and replied with *nothing*, so it + could only ever report changes; the account is restored in `init_app_config()`, whose + `notify()` reaches nobody, and `post_init()`'s re-announce runs **4.1 seconds** before + the Flutter bundle finishes parsing and subscribes. The subscription answers with the + current state now. + +Why no suite saw the first three: `u1_bridge.py` **refuses** that command with a written +reason (which is correct — it is Orca's record, not the printer's), so `--real` cannot +reach it; the simulator had no handler; and `check_coverage.py` counts a reference from a +panel's module, which there was. The simulator now validates it exactly as the C++ does, and +`drive/orca-sync.js` is the witness. The generalisation worth keeping: **a command that +Orca answers itself is not covered by anything that talks to the printer**, and there are +eight of them. + +### And making it work switched on code that had never run + +Telling Orca what is loaded makes `m_connect_machine_info_list` non-empty, and several +things in the Prepare page are gated on exactly that. **The first press of Sync info after +this landed segfaulted**, at address 0: + +``` +#0 0x0000000000000000 +#1 Slic3r::GUI::Sidebar::refresh_filament_sync_marks() +#2 Slic3r::GUI::Sidebar::show_sync_filament_dialog() +#3 Slic3r::GUI::Sidebar::finish_printer_sync() +``` + +`SyncMarkOverlay` is a child window of the filament combo, `filament_sync_marks` holds a +raw pointer to it, and both places that shrink the row list `Destroy()` the combo — which +frees the mark — without dropping the pointer. `SetSynced()` then calls `IsShown()`, which +is virtual, through a freed vtable. The invariant was written on the member as a comment +and enforced nowhere. Fixed at both removal sites and bounded in the loop. + +Also newly live, and the reason the sidebar's **"Machine Filament" section had never +appeared on this firmware**: it is filtered by `currentNozzleInfo != machine_nozzles`, and +`nozzle_info` was always `""` because `print_task_config` does not carry +`nozzle_diameters`. Reading them from `machine.system_info` is what fills the section. + +The general lesson, and it is the same one as the two above: **a feature that has never +run has never been tested.** Finishing a half-connected path lights up everything gated on +it, and what lights up first is whatever was written on the assumption it never would. + +Verified in Orca, against `811002511261022618B3`: + +``` +[WCP] [rebuilt-device] filament inventory synced to Orca (state) +[WCP] [rebuilt-device] filament inventory synced to Orca (stream) +``` + +The full account is +[02-device-page/12-orca-integration.md](02-device-page/12-orca-integration.md). + ## Not verified Everything below was built against the simulator in `shared/js/mockhost.js`, which was diff --git a/docs/u1-webui/tools/check_coverage.py b/docs/u1-webui/tools/check_coverage.py index c932a211c1f..235d524299b 100644 --- a/docs/u1-webui/tools/check_coverage.py +++ b/docs/u1-webui/tools/check_coverage.py @@ -84,7 +84,6 @@ "sw_UploadFileResult": "cloud file transfer; no host handler either", # --- account: Orca owns the session, the page only reads it ----------- - "sw_UserLogin": "account surface; the Device tab only reads login state", "sw_UserLogout": "account surface", "sw_SubscribeUserLoginState": "read once at boot instead; no live re-auth UI", "sw_GetUserUpdatePrivacy": "privacy-consent surface", @@ -197,6 +196,10 @@ def implemented(): "sw_SubscribeMachineState": "state stream", "sw_GetMachineState": "state stream + the toolchange wait's polling", "sw_MachineHeartbeat": "session supervisor", + # Not a control at all, and the only command on this page that goes to ORCA rather + # than to the printer: it is how the sidebar's filament combo boxes learn what is + # loaded. No panel offers it because nobody presses it - the state stream does. + "sw_UpdateMachineFilamentInfo": "Orca's filament record (core/orcasync.js)", } diff --git a/resources/web/device_page/js/app.js b/resources/web/device_page/js/app.js index a063d138aee..042c8a317b3 100644 --- a/resources/web/device_page/js/app.js +++ b/resources/web/device_page/js/app.js @@ -24,6 +24,7 @@ import { installMock } from './core/mock.js'; import { Pending } from './core/pending.js'; import { createStore } from './core/store.js'; import { createSession } from './core/session.js'; +import { createOrcaSync } from './core/orcasync.js'; import { DIAG, createLog } from './core/diag.js'; import { mountBuildBadge } from '../../shared/js/buildinfo.js'; import { $ } from './core/dom.js'; @@ -79,6 +80,16 @@ const session = createSession({ handlers: all, }); +/** + * What this page owes Orca rather than the printer: the machine's filament inventory, + * which the sidebar's filament combo boxes are built from and which has exactly one + * writer. The shipped Device page was that writer; see core/orcasync.js. + */ +const orcaSync = createOrcaSync({ + bridge: () => bridge, + state, store, hostLog, +}); + function setStatus(text, kind = '') { const n = $('#status'); n.textContent = text; @@ -157,7 +168,23 @@ async function boot() { // Fails harmlessly when nothing is connected yet; the connect path re-runs it. await session.startStateStream('boot'); - state.onChange(render); + /* + * Repaint, and tell Orca what is loaded. + * + * Both hang off the state change, and the second one deliberately does NOT hang off the + * first. It did, inside render()'s requestAnimationFrame, and it never ran once in + * Orca: the Device tab's webview is not the visible page at startup, and WebKit does + * not fire animation frames into a view that is not being composited. Everything else + * carried on - the session connected, the snapshot arrived, the log said so - and the + * one thing that has no on-screen consequence was the one thing that stopped. + * + * It showed up as an empty filament list on the PREPARE tab, which is the tab this page + * cannot see. Drawing may wait for someone to look; a side effect may not. + */ + state.onChange(() => { + render(); + orcaSync.sync('state'); + }); render(); session.supervise(); @@ -242,7 +269,7 @@ const deps = { // answer them - the simulator has to, or the camera is the one panel a simulated run // cannot exercise. get mock() { return window.__devicePage.mock; }, - state, store, pending, session, cmd: all, + state, store, pending, session, orcaSync, cmd: all, send, setpoint, setStatus, render: () => render(), }; const byModule = {}; @@ -365,6 +392,17 @@ function wireDeviceMenu() { onClick: () => send(CMD.ADD_DEVICE, {}, 'add device') }); items.push({ label: 'Connect another machine…', icon: 'iconHome', onClick: () => ctx.handlers.connectOther() }); + // The account, last, because it is about Orca rather than about any printer. Signed + // in it is a statement and not a control; signed out it opens ORCA's login dialog on + // Snapmaker's own page - this page has no login screen and is not getting one. + items.push(null); + if (store.loginUser && store.loginUser.userid) { + items.push({ label: `Signed in as ${store.loginUser.nickname || store.loginUser.userid}`, + icon: 'iconHome', muted: true }); + } else { + items.push({ label: 'Sign in to Snapmaker…', icon: 'iconHome', + onClick: () => ctx.handlers.signIn() }); + } openMenu(sel, items); }; } diff --git a/resources/web/device_page/js/core/orcasync.js b/resources/web/device_page/js/core/orcasync.js new file mode 100644 index 00000000000..cfe031eb366 --- /dev/null +++ b/resources/web/device_page/js/core/orcasync.js @@ -0,0 +1,225 @@ +/* + * orcasync.js - the one thing this page owes ORCA rather than the printer. + * + * Every other command on this page asks the machine something. This one tells Orca what + * the machine is carrying, and it exists because the Device page is the only thing that + * ever told it. + * + * `sw_UpdateMachineFilamentInfo` does not reach the printer at all - `bridge-methods.json` + * has it as "answered inside Orca", and what it does there is + * `SSWCP_Instance::update_filament_info()`: fill `preset_bundle->machine_filaments` and + * `m_connect_machine_info_list`, then `load_current_presets()`. Those two containers are + * what the sidebar's filament combo boxes are built from (`PresetComboBoxes.cpp:330`, + * `:1396`, `:1968`) and they have exactly one writer. So with nothing calling this, the + * Prepare page's filament rows never learn what is in the printer - the shipped Device + * page was doing it, and switching the tab to the reconstruction switched it off. + * + * Three things about the payload, all of them read out of the C++ rather than guessed, + * and all three fatal to get wrong because `handle_web_message` does not catch: + * + * `objects: [{ key, value }]` key is the SERIAL, value is a JSON **string**. Both are + * type-checked; a flat patch object - which is what the + * two panels here used to send - fails at the first `if` + * and the whole call does nothing. + * the SN must be CONNECTED `get_device_info(sn)` then `info.connected`, else + * "sn does not exist!" / "The machine is not connected!". + * Not pre-checked here: `DeviceInfo.connected` is + * force-cleared on every config save and set from a + * CallAfter that lands after the connect reply, so a page + * that gates on it declines for as long as the race lasts + * and says nothing. Orca's answer is the answer. + * the arrays must line up the loop runs over `filament_official.size()` and + * indexes every other array with the same `i`, including + * `nozzle_diameters` and `filament_color_rgba`. A short + * array is an out-of-range throw inside a wxWidgets event + * handler, not an error reply. + * + * The key set is the shipped page's own `PrintTaskConfig.O()`, recovered verbatim from + * `main.dart.js` - see docs/u1-webui/02-device-page/12-orca-integration.md. Sending the + * same keys is what makes this a like-for-like substitute rather than a fresh guess at + * what Orca will accept. + */ +'use strict'; + +import { CMD, DEVICE } from '../../../shared/js/protocol.js'; + +/** + * How many slots to describe. + * + * `filament_official` decides it on the C++ side, so it decides it here: whatever the + * machine reported, the arrays are cut or padded to that one length. + */ +function slotCount(tc) { + const lens = ['filament_official', 'filament_type', 'filament_vendor', 'filament_exist'] + .map((k) => (Array.isArray(tc[k]) ? tc[k].length : 0)); + return Math.max(0, ...lens); +} + +/** `v` as an array of exactly `n` entries, each passed through `cast`. */ +function fixed(v, n, cast, fill) { + const src = Array.isArray(v) ? v : []; + return Array.from({ length: n }, (_, i) => (i < src.length ? cast(src[i]) : fill)); +} + +const asStr = (v) => (v == null ? '' : String(v)); +const asBool = (v) => v === true; +const asInt = (v) => { const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : 0; }; + +/** How long to leave a refused push alone before trying the same inventory again. */ +const RETRY_MS = 15000; + +export function createOrcaSync({ bridge, state, store, hostLog }) { + // What was last accepted. Compared, not counted: the state stream pushes roughly twice + // in thirty seconds on an idle machine and re-sending an unchanged inventory would run + // `load_current_presets()` - which rebuilds every combo box - on each of them. + let sent = null; + let busy = false; + let retryAt = 0; + let said = ''; // the last thing said about a refusal, so it is said once + + /** + * The nozzle per slot, as strings. + * + * `machine.system_info` -> `product_info.nozzle_diameter` is the only place this + * firmware publishes it; `print_task_config` has no such key, which is why the shipped + * page's model defaults it to an empty list. Orca reads each entry with + * `get()`, so a number here is a type_error and not a value it can use. + */ + function nozzles(n) { + const info = (store.systemInfo && store.systemInfo.product_info) || {}; + const raw = Array.isArray(info.nozzle_diameter) ? info.nozzle_diameter : []; + const first = raw.length ? asStr(raw[0]) : ''; + return Array.from({ length: n }, (_, i) => (i < raw.length ? asStr(raw[i]) : first)); + } + + /** The inventory, in the shape `update_filament_info()` parses. */ + function inventory() { + const tc = state.taskConfig(); + const n = slotCount(tc); + if (!n) return null; + + // filament_color_multi is per-slot objects, and Orca reaches into `.colors` and + // `.mode` on each. Anything else in the array would be read as an object and come + // back empty rather than throw, but pass through only what is actually shaped right. + const multi = fixed(tc.filament_color_multi, n, + (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {}), {}); + + return { + filament_vendor: fixed(tc.filament_vendor, n, asStr, ''), + filament_type: fixed(tc.filament_type, n, asStr, ''), + filament_sub_type: fixed(tc.filament_sub_type, n, asStr, ''), + filament_color: fixed(tc.filament_color, n, asInt, 0), + filament_color_rgba: fixed(tc.filament_color_rgba, n, asStr, '00000000'), + filament_official: fixed(tc.filament_official, n, asBool, false), + filament_sku: fixed(tc.filament_sku, n, asInt, 0), + filament_edit: fixed(tc.filament_edit, n, asBool, false), + filament_exist: fixed(tc.filament_exist, n, asBool, false), + filament_color_multi: multi, + // extruder_map_table is 32 long on the machine - one entry per possible tool, not + // per slot - and Orca only ever indexes the first `n`. Passed as the machine sends + // it, padded only if it is somehow shorter than the slots it maps. + extruder_map_table: fixed(tc.extruder_map_table, + Math.max(n, Array.isArray(tc.extruder_map_table) + ? tc.extruder_map_table.length : 0), + asInt, 0), + extruders_used: fixed(tc.extruders_used, n, asBool, false), + time_lapse_camera: asBool(tc.time_lapse_camera), + auto_bed_leveling: asBool(tc.auto_bed_leveling), + flow_calibrate: asBool(tc.flow_calibrate), + shaper_calibrate: asBool(tc.shaper_calibrate), + auto_replenish_filament: asBool(tc.auto_replenish_filament), + can_auto_replenish: asBool(tc.can_auto_replenish), + auto_replenish_index: asInt(tc.auto_replenish_index), + nozzle_diameters: nozzles(n), + }; + } + + /** + * Said once per distinct reason, and only about a state that is not obviously + * temporary. + * + * Worth having at all because every way this can fail is silent: the consequence shows + * up on ANOTHER TAB, as a filament list that is empty or stale, with nothing on this + * page to connect the two. "It never ran" and "Orca refused it" look identical from the + * Prepare page, and they have opposite fixes. + */ + function decline(why) { + if (said === why) return false; + said = why; + hostLog(`filament sync not sent: ${why}`); + return false; + } + + /** + * Push, if there is anything to push and Orca will take it. + * + * Called on every state change - and **not** from render(). It was, inside the + * repaint's requestAnimationFrame, and in Orca it never ran once: the Device tab's + * webview is not the composited page at startup, and WebKit does not fire animation + * frames into a view nobody is looking at. Everything with an on-screen consequence + * carried on working, because a page nobody can see does not need to repaint. This has + * its consequence on ANOTHER TAB, so it may not wait for someone to look. + */ + async function sync(reason) { + if (busy || Date.now() < retryAt) return false; + const sn = store.device && store.device[DEVICE.SN]; + if (!sn) return decline('no saved machine to file it under'); + // Live machine state, NOT `DeviceInfo.connected`. That flag is force-cleared on every + // config save (AppConfig.cpp:887) and is set from a CallAfter that lands some time + // after the connect reply, so a page that gates on it declines for as long as the + // race lasts and says nothing. Orca has its own `info.connected` check inside + // `update_filament_info` and will refuse if it disagrees - which is an answer, and an + // answer is what this needs. Objects arriving is the evidence that there is a printer. + if (!state.lastUpdate) return decline('no machine state has arrived yet'); + + const inv = inventory(); + if (!inv) return decline('print_task_config reports no filament slots'); + + const value = JSON.stringify(inv); + const key = JSON.stringify([sn, value]); + if (key === sent) return false; + + busy = true; + try { + await bridge().request(CMD.UPDATE_MACHINE_FILAMENT_INFO, + { objects: [{ key: sn, value }] }); + sent = key; + said = ''; + hostLog(`filament inventory synced to Orca (${reason})`); + return true; + } catch (e) { + // Worth saying, because the visible consequence is on ANOTHER TAB: the Prepare + // page's filament rows go on offering what was loaded before. Said once per + // distinct reason, and retried on a clock rather than on every repaint - the + // common refusal is "The machine is not connected!", which is Orca's own device + // book catching up and resolves itself. + if (said !== e.message) { + said = e.message; + hostLog(`filament sync refused (${reason}): ${e.message}`, 'error'); + } + retryAt = Date.now() + RETRY_MS; + return false; + } finally { + busy = false; + } + } + + /** Read the one object the inventory needs and this page does not otherwise keep. */ + async function readSystemInfo() { + try { + const info = await bridge().request(CMD.GET_MACHINE_SYSTEM_INFO, {}); + store.systemInfo = (info && info.system_info) || info || null; + } catch (e) { + /* an older firmware, or no printer; nozzle_diameters degrades to empty strings */ + } + } + + return { + sync, + readSystemInfo, + /** Forget what was sent, so the next state change re-sends. Used on reconnect. */ + forget() { sent = null; retryAt = 0; said = ''; }, + /** For tests: the payload without sending it. */ + inventory, + }; +} diff --git a/resources/web/device_page/js/core/session.js b/resources/web/device_page/js/core/session.js index 402c8899e6c..7255a624988 100644 --- a/resources/web/device_page/js/core/session.js +++ b/resources/web/device_page/js/core/session.js @@ -362,6 +362,19 @@ export function createSession({ bridge, state, store, setStatus, render, refresh .then(() => hostLog(`filter accepted (${reason})`)) .catch((e) => hostLog(`filter skipped (${reason}): ${e.message}`)); + // BEFORE the snapshot, and that ordering is the whole of it. Orca clears its filament + // record when a machine disconnects, so a fresh stream has to re-send one it would + // otherwise consider unchanged - and `resyncOrca` is what makes an unchanged value + // count as new. Run after the snapshot, its forget() lands on a push the snapshot + // itself has just made, and the identical inventory goes out a second time 5 ms + // later. Each one costs a `load_current_presets()`: every preset tab reloaded, the + // page tree rebuilt, the bed forced to update. Twice, from inside a webview message + // handler. + // + // Here, it forgets while there is nothing to forget, reads the nozzle sizes the + // inventory needs, and the snapshot below does the one push. + if (handlers.resyncOrca) await handlers.resyncOrca(); + let ok = false; try { const snap = await bridge().request(CMD.GET_MACHINE_STATE, { objects: SUBSCRIBE_OBJECTS }); diff --git a/resources/web/device_page/js/core/store.js b/resources/web/device_page/js/core/store.js index ffbb52afdf7..c8e1e1d045b 100644 --- a/resources/web/device_page/js/core/store.js +++ b/resources/web/device_page/js/core/store.js @@ -83,6 +83,16 @@ export function createStore() { /** Machines LAN discovery has turned up. */ found: [], + /** + * `machine.system_info`, as the printer answered it. + * + * Kept because of one field nothing else on the wire carries: + * `product_info.nozzle_diameter`. Orca's filament record wants a nozzle per slot + * and `print_task_config` has no such key on this firmware, so the sync in + * core/orcasync.js reads it from here. Null until it has been asked for. + */ + systemInfo: null, + /** `{ userid, nickname }` - the pairing request wants both. */ loginUser: {}, diff --git a/resources/web/device_page/js/page-commands.js b/resources/web/device_page/js/page-commands.js index cc8fab2a012..b7fabbb06fa 100644 --- a/resources/web/device_page/js/page-commands.js +++ b/resources/web/device_page/js/page-commands.js @@ -41,10 +41,9 @@ export function create(deps) { await session.startStateStream('refresh'); // filter + snapshot + subscription, and it // re-reads homed_axes on its own way out cmd.queryException(); - // `startStateStream` above re-reads the ACE and its bays on its own way out, so - // there is nothing to ask for here - two triggers for one read is how a subsystem - // ends up defined in more than one place. - deps.bridge.request(CMD.GET_MACHINE_SYSTEM_INFO, {}).catch(() => {}); + // `startStateStream` above re-reads the ACE, its bays and the nozzle sizes on its + // own way out, so there is nothing to ask for here - two triggers for one read is + // how a subsystem ends up defined in more than one place. deps.bridge.request(CMD.FILE_STATUS, {}).catch(() => {}); deps.bridge.request(CMD.FILES_ROOTS, {}).catch(() => {}); render(); @@ -62,6 +61,27 @@ export function create(deps) { render(); } + /** + * Orca's copy of the machine's filament inventory, after anything that could have + * invalidated it. + * + * Two halves, and the first is the reason this is not just "push again": Orca CLEARS + * `machine_filaments` whenever a machine disconnects (SSWCP.cpp:4186, :6618), so after + * a reconnect an unchanged inventory still has to be re-sent. `forget()` is what makes + * an unchanged value count as new. + */ + async function resyncOrca() { + deps.orcaSync.forget(); + await deps.orcaSync.readSystemInfo(); + // Then push, for the case where the snapshot that follows changes nothing: on a + // refresh the state is already there and applyPayload may have nothing new to + // announce. At boot this declines - no state has arrived yet - and the snapshot's + // own change does it. Either way it is ONE push, because the second caller finds the + // inventory unchanged. + await deps.orcaSync.sync('stream'); + render(); + } + function showView(next) { store.view = next; render(); @@ -73,6 +93,9 @@ export function create(deps) { /** Orca's device list. session.js needs it too, on a reconnect. */ refresh: () => refresh(), + /** Orca's filament record. session.js calls it when a state stream comes up. */ + resyncOrca: () => resyncOrca(), + showView: (v) => showView(v), }; diff --git a/resources/web/device_page/js/views/device-control/control/control-commands.js b/resources/web/device_page/js/views/device-control/control/control-commands.js index 2c542958e3c..48c5d8e8cc9 100644 --- a/resources/web/device_page/js/views/device-control/control/control-commands.js +++ b/resources/web/device_page/js/views/device-control/control/control-commands.js @@ -15,7 +15,7 @@ */ 'use strict'; -import { CMD, NAMED, LIMITS, PRINT_PREFERENCES } +import { CMD, NAMED, LIMITS, PRINT_PREFERENCES, prefsLine } from '../../../../../shared/js/protocol.js'; import { openDialog, openBlockingDialog, toggleField } from '../../../core/overlay.js'; import { isTimeout } from '../../../../../shared/js/sswcp.js'; @@ -287,19 +287,30 @@ export function create(deps) { { script: `G91\nG0 ${axis}${d} F${feed}\nG90` }, `jog ${axis}`); }, + /* + * The three job-time toggles. + * + * Read from `print_task_config` and written with `SET_PRINT_PREFERENCES`, which is + * the macro the shipped page sends for the same three switches. It used to send + * `sw_UpdateMachineFilamentInfo` with the toggles as its parameters, and that command + * never reaches the printer at all - it is Orca's own filament record, it wants + * `{objects:[{key,value}]}`, and against a real host every one of these clicks + * failed its first `if` and changed nothing. See core/orcasync.js. + */ printPrefs: () => { const tc = state.taskConfig(); const boxes = []; openDialog({ title: 'Print Preferences', - build: (b) => PRINT_PREFERENCES.forEach(({ key, label }) => { - boxes.push([key, toggleField(b, { label, checked: !!tc[key] })]); + build: (b) => PRINT_PREFERENCES.forEach(({ key, arg, label }) => { + boxes.push([arg, toggleField(b, { label, checked: !!tc[key] })]); }), confirmLabel: 'Apply', onConfirm: () => { - const patch = {}; - boxes.forEach(([k, input]) => { patch[k] = input.checked; }); - send(CMD.UPDATE_MACHINE_FILAMENT_INFO, patch, 'set print preferences'); + const prefs = {}; + boxes.forEach(([arg, input]) => { prefs[arg] = input.checked; }); + const script = prefsLine(prefs); + if (script) send(CMD.SEND_GCODES, { script }, 'set print preferences'); }, }); }, diff --git a/resources/web/device_page/js/views/device-control/filament/filament-commands.js b/resources/web/device_page/js/views/device-control/filament/filament-commands.js index 4364d988d7d..45cd4f80581 100644 --- a/resources/web/device_page/js/views/device-control/filament/filament-commands.js +++ b/resources/web/device_page/js/views/device-control/filament/filament-commands.js @@ -28,7 +28,8 @@ */ 'use strict'; -import { CMD, TASK_CONFIG, cssColor } from '../../../../../shared/js/protocol.js'; +import { CMD, cssColor, quotedLine, PRINT_TASK } + from '../../../../../shared/js/protocol.js'; import { isTimeout } from '../../../../../shared/js/sswcp.js'; import { machineActivity, isBusy } from '../../../../../shared/js/activity.js'; // Everything about the ACE comes from one module - see shared/js/multiACE.js for why. @@ -592,27 +593,35 @@ export function create(deps) { /* ---- the head's own filament, unchanged --------------------------- */ return { + /** + * Name what is in one head. + * + * `SET_PRINT_FILAMENT_CONFIG` is the write, and it is the write the firmware gates: + * `print_task_config.py` refuses an `official` slot without `FORCE=1`, which is the + * same `filament_edit` the panel reads before offering the form at all. Colour goes + * out in the form the printer sends - RRGGBBAA, no '#'; writing CSS here would put a + * value on the machine that nothing else on it can read. + * + * This used to send `sw_UpdateMachineFilamentInfo` with the slot arrays as its + * parameters. That command never reaches the printer - it writes ORCA's filament + * record, wants `{objects:[{key,value}]}`, and answered every one of these with + * "param [objects] required or wrong type!". Nothing on the machine changed, and the + * panel had no way to know: it does not await this, by design. Orca's record is now + * kept by core/orcasync.js, which is a different job in the opposite direction. + */ setFilament: (index, type, color, vendor) => { - // print_task_config carries these as parallel per-slot arrays. Colour goes back in - // the form the printer sends: RRGGBBAA, no '#'. Writing CSS here would put a value - // on the machine that nothing else on it can read. - const tc = state.taskConfig(); - const types = (tc[TASK_CONFIG.TYPE] || []).slice(); - const vendors = (tc[TASK_CONFIG.VENDOR] || []).slice(); - const rgba = (tc[TASK_CONFIG.COLOR_RGBA] || []).slice(); - const argb = (tc[TASK_CONFIG.COLOR] || []).slice(); - const hex = (cssColor(color) || '#CCCCCC').slice(1).toUpperCase(); - types[index] = type; - if (vendor !== undefined) vendors[index] = vendor; - rgba[index] = `${hex}FF`; - argb[index] = (0xFF000000 | parseInt(hex, 16)) >>> 0; - - const patch = { [TASK_CONFIG.TYPE]: types, - [TASK_CONFIG.COLOR]: argb, - [TASK_CONFIG.COLOR_RGBA]: rgba }; - if (vendor !== undefined) patch[TASK_CONFIG.VENDOR] = vendors; - send(CMD.UPDATE_MACHINE_FILAMENT_INFO, patch, 'set filament'); + // SAVE=1 is the bundle's own: without it the machine forgets on the next restart. + // An undefined vendor is dropped rather than sent empty, because an absent argument + // leaves the field alone and an empty one clears it. + const script = quotedLine(PRINT_TASK.FILAMENT_CONFIG, { + CONFIG_EXTRUDER: index, + FILAMENT_TYPE: type, + FILAMENT_COLOR_RGBA: `${hex}FF`, + VENDOR: vendor === undefined ? null : vendor, + SAVE: 1, + }); + send(CMD.SEND_GCODES, { script }, 'set filament'); }, syncBays: () => syncBays(), diff --git a/resources/web/device_page/js/widgets/rail-commands.js b/resources/web/device_page/js/widgets/rail-commands.js index ff15566d2fe..8aa909227ed 100644 --- a/resources/web/device_page/js/widgets/rail-commands.js +++ b/resources/web/device_page/js/widgets/rail-commands.js @@ -29,6 +29,44 @@ export function create(deps) { return { + /** + * The Snapmaker account. + * + * There is no login screen on this page and there is not going to be one: + * `sw_UserLogin` opens ORCA's own dialog, which loads Snapmaker's real login page at + * id.snapmaker.com. Credentials are typed there and nowhere else. All this does is + * ask for that dialog and then re-read who came back from it. + * + * Worth having because the Device tab is now the only Device page: pairing a machine + * to an account, and anything cloud-side, needs a session, and the tab that needs it + * should be able to ask for it rather than sending someone to another tab. + */ + signIn: async () => { + try { + await deps.bridge.request(CMD.USER_LOGIN, { show: true }); + } catch (e) { + setStatus(`sign-in failed: ${e.message}`, 'err'); + return; + } + // The dialog is modal in Orca and this call returns before it closes, so who is + // signed in is re-read on a clock rather than once. Six seconds of asking, then it + // stops: a person who cancelled the dialog is not signed in, and a page that keeps + // asking is a page that never settles. + for (let i = 0; i < 12; i += 1) { + await new Promise((r) => setTimeout(r, 500)); + try { + const u = await deps.bridge.request(CMD.GET_USER_LOGIN_STATE, {}); + if (u && u.status === 'online') { + store.loginUser = { userid: u.userid, nickname: u.nickname }; + setStatus(`Signed in as ${u.nickname || u.userid}`); + render(); + return; + } + } catch (e) { /* asked too early, or not signed in; try again */ } + } + render(); + }, + showSystemInfo: async () => { const gather = async (cmd, params = {}) => { try { return await deps.bridge.request(cmd, params); } catch (e) { return { error: e.message }; } @@ -58,7 +96,15 @@ export function create(deps) { add('Address', store.device && `${store.device[DEVICE.IP]}:${store.device[DEVICE.PORT] || 8883}`); add('State', printer.state); add('Host name', printer.hostname); - add('Firmware', printer.software_version || sys.firmware_version || dev.firmware); + // `machine.system_info` nests everything under `system_info`, and its + // `product_info` is the only place the nozzle sizes are published. Read as it + // is sent - `sys.firmware_version` was a flat field the machine never had, and + // it only ever looked right because printer.info answers first. + const prod = (sys && sys.system_info && sys.system_info.product_info) || {}; + add('Firmware', printer.software_version || prod.firmware_version || dev.firmware); + add('Machine', prod.machine_type); + add('Nozzles', Array.isArray(prod.nozzle_diameter) + ? prod.nozzle_diameter.join(' / ') : undefined); add('Klipper objects', Array.isArray(objects && objects.objects) ? objects.objects.length : undefined); b.appendChild(dl); diff --git a/resources/web/print_processing/js/app.js b/resources/web/print_processing/js/app.js index 4a589f557f7..05cf6f11a5f 100644 --- a/resources/web/print_processing/js/app.js +++ b/resources/web/print_processing/js/app.js @@ -16,7 +16,8 @@ */ 'use strict'; -import { CMD, SUBSCRIBE_OBJECTS, MAPPING_STATUS, TASK_CONFIG } +import { CMD, SUBSCRIBE_OBJECTS, MAPPING_STATUS, PRINT_PREFERENCES, PRINT_TASK, + plainLine, prefsLine } from '../../shared/js/protocol.js'; import { Sswcp } from '../../shared/js/sswcp.js'; import { MachineState } from '../../shared/js/state.js'; @@ -124,20 +125,41 @@ const handlers = { refreshFilament: async () => { await loadMapping(); render(); }, + /* + * Both of these write `print_task_config`, and both did it with + * `sw_UpdateMachineFilamentInfo` - which does not reach the printer. It is Orca's own + * filament record, it wants `{objects:[{key,value}]}`, and a flat patch fails its first + * `if`. The macros below are what the shipped popup sends for the same two actions, + * recovered from `setPrePrintConfiguration` in main.dart.js; it emits all three lines in + * one script, so they are joined the same way here. + */ assignSlot: (filamentIndex, toolhead) => { if (!mapping || !mapping.filaments[filamentIndex]) return; mapping.filaments[filamentIndex].extruder = toolhead; - const table = mapping.filaments.map((f) => Number(f.extruder) || 0); - bridge.request(CMD.UPDATE_MACHINE_FILAMENT_INFO, - { [TASK_CONFIG.MAP_TABLE]: table }) + // EXTRUDERS is the set of TOOLHEADS in use, not the file's filament indices - the + // bundle joins the map's values, and `extruders_used` on the machine is one flag per + // toolhead. De-duplicated because two filaments can share one head. + const heads = [...new Set(mapping.filaments.map((f) => Number(f.extruder) || 0))]; + const script = [ + // Bare, not quoted: the bundle builds these two by plain concatenation, and only + // SET_PRINT_FILAMENT_CONFIG goes through the quoting map. + plainLine(PRINT_TASK.EXTRUDER_MAP, + { CONFIG_EXTRUDER: filamentIndex, MAP_EXTRUDER: Number(toolhead) || 0 }), + plainLine(PRINT_TASK.USED_EXTRUDERS, { EXTRUDERS: heads.join(',') }), + ].join('\n'); + bridge.request(CMD.SEND_GCODES, { script }) .catch((e) => console.warn('[preprint] update mapping:', e.message)); render(); }, setPreference: (key, value) => { prefs[key] = value; - bridge.request(CMD.UPDATE_MACHINE_FILAMENT_INFO, { [key]: value }) - .catch((e) => console.warn('[preprint] update preference:', e.message)); + const pref = PRINT_PREFERENCES.find((p) => p.key === key); + const script = pref ? prefsLine({ [pref.arg]: value }) : ''; + if (script) { + bridge.request(CMD.SEND_GCODES, { script }) + .catch((e) => console.warn('[preprint] update preference:', e.message)); + } render(); }, diff --git a/resources/web/shared/js/mockhost.js b/resources/web/shared/js/mockhost.js index 4fb6903f503..ab6dd7825e9 100644 --- a/resources/web/shared/js/mockhost.js +++ b/resources/web/shared/js/mockhost.js @@ -105,8 +105,30 @@ export function installMockHost({ log = () => {}, handlers = {}, printer: given const conn = printer.devices.find((d) => d.connected); return ok(conn ? conn : {}); } + /* + * `machine.system_info`, in the shape the printer answers it - one `system_info` + * wrapper around `product_info` / `cpu_info` / `network` and the rest. Measured on + * 811002511261022618B3, 2026-09-01. This used to return a flat + * `{firmware_version, model, sn}`, which no U1 has ever sent, and a flat reply is + * why the one field only this call carries went unnoticed: + * `product_info.nozzle_diameter`, which Orca's filament record wants per slot. + */ case 'sw_GetMachineSystemInfo': - return ok({ firmware_version: printer.firmware, model: 'Snapmaker U1', sn: printer.sn }); + return ok({ system_info: { + product_info: { + machine_type: 'Snapmaker U1', + nozzle_diameter: printer.toolheads.map((t) => t.nozzle_diameter), + serial_number: printer.sn, + device_name: printer.name, + firmware_version: printer.firmware, + software_version: printer.firmware, + }, + cpu_info: { cpu_count: 4, bits: '64bit', processor: 'aarch64', + total_memory: 983540, memory_units: 'kB' }, + network: { wlan0: { mac_address: '02:00:00:00:00:01', + ip_addresses: [{ family: 'ipv4', address: '192.168.1.42', + is_link_local: false }] } }, + } }); case 'sw_GetPrinterInfo': return ok({ state: 'ready', state_message: 'Printer is ready', hostname: 'snapmaker-u1', software_version: printer.firmware }); @@ -190,6 +212,56 @@ export function installMockHost({ log = () => {}, handlers = {}, printer: given return ok({ executed: params.script }); } + /* + * Orca's own filament record. NOT a printer command - it never leaves the host - + * and the reason it is answered here at all is that the page's only sign of + * getting it wrong is this reply. + * + * Validated exactly as `SSWCP_Instance::update_filament_info()` validates it, + * because being lax here is what let a flat `print_task_config` patch look like a + * working call for as long as it did: `objects` an array, `key` and `value` + * strings, `value` parsing to an object carrying the seven fields the C++ reads, + * and every array as long as `filament_official` - which the C++ loops over and + * indexes the rest with. Orca has no try/catch around any of that. + */ + case 'sw_UpdateMachineFilamentInfo': { + const objs = params && params.objects; + if (!Array.isArray(objs) || !objs.length) { + return fail('param [objects] required or wrong type!'); + } + const o = objs[0] || {}; + if (typeof o.key !== 'string') return fail('param [key] required or wrong type!'); + if (typeof o.value !== 'string') return fail('param [value] required or wrong type!'); + const dev = printer.devices.find((d) => d.sn === o.key); + if (!dev) return fail('sn does not exist!'); + if (!dev.connected) return fail('The machine is not connected!'); + let v; + try { v = JSON.parse(o.value); } catch (e) { return fail('value parse failed'); } + const need = ['nozzle_diameters', 'filament_vendor', 'filament_type', + 'filament_sub_type', 'extruder_map_table', 'filament_official']; + const missing = need.filter((k) => !Array.isArray(v && v[k])); + if (!Array.isArray(v && v.filament_color) && !Array.isArray(v && v.filament_color_rgba)) { + missing.push('filament_color'); + } + if (missing.length) return fail('value parse failed'); + const n = v.filament_official.length; + const short = ['filament_vendor', 'filament_type', 'filament_sub_type', + 'filament_color_rgba', 'nozzle_diameters'] + .filter((k) => Array.isArray(v[k]) && v[k].length && v[k].length < n); + if (short.length) { + // Out of range inside a wxWidgets event handler on the real host, so it is an + // error here rather than something the simulator quietly tolerates. + return fail(`arrays shorter than filament_official: ${short.join(', ')}`); + } + printer.orcaFilamentRecord = { sn: o.key, value: v }; + // Counted, not just kept: `load_current_presets()` rebuilds every combo box in + // the sidebar, so "did it re-send an unchanged inventory" is a question a test + // has to be able to ask. + printer.orcaSyncCount = (printer.orcaSyncCount || 0) + 1; + log('mock-note', { orcaFilamentSync: o.key, slots: n }); + return ok({}); + } + /* ---- bringing a session up ------------------------------------- */ // Mirrors the real sequence: the printer shows a PIN, the page hands it // back with sw_mqtt_set_engine, and Orca stores keys and flips the @@ -386,6 +458,26 @@ export function installMockHost({ log = () => {}, handlers = {}, printer: given // Both open a native Orca dialog; there is nothing to simulate. return ok({ opened: true }); + /* + * ---- the Snapmaker account ------------------------------------- + * + * Orca answers `sw_UserLogin` and THEN opens a modal on id.snapmaker.com, so the + * reply says nothing about whether anyone signed in. Modelled the same way here - + * an immediate `ok`, and a session that appears a moment later - because that gap + * is the whole reason the page polls the state afterwards instead of reading the + * reply. + */ + case 'sw_UserLogin': + setTimeout(() => { + printer.loginUser = { userid: 'mock-user-1', nickname: 'Mock Account' }; + }, 900); + return ok({}); + case 'sw_GetUserLoginState': + return ok(printer.loginUser + ? { status: 'online', userid: printer.loginUser.userid, + nickname: printer.loginUser.nickname, icon: '', token: '', account: '' } + : { status: 'offline' }); + /* ---- logging / telemetry the real page emits constantly -------- */ case 'sw_FileLog': case 'sw_Log': case 'sw_UploadEvent': case 'sw_SetLogLevel': return ok({}); @@ -543,13 +635,23 @@ function mockAceOverrides() { }; } -/** `ACE_DRY TEMP=55 DURATION=4` -> {TEMP: '55', DURATION: '4'}. */ +/** + * `ACE_DRY TEMP=55 DURATION=4` -> {TEMP: '55', DURATION: '4'}. + * + * Quoted values are kept whole, quotes stripped: the shipped page writes + * `SET_PRINT_FILAMENT_CONFIG VENDOR='Kingroon Filament'`, and splitting on whitespace + * made that two arguments and a vendor of `'Kingroon`. + */ function gcodeArgs(script) { const out = {}; - String(script).trim().split(/\s+/).slice(1).forEach((tok) => { - const m = /^([A-Za-z_]+)=(.*)$/.exec(tok); - if (m) out[m[1].toUpperCase()] = m[2]; - }); + const re = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*('[^']*'|"[^"]*"|[^\s]*)/g; + const body = String(script).trim().replace(/^\S+\s*/, ''); + let m; + while ((m = re.exec(body)) !== null) { + const v = m[2]; + out[m[1].toUpperCase()] = (v.length > 1 && (v[0] === "'" || v[0] === '"')) + ? v.slice(1, -1) : v; + } return out; } @@ -576,6 +678,10 @@ export function makePrinter() { link_mode: 'lan', nozzle_sizes: ['0.4', '0.4', '0.4', '0.4'] }, ], firmware: '1.0.0-mock', + /** How many times Orca's filament record has been written. See sw_UpdateMachineFilamentInfo. */ + orcaSyncCount: 0, + /** Who is signed in, or null. Orca's, not the printer's - see sw_UserLogin. */ + loginUser: null, // What sw_GetSoftwareInfo reports - the real bundle's shipped values, so the // build badge shows the same number with or without a host. orcaVersion: '2.3.26', @@ -609,6 +715,13 @@ export function makePrinter() { filamentVendor: ['Snapmaker', 'Snapmaker', 'Snapmaker', 'Generic'], filamentType: ['PLA', 'PLA', 'PETG', 'ABS'], filamentColorRgba: ['E03131FF', '1971C2FF', '2F9E44FF', 'F08C00FF'], + /* + * The three job toggles, mutable because SET_PRINT_PREFERENCES writes them. Keyed by + * the field name the machine REPORTS them under; the macro's own argument for the + * third one is `BED_LEVEL`, and the translation is the page's business, not this + * side's - see PRINT_PREFERENCES in protocol.js. + */ + prefs: { flow_calibrate: true, time_lapse_camera: false, auto_bed_leveling: true }, /** What multiACE does to a feeder head on entering head mode. */ clearFilamentDisplay(head) { this.filamentVendor[head] = ''; @@ -820,6 +933,56 @@ export function makePrinter() { const line = String(script || '').trim(); const name = line.split(/\s+/)[0].toUpperCase(); const a = gcodeArgs(line); + + /* + * Snapmaker's own print_task_config macros come FIRST, because they are the printer's + * and not multiACE's: a stock U1 with no ACE at all still answers them, and the guard + * below would otherwise drop every one of them on such a machine. + * + * None of the four appears in `printer.gcode.help`; all four were recovered from the + * shipped bundle. Absence from the help table is not evidence - the T-commands are not + * in it either. + */ + switch (name) { + case 'SET_PRINT_FILAMENT_CONFIG': { + const ch = Number(a.CONFIG_EXTRUDER); + if (!Number.isInteger(ch) || ch < 0 || ch > 3) return; + // The machine's own gate, and the reason the panel reads `filament_edit`: + // an official slot is refused unless FORCE=1. + if (p.filamentOfficial[ch] && String(a.FORCE || '0') !== '1') { + return '[print_task_config] filament_config, official filament, not configurable!'; + } + if (a.FILAMENT_TYPE !== undefined) p.filamentType[ch] = a.FILAMENT_TYPE; + if (a.VENDOR !== undefined) p.filamentVendor[ch] = a.VENDOR; + if (a.FILAMENT_SUBTYPE !== undefined) p.filamentSubType[ch] = a.FILAMENT_SUBTYPE; + if (a.FILAMENT_COLOR_RGBA !== undefined) { + p.filamentColorRgba[ch] = String(a.FILAMENT_COLOR_RGBA).toUpperCase(); + } + // Every write clears the flag, in the firmware's own words - which is what makes + // `filament_edit` a latch rather than a property of the spool. + p.filamentOfficial[ch] = false; + return; + } + case 'SET_PRINT_PREFERENCES': { + const set = (arg, field) => { + if (a[arg] === undefined) return; + p.prefs[field] = a[arg] === '1' || String(a[arg]).toLowerCase() === 'true'; + }; + set('FLOW_CALIBRATE', 'flow_calibrate'); + set('TIME_LAPSE_CAMERA', 'time_lapse_camera'); + set('BED_LEVEL', 'auto_bed_leveling'); + return; + } + // The two the print popup sends. Accepted and ignored here: nothing in this + // simulator reads the map back, and a handler that pretended to would be claiming + // more than has been measured. + case 'SET_PRINT_EXTRUDER_MAP': + case 'SET_PRINT_USED_EXTRUDERS': + return; + default: + break; + } + const ace = p.ace; if (!ace) return; const head = Number(a.HEAD); @@ -1075,11 +1238,29 @@ export function makePrinter() { filament_edit: p.filamentExist.map( (e, i) => e !== false && !p.filamentOfficial[i]), filament_exist: p.filamentExist.slice(), - extruders_used: [0, 1, 2, 3], - extruder_map_table: { 0: 0, 1: 1, 2: 2, 3: 3 }, - flow_calibrate: true, - time_lapse_camera: false, - auto_bed_leveling: true, + filament_sku: [0, 0, 0, 0], + /* + * Both of these were the wrong SHAPE, and the shape is the half that matters to + * anything reading them off the wire. Read off 811002511261022618B3, 2026-09-01: + * `extruders_used` is one BOOLEAN per toolhead, and `extruder_map_table` is a + * flat array of THIRTY-TWO - one entry per possible tool, not one per slot. The + * simulator had them as four small integers and a four-key object, so a reader + * that indexes the table past the fourth entry, or treats it as an array at all, + * agreed with the mock and not with the printer. + */ + extruders_used: [false, false, false, false], + extruder_map_table: Array.from({ length: 32 }, (_, i) => (i < 4 ? i : 0)), + flow_calibrate: p.prefs.flow_calibrate, + time_lapse_camera: p.prefs.time_lapse_camera, + auto_bed_leveling: p.prefs.auto_bed_leveling, + shaper_calibrate: false, + auto_replenish_filament: true, + can_auto_replenish: false, + auto_replenish_index: 0, + filament_color_multi: p.filamentColorRgba.map((h) => ({ + nums: 1, alpha: parseInt(String(h).slice(6, 8), 16), + mode: 0, colors: [String(h).slice(0, 6)], + })), }, }; TOOLHEADS.forEach((k, i) => { objs[k] = Object.assign({}, p.toolheads[i]); }); diff --git a/resources/web/shared/js/protocol.js b/resources/web/shared/js/protocol.js index 486c7da27bb..8651f498b09 100644 --- a/resources/web/shared/js/protocol.js +++ b/resources/web/shared/js/protocol.js @@ -32,6 +32,9 @@ export const CMD = { // docs/u1-webui/02-device-page/06-connection.md FILE_LOG: 'sw_FileLog', // { level, content } -> Orca's own log file GET_USER_LOGIN_STATE: 'sw_GetUserLoginState', // -> { status, userid, nickname, ... } + // Opens ORCA's own login dialog on Snapmaker's real login page (id.snapmaker.com). + // The page never sees a credential; it asks for the dialog and re-reads the state. + USER_LOGIN: 'sw_UserLogin', // { show } GET_PINCODE: 'sw_GetPincode', // { ip, userid, nickname, port=1884 } CREATE_MQTT_CLIENT: 'sw_create_mqtt_client', // { server_address, clientId, ca?, cert?, // key?, username?, password?, @@ -641,13 +644,88 @@ export const TASK_CONFIG = { AUTO_LEVEL: 'auto_bed_leveling', }; -/** The popup's three toggles, in the order the shipped UI lists them. */ +/** + * The three toggles, in the order the shipped UI lists them. + * + * `key` reads the state, `arg` writes it, and they are two fields because for one of the + * three they are not the same word: the machine reports `auto_bed_leveling` and the macro + * that sets it takes `BED_LEVEL`. Recovered from the shipped bundle, where the toggle + * writes into a map literally keyed `["bed_level", "flow_calibrate", "time_lapse_camera"]` + * and that map is what `SET_PRINT_PREFERENCES` is built from. Sending `AUTO_BED_LEVELING=` + * is an argument the macro does not have, which a G-code macro answers `ok` to. + */ export const PRINT_PREFERENCES = [ - { key: TASK_CONFIG.FLOW_CALIBRATE, label: 'Extrusion Flow Calibration' }, - { key: TASK_CONFIG.TIME_LAPSE, label: 'Time-lapse Camera' }, - { key: TASK_CONFIG.AUTO_LEVEL, label: 'Auto Leveling' }, + { key: TASK_CONFIG.FLOW_CALIBRATE, arg: 'flow_calibrate', label: 'Extrusion Flow Calibration' }, + { key: TASK_CONFIG.TIME_LAPSE, arg: 'time_lapse_camera', label: 'Time-lapse Camera' }, + { key: TASK_CONFIG.AUTO_LEVEL, arg: 'bed_level', label: 'Auto Leveling' }, ]; +/* ------------------------------------------------------------------ * + * Writing print_task_config + * + * `print_task_config` is READ off the state stream and WRITTEN with G-code macros - four + * of them, none of which appears in `printer.gcode.help` and all four recovered verbatim + * from the shipped bundle (`setPrintFilamentConfig` / `setPrePrintConfiguration` in + * main.dart.js; see docs/u1-webui/02-device-page/12-orca-integration.md). + * + * They are here rather than in a panel because two surfaces send them and because the + * quoting differs between the two families, which is exactly the kind of detail that + * gets re-derived slightly wrong the second time: + * + * SET_PRINT_FILAMENT_CONFIG KEY='value' single-quoted; a vendor with a space in it + * is one argument only because of the quotes + * SET_PRINT_PREFERENCES KEY=value bare, and the key is upper-cased from the + * `print_task_config` field name + * + * What does NOT write print_task_config is `sw_UpdateMachineFilamentInfo`. That name + * reads like the write and is not one: it never reaches the printer. See + * device_page/js/core/orcasync.js. + * ------------------------------------------------------------------ */ +export const PRINT_TASK = { + FILAMENT_CONFIG: 'SET_PRINT_FILAMENT_CONFIG', + PREFERENCES: 'SET_PRINT_PREFERENCES', + EXTRUDER_MAP: 'SET_PRINT_EXTRUDER_MAP', + USED_EXTRUDERS: 'SET_PRINT_USED_EXTRUDERS', +}; + +/** + * `SET_PRINT_FILAMENT_CONFIG` + `{CONFIG_EXTRUDER: 3, FILAMENT_TYPE: 'PETG'}` + * -> `SET_PRINT_FILAMENT_CONFIG CONFIG_EXTRUDER='3' FILAMENT_TYPE='PETG'` + * + * Null and undefined are dropped, as the bundle drops them - the macro treats an absent + * argument as "leave this field alone", so passing an empty string instead would clear + * the field rather than skip it. An empty string is therefore kept: it is a value. + */ +export function quotedLine(macro, args) { + return [macro].concat( + Object.entries(args || {}) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}='${v}'`)).join(' '); +} + +/** `SET_PRINT_EXTRUDER_MAP` + `{CONFIG_EXTRUDER: 0}` -> `SET_PRINT_EXTRUDER_MAP CONFIG_EXTRUDER=0`. */ +export function plainLine(macro, args) { + return [macro].concat( + Object.entries(args || {}) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${v}`)).join(' '); +} + +/** + * `SET_PRINT_PREFERENCES` + `{flow_calibrate: true}` -> `... FLOW_CALIBRATE=1`. + * + * Keyed by PRINT_PREFERENCES `arg`, not by the state field name. Booleans go out as 1/0 + * because that is what the bundle sends: it keeps two parallel maps of the same three + * toggles, bools for the checkboxes and ints for the wire, and the int one is the one + * the macro line is built from. + */ +export function prefsLine(prefs) { + const args = Object.entries(prefs || {}) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k.toUpperCase()}=${typeof v === 'boolean' ? (v ? 1 : 0) : v}`); + return args.length ? [PRINT_TASK.PREFERENCES].concat(args).join(' ') : ''; +} + /* ------------------------------------------------------------------ * * Control limits - DEVICE TAB ONLY. * (docs: 02-device-page/03-strings-and-controls.md) diff --git a/resources/web/shared/tests/conformance_test.py b/resources/web/shared/tests/conformance_test.py index edcc0792e6b..d0108c1c885 100644 --- a/resources/web/shared/tests/conformance_test.py +++ b/resources/web/shared/tests/conformance_test.py @@ -1171,5 +1171,63 @@ def _literals(span): "neither is what the control is or what state it is in. The wire has a home, and " "it is the trace pane") +# --- what Orca is told, and when ------------------------------------------- +# +# Two rules about core/orcasync.js, both of which were broken and both of which are +# invisible from this page: its consequence is on the PREPARE tab. +print("\n== the filament record Orca keeps ==") + +_app = open(os.path.join(JS, "app.js"), encoding="utf-8").read() +_sync = open(os.path.join(JS, "core", "orcasync.js"), encoding="utf-8").read() + +# 1. Driven by the state change, not by the repaint. render() defers its work to a +# requestAnimationFrame, and WebKit does not fire animation frames into a view that is +# not being composited - which the Device tab is not, at startup, in Orca. The sync ran +# on every suite and never once in the app. +_raf = re.search(r"requestAnimationFrame\(\(\)\s*=>\s*\{(.*?)\n \}\)", _app, re.S) +check("the filament sync is not driven off the repaint", + _raf is not None and "orcaSync.sync" not in _raf.group(1), + "orcaSync.sync() is inside render()'s requestAnimationFrame. A hidden webview gets " + "no animation frames, so it would never run in Orca while running in every test") +check("it is driven off the state change instead", + re.search(r"state\.onChange\(\s*\(\)\s*=>\s*\{[^}]*orcaSync\.sync", _app, re.S) + is not None) + +# 1b. Forgotten BEFORE the snapshot, not after it. Orca clears its filament record on +# disconnect, so a fresh stream has to re-send one it would otherwise call unchanged - +# and resyncOrca() is what makes an unchanged value count as new. Run after the +# snapshot, its forget() lands on a push the snapshot itself has just made and the +# identical inventory goes out twice, ~5 ms apart. Each one costs Orca a +# load_current_presets() on the stack of a webview message handler. +# +# Checked here rather than in the browser because the two pushes race: in the +# simulator the second finds the first still in flight and skips, so a drive script +# sees one push whichever order the calls are in. The ordering is the invariant. +_sess = open(os.path.join(JS, "core", "session.js"), encoding="utf-8").read() +_stream = _sess[_sess.index("async function startStateStream"):] +_resync = _stream.find("handlers.resyncOrca") +_snap = _stream.find("CMD.GET_MACHINE_STATE") +check("Orca's filament record is forgotten before the snapshot, not after", + _resync != -1 and _snap != -1 and _resync < _snap, + "resyncOrca() runs after the snapshot, so its forget() re-sends an inventory the " + "snapshot has already pushed - two load_current_presets() milliseconds apart") + +# 2. Sent in the shape update_filament_info() parses. The two panels that used to send a +# flat print_task_config patch to this command failed its first `if` and did nothing. +check("the inventory goes out as objects:[{key,value}] with value a JSON string", + "objects: [{ key: sn, value }]" in _sync and "JSON.stringify(inv)" in _sync) +check("every array Orca indexes is cut to filament_official's length", + "filament_official" in _sync and "fixed(" in _sync and "slotCount" in _sync) + +# 3. print_task_config is WRITTEN with macros, never with that command. +for _name, _rel in (("Filament", os.path.join("views", "device-control", "filament", + "filament-commands.js")), + ("Control", os.path.join("views", "device-control", "control", + "control-commands.js"))): + _mod = open(os.path.join(JS, _rel), encoding="utf-8").read() + check(f"the {_name} panel writes print_task_config with a macro, not sw_UpdateMachineFilamentInfo", + "UPDATE_MACHINE_FILAMENT_INFO" not in _mod, + "that command never reaches the printer - it writes Orca's own filament record") + print(f"\n{checks - len(fails)}/{checks} checks passed") sys.exit(1 if fails else 0) diff --git a/resources/web/shared/tests/drive/README.md b/resources/web/shared/tests/drive/README.md index f1e2ed275e9..78c0d17b1b6 100644 --- a/resources/web/shared/tests/drive/README.md +++ b/resources/web/shared/tests/drive/README.md @@ -23,6 +23,7 @@ nobody runs. | `homing.js` | The Homing dialog against a scripted G28. `homed_axes` reaching "xyz" is not the end of the procedure - the bed goes on moving - and the wait used to close on it. The simulator models neither `homed_axes` nor `motion_report`, so both are posed on `state`; the loop under test is the page's. | | `storage-real.js` | Storage against a real printer, read-only. Where the paging was actually settled: the simulator holds too few items to page and none that collide, and this printer holds sixty recordings of which one file appears four times - which is what showed the grid drawing 72 cards for 60 items. Needs `--real`, and Orca closed. | | `homing-real.js` | The same dialog against a **real G28**, and the only witness that counts - this one MOVES THE MACHINE. Two attempts at this wait passed every offline check and closed the dialog over a moving bed; what settled it was pressing the button with the printer in sight. Needs `--real`, and Orca closed. Takes a minute. | +| `orca-sync.js` | What the page sends to **Orca** rather than to the printer, and the three writes that were going to the wrong one of the two. Checks the filament inventory reaches Orca unprompted and in the exact shape `update_filament_info()` parses, that an unchanged one is not re-sent, that naming a filament and applying the print preferences send G-code macros, and that signing in opens Orca's own dialog rather than a form on this page. 22 checks. | | `task-card.js` | That the Printing Task panel is as tall as its card. It was pinned at 150 px against a 304 px card, and `.panel-body` hides its overflow — so the progress bar and both job buttons were cut off with nothing to say they had been. | ```bash @@ -35,6 +36,7 @@ python3 $R/run_webkit.py --real --sn --drive $R/drive/storage-real.js python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/dom-dump.js > /tmp/before.txt python3 $R/run_webkit.py --real --size 1920x1080 --drive $R/drive/camera-real.js python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/ace-panel.js +python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/orca-sync.js python3 $R/run_webkit.py --real --size 1920x1080 --drive $R/drive/ace-real.js python3 $R/run_webkit.py --real --device-ip 192.0.2.1 --drive $R/drive/no-printer.js ``` diff --git a/resources/web/shared/tests/drive/orca-sync.js b/resources/web/shared/tests/drive/orca-sync.js new file mode 100644 index 00000000000..6d53d2a22ff --- /dev/null +++ b/resources/web/shared/tests/drive/orca-sync.js @@ -0,0 +1,167 @@ +/* What the Device page owes ORCA, and the two writes that were going to the wrong place. + * + * Three things nothing else can see, because all three are about what leaves the page + * rather than about what is drawn: + * + * 1. the filament inventory reaching Orca at all. `sw_UpdateMachineFilamentInfo` is the + * only writer of `preset_bundle->machine_filaments`, which the sidebar's filament + * combo boxes are built from. The shipped Device page was the only caller, so + * retiring it silently emptied the Prepare page's filament rows. + * 2. its SHAPE. Orca type-checks `objects`, `key` and `value`, then loops + * `filament_official` and indexes every other array with the same `i` - with no + * try/catch anywhere above it. The simulator validates exactly that, so a payload + * that would throw inside a wxWidgets event handler fails here instead. + * 3. that naming a filament and setting the print preferences send G-CODE. Both used to + * send `sw_UpdateMachineFilamentInfo` with a flat `print_task_config` patch, which + * never reaches the printer and fails its first `if`. Neither is awaited, by design, + * so nothing on screen would ever have said so. + * + * The macro text is checked verbatim, quotes included. `SET_PRINT_FILAMENT_CONFIG` is + * single-quoted and `SET_PRINT_PREFERENCES` is bare, and the third preference is sent as + * `BED_LEVEL` while the machine reports it as `auto_bed_leveling` - all three recovered + * from the shipped bundle, and all three the kind of detail that is re-derived wrong. + */ +(function () { + const out = []; const P = window.__devicePage; + const eq = (g, w) => JSON.stringify(g) === JSON.stringify(w); + const say = (n, g, w) => out.push(`${eq(g, w) ? 'PASS' : 'FAIL'} ${n}` + + (eq(g, w) ? '' : ` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s) => document.querySelector(s); + const $$ = (s) => [...document.querySelectorAll(s)]; + const sent = () => P.mock.printer.gcodeLog[P.mock.printer.gcodeLog.length - 1]; + const rec = () => P.mock.printer.orcaFilamentRecord; + + (async () => { + try { + /* ---- 1. it happened, unprompted ---------------------------------- */ + // Nothing was clicked. The state stream brought it up, which is the point: the + // sidebar has to be right before anyone opens this tab, not after. + say('Orca has the inventory without anyone asking for it', !!rec(), true); + say('filed under the connected machine`s serial', + rec() && rec().sn, P.mock.printer.sn); + + /* ---- 2. the shape Orca actually parses ---------------------------- */ + const v = (rec() || {}).value || {}; + const n = (v.filament_official || []).length; + say('four slots, from filament_official', n, 4); + // Every one of these is indexed with the loop's own `i` on the C++ side. + const lens = ['filament_vendor', 'filament_type', 'filament_sub_type', + 'filament_color', 'filament_color_rgba', 'nozzle_diameters'] + .map((k) => (v[k] || []).length); + say('and every array Orca indexes is that long', lens, [n, n, n, n, n, n]); + // `machine.system_info` is the only place this firmware publishes the nozzle, and + // Orca reads each entry with get() - a number there is a type_error. + say('the nozzles are strings, from machine.system_info', + [v.nozzle_diameters, v.nozzle_diameters.every((x) => typeof x === 'string')], + [['0.4', '0.4', '0.4', '0.4'], true]); + // 32 entries, one per possible tool - not one per slot. The simulator used to send + // a four-key object here, which is not what any U1 sends. + say('the extruder map is passed through at the machine`s own length', + (v.extruder_map_table || []).length, 32); + + /* ---- and once per stream, not once per caller ---------------------- */ + /* + * Each push costs Orca a `load_current_presets()`: every preset tab reloaded, the + * model page tree rebuilt, the print bed forced to update - all on the stack of a + * webview message handler. + * + * This counts; it does not settle the ORDERING that made it two. In Orca the + * snapshot's push completed 5 ms before resyncOrca's forget() re-armed the + * identical inventory, so it went twice; here the second call finds the first still + * in flight and skips, whichever order they are in. Reproduced against the source + * instead, in conformance_test.py - "forgotten before the snapshot, not after". + */ + const before = P.mock.printer.orcaSyncCount; + say('coming up pushes the inventory once', before, 1); + // load_current_presets() rebuilds every combo box in the sidebar. An idle machine + // pushes state about twice a minute and this must not ride on that. + await wait(1600); + say('and an unchanged inventory is not pushed again', + P.mock.printer.orcaSyncCount, before); + + /* ---- 3a. naming a filament writes the PRINTER --------------------- */ + const idRow = () => $$('.menu .menu-item') + .filter((b) => /this filament/.test(b.textContent))[0]; + $$('#filament .ace-more')[2].click(); await wait(60); + say('the untagged head offers an edit', /Edit this filament/.test(idRow().textContent), true); + idRow().click(); await wait(200); + const type = $$('.dialog input')[0]; + say('the sheet is a form', !!type, true); + // Deliberately not the type it already has - the sync compares before it sends, so + // re-confirming the same value proves nothing about whether Orca would be told. + type.value = 'TPU'; + type.dispatchEvent(new Event('input', { bubbles: true })); + $('.dialog .btn.primary').click(); await wait(200); + + say('Confirm sends the firmware`s own macro, single-quoted', + /^SET_PRINT_FILAMENT_CONFIG /.test(sent()) && /FILAMENT_TYPE='TPU'/.test(sent()), + true); + say('addressed at the head, and told to persist', + [/CONFIG_EXTRUDER='2'/.test(sent()), /SAVE='1'/.test(sent())], [true, true]); + say('and the colour goes back as RRGGBBAA with no hash', + /FILAMENT_COLOR_RGBA='[0-9A-F]{8}'/.test(sent()), true); + say('nothing was sent to Orca as if it were the printer', + /UpdateMachineFilamentInfo/.test(sent()), false); + document.body.click(); await wait(60); + + /* ---- and Orca is told about the change --------------------------- */ + // The machine moved, so the mirror moved, so the record Orca holds has to follow - + // otherwise the sidebar keeps offering the filament that was there before. + await wait(1400); + say('the new type reaches Orca too', + (rec().value.filament_type || [])[2], 'TPU'); + say('which is a second push, not the first one again', + P.mock.printer.orcaSyncCount > before, true); + + /* ---- 3b. the print preferences ------------------------------------ */ + // The pill lives in the Control panel's HEADER, which is on the card rather than + // inside #control - so it is found by its id, not by walking the body. + const prefsBtn = $('#print-prefs'); + if (prefsBtn) { + prefsBtn.click(); await wait(160); + const boxes = $$('.dialog input[type=checkbox]'); + say('three toggles, as the shipped page has', boxes.length, 3); + boxes[2].checked = !boxes[2].checked; + boxes[2].dispatchEvent(new Event('change', { bubbles: true })); + $('.dialog .btn.primary').click(); await wait(200); + say('Apply sends SET_PRINT_PREFERENCES, bare and upper-cased', + /^SET_PRINT_PREFERENCES /.test(sent()), true); + // The one that is not the field name. `auto_bed_leveling` is what the machine + // reports; `BED_LEVEL` is what the macro takes, and a G-code macro answers `ok` + // to an argument it has never heard of. + say('and Auto Leveling goes out as BED_LEVEL, not the field name', + [/BED_LEVEL=[01]/.test(sent()), /AUTO_BED_LEVELING/.test(sent())], + [true, false]); + say('with the other two named as the machine reports them', + [/FLOW_CALIBRATE=[01]/.test(sent()), /TIME_LAPSE_CAMERA=[01]/.test(sent())], + [true, true]); + document.body.click(); await wait(60); + } else { + out.push('FAIL no Print Preferences control on the Control panel'); + } + + /* ---- 4. the account, which is Orca's dialog and not a page --------- */ + // The requirement is that the Snapmaker login stays the ORIGINAL one. So the page + // has no login form: this menu row asks Orca to open its own, which loads + // id.snapmaker.com. What is checked here is that it asks, and that it then notices + // the answer - the reply to sw_UserLogin arrives before the modal is even shown. + const openRail = () => { $('#device-select').click(); }; + openRail(); await wait(120); + const row = () => $$('.menu .menu-item') + .filter((b) => /Sign in to Snapmaker|Signed in as/.test(b.textContent))[0]; + say('signed out, the rail offers the original login', + !!row() && /Sign in to Snapmaker/.test(row().textContent), true); + say('and no password is asked for anywhere on this page', + $$('input[type=password]').length, 0); + row().click(); + await wait(2600); + document.body.click(); await wait(60); + openRail(); await wait(140); + say('and once Orca reports a session, the rail says who', + !!row() && /Signed in as Mock Account/.test(row().textContent), true); + document.body.click(); + } catch (e) { out.push('FAIL threw: ' + ((e && e.stack) || e)); } + window.__report = out.join('\n'); + })(); +})(); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 7b13214b241..2a72d3c07ae 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -4372,41 +4372,45 @@ wxString GUI_App::get_international_url(const wxString& origin_url) { // ---- Snapmaker U1 embedded web surfaces ------------------------------------ // -// One place decides whether the Device tab and the print-processing popup are -// served by the shipped Flutter bundle or by the reconstructions in -// resources/web/. See docs/u1-webui/ for what the reconstructions cover, and -// note they are deliberately narrower than the bundle - flipping -// "u1_reconstructed_ui" back to false restores the shipped pages. +// One place decides which page each surface loads. See docs/u1-webui/. +// +// The **Device tab is the reconstruction, always**: it is the only Device page +// now, and nothing loads `flutter_web?path=2` any more. What that page owed Orca +// - the machine's filament inventory, which the sidebar's filament combos are +// built from - it now pays itself; see device_page/js/core/orcasync.js. +// +// The print-processing popup still has two implementations, and +// "u1_reconstructed_ui" still chooses between them. The bundle also stays for the +// Home tab (?path=0), the preset dialog (?path=3) and the Add-Device dialog, none +// of which was reconstructed. wxString GUI_App::get_u1_surface_url(U1Surface surface) const { const std::string port = std::to_string(get_page_http_port()); const bool reconstructed = app_config->get_bool("u1_reconstructed_ui"); std::string path; - if (reconstructed) { - switch (surface) { - case U1Surface::DeviceTab: path = "/web/device_page/index.html"; break; - case U1Surface::PrintAndUpload: path = "/web/print_processing/index.html?mode=print"; break; - case U1Surface::UploadOnly: path = "/web/print_processing/index.html?mode=upload"; break; - } - } else { - switch (surface) { - case U1Surface::DeviceTab: path = "/web/flutter_web/index.html?path=2"; break; - case U1Surface::PrintAndUpload: path = "/web/flutter_web/index.html?path=4"; break; - case U1Surface::UploadOnly: path = "/web/flutter_web/index.html?path=5"; break; - } + switch (surface) { + case U1Surface::DeviceTab: + path = "/web/device_page/index.html"; + break; + case U1Surface::PrintAndUpload: + path = reconstructed ? "/web/print_processing/index.html?mode=print" + : "/web/flutter_web/index.html?path=4"; + break; + case U1Surface::UploadOnly: + path = reconstructed ? "/web/print_processing/index.html?mode=upload" + : "/web/flutter_web/index.html?path=5"; + break; } return wxString::FromUTF8(LOCALHOST_URL + port + path); } bool GUI_App::is_u1_device_tab_url(const wxString& url) { - // The Flutter bundle distinguishes its surfaces by ?path=; the reconstruction - // uses a directory per surface. Accept either, so callers do not have to know - // which implementation is active. - if (url.find("device_page/index.html") != wxString::npos) - return true; - return url.find("flutter_web") != wxString::npos && url.find("path=2") != wxString::npos; + // One Device page, one URL. `flutter_web?path=2` is deliberately NOT accepted: + // the tab never loads it, and treating it as the Device tab would register a + // second page for the state pushes that only one document can own. + return url.find("device_page/index.html") != wxString::npos; } bool GUI_App::is_u1_surface_url(const wxString& url) diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 5988bffe185..4a2c6211f1c 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -546,14 +546,13 @@ class GUI_App : public wxApp // ---- Snapmaker U1 embedded web surfaces -------------------------------- // // The Device tab and the print-processing popup are pages served from the - // loopback HTTP server. Two implementations exist: the shipped Flutter - // bundle (resources/web/flutter_web) and the reconstructions documented in - // docs/u1-webui (resources/web/device_page, resources/web/print_processing). + // loopback HTTP server, documented in docs/u1-webui. // - // Which one loads is decided in ONE place - get_u1_surface_url() - keyed on - // the "u1_reconstructed_ui" app config flag. Build the URLs through it - // rather than by hand, and test them with the helpers below, so the two - // implementations cannot drift apart. + // The Device tab is always resources/web/device_page. The popup still has + // two implementations - resources/web/print_processing and the shipped + // Flutter bundle - and "u1_reconstructed_ui" chooses. Both decisions live in + // ONE place, get_u1_surface_url(); build the URLs through it rather than by + // hand, and test them with the helpers below. enum class U1Surface { DeviceTab, // Orca's Device tab PrintAndUpload, // the popup, uploading and then printing diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 47c02dae6c5..c9db8797fad 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -3560,6 +3560,13 @@ void Sidebar::remove_unused_filament_combos(const size_t current_extruder_count) sizer_filaments->Remove(last / 2); (*p->combos_filament[last]).Destroy(); p->combos_filament.pop_back(); + // The sync mark is a CHILD of that combo, so Destroy() has just taken it with it. + // Dropping the pointer here is what keeps "one per filament combo" true - left + // behind, it dangles, and refresh_filament_sync_marks() calls SetSynced() on it, + // which calls the virtual IsShown() through a freed vtable. That is a jump to + // address 0, and it is what crashed the app the first time the Prepare page had + // any machine filament to sync against. + p->filament_sync_marks.resize(p->combos_filament.size()); } // BBS: filament double columns auto sizer_filaments0 = this->p->sizer_filaments->GetItem((size_t)0)->GetSizer(); @@ -8183,6 +8190,9 @@ void Sidebar::on_filaments_delete(size_t filament_id) PlaterPresetComboBox* to_delete_combox = p->combos_filament[filament_id]; (*p->combos_filament[last]).Destroy(); p->combos_filament.pop_back(); + // Same as remove_unused_filament_combos(): the mark is the combo's child and has + // just been destroyed with it. See the note there. + p->filament_sync_marks.resize(p->combos_filament.size()); // BBS: filament double columns auto sizer_filaments0 = this->p->sizer_filaments->GetItem((size_t) 0)->GetSizer(); @@ -9725,7 +9735,14 @@ void Sidebar::refresh_filament_sync_marks() std::vector project; build_design_filament_list(wxGetApp().preset_bundle, project); - for (size_t i = 0; i < p->filament_sync_marks.size(); ++i) { + // Only as far as there are combos. Each mark is a child window of the combo it rides + // on, so a combo that has gone has taken its mark with it; an entry past the end of + // combos_filament is a pointer to freed memory, and SetSynced() below reaches a + // virtual through it. The two removal sites keep this in step - this bound is the + // second line of defence, because the cost of being wrong here is a null-jump rather + // than a wrong mark. + const size_t count = std::min(p->filament_sync_marks.size(), p->combos_filament.size()); + for (size_t i = 0; i < count; ++i) { SyncMarkOverlay* mark = p->filament_sync_marks[i]; if (!mark) continue; diff --git a/src/slic3r/GUI/PrinterWebView.cpp b/src/slic3r/GUI/PrinterWebView.cpp index 5aa6ad3559c..761e8dfd981 100644 --- a/src/slic3r/GUI/PrinterWebView.cpp +++ b/src/slic3r/GUI/PrinterWebView.cpp @@ -28,11 +28,6 @@ PrinterWebView::PrinterWebView(wxWindow *parent) wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL); - // Which implementation the tab opens on; the switcher can change it live. - m_reconstructed = wxGetApp().app_config->get_bool("u1_reconstructed_ui"); - - build_switcher(topsizer); - wxString url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::DeviceTab); auto real_url = wxGetApp().get_international_url(url); // Create the webview @@ -72,61 +67,6 @@ PrinterWebView::~PrinterWebView() } -// A slim bar above the webview: two buttons, one per implementation. -void PrinterWebView::build_switcher(wxSizer* topsizer) -{ - m_switcher = new wxPanel(this, wxID_ANY); - auto* row = new wxBoxSizer(wxHORIZONTAL); - - m_btn_original = new wxButton(m_switcher, wxID_ANY, _L("Original"), - wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); - m_btn_rebuilt = new wxButton(m_switcher, wxID_ANY, _L("Rebuilt"), - wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); - m_btn_original->SetToolTip(_L("The Snapmaker Device page as shipped")); - m_btn_rebuilt->SetToolTip(_L("The reconstruction in resources/web/device_page")); - - m_btn_original->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { show_surface(false); }); - m_btn_rebuilt->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { show_surface(true); }); - - row->AddSpacer(FromDIP(8)); - row->Add(m_btn_original, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, FromDIP(4)); - row->AddSpacer(FromDIP(4)); - row->Add(m_btn_rebuilt, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, FromDIP(4)); - row->AddStretchSpacer(1); - - m_switcher->SetSizer(row); - topsizer->Add(m_switcher, 0, wxEXPAND); - update_switcher(); -} - -// The selected one is disabled, which reads as "you are here" without needing a -// custom toggle widget. -void PrinterWebView::update_switcher() -{ - if (!m_btn_original || !m_btn_rebuilt) - return; - m_btn_original->Enable(m_reconstructed); - m_btn_rebuilt->Enable(!m_reconstructed); -} - -void PrinterWebView::show_surface(bool reconstructed) -{ - if (m_browser == nullptr) - return; - - m_reconstructed = reconstructed; - wxGetApp().app_config->set_bool("u1_reconstructed_ui", reconstructed); - update_switcher(); - - // Drop the outgoing page's bridge subscriptions before the new one loads; - // otherwise its pushes would arrive at a document that never asked for them. - SSWCP::on_webview_delete(m_browser); - - wxString url = wxGetApp().get_u1_surface_url(GUI_App::U1Surface::DeviceTab); - wxString real_url = wxGetApp().get_international_url(url); - load_url(real_url, m_apikey); -} - void PrinterWebView::load_url(wxString& url, wxString apikey) { if (m_browser == nullptr) diff --git a/src/slic3r/GUI/PrinterWebView.hpp b/src/slic3r/GUI/PrinterWebView.hpp index 1ad0529c2b6..fd8251aa0dc 100644 --- a/src/slic3r/GUI/PrinterWebView.hpp +++ b/src/slic3r/GUI/PrinterWebView.hpp @@ -39,12 +39,6 @@ class PrinterWebView : public wxPanel{ void load_url(wxString& url, wxString apikey = ""); - // The Device tab can show either implementation: the shipped Flutter page - // or the reconstruction in resources/web/device_page. Both are reachable - // from a switcher above the webview; only one is loaded at a time, because - // two live Device pages would open two MQTT sessions to the same printer. - void show_surface(bool reconstructed); - bool showing_reconstructed() const { return m_reconstructed; } void UpdateState(); void OnClose(wxCloseEvent& evt); void OnError(wxWebViewEvent& evt); @@ -59,14 +53,7 @@ class PrinterWebView : public wxPanel{ private: void SendAPIKey(); - void build_switcher(wxSizer* topsizer); - void update_switcher(); - wxWebView* m_browser; - wxPanel* m_switcher = nullptr; - wxButton* m_btn_original = nullptr; - wxButton* m_btn_rebuilt = nullptr; - bool m_reconstructed = false; long m_zoomFactor; wxString m_apikey; diff --git a/src/slic3r/GUI/SSWCP.cpp b/src/slic3r/GUI/SSWCP.cpp index 8a204015bce..941808569bd 100644 --- a/src/slic3r/GUI/SSWCP.cpp +++ b/src/slic3r/GUI/SSWCP.cpp @@ -1658,7 +1658,18 @@ void SSWCP_Instance::update_filament_info(const json& objects, bool send_message } if (need_load_preset) { tmp_filaments = filaments; - wxGetApp().load_current_presets(); + // DEFERRED, and it matters. This runs on the stack of a + // wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED handler - a page asked for it - + // and load_current_presets() is not a small thing: update_pages(), + // force_print_bed_update(), load_current_preset() on every tab and + // rebuild_page_tree() on the model tabs. Destroying and rebuilding + // widgets while GTK is still dispatching the webview's message is how a + // sidebar refresh turns into a crash on the next tab switch. + // + // The containers above are already updated, so nothing waits on this but + // the redraw. Every other GUI action in this file goes through CallAfter + // for the same reason. + wxGetApp().CallAfter([]() { wxGetApp().load_current_presets(); }); } if (send_message) { @@ -5780,10 +5791,39 @@ void SSWCP_UserLogin_Instance::sw_SubUserUpdatePrivacy() wxGetApp().m_user_update_privacy_subscribers[m_webview] = weak_ptr; } +// A subscription that answers nothing only ever reports CHANGES, and the change a page +// most needs to hear about happened before it existed: the account is restored from +// app_config in init_app_config(), and the notify() that goes with it reaches no +// subscribers. post_init() re-announces, but that runs when the frame is built - several +// seconds before a 5 MB Flutter bundle has parsed and subscribed. The page then sits on +// its default, which is signed out, over a live session that is right there in the +// config. +// +// So the subscription replies with the state as it stands. `notify()` builds the same +// payload for a change, and a page cannot tell - nor should it care - whether what it +// received was the current value or a new one. void SSWCP_UserLogin_Instance::sw_SubscribeUserLoginState() { std::weak_ptr weak_ptr = shared_from_this(); wxGetApp().m_user_login_subscribers[m_webview] = weak_ptr; + + auto* info = wxGetApp().sm_get_userinfo(); + if (!info) + return; + + json data; + if (info->is_user_login()) { + data["status"] = "online"; + data["nickname"] = info->get_user_name(); + data["icon"] = info->get_user_icon_url(); + data["token"] = info->get_user_token(); + data["userid"] = info->get_user_id(); + data["account"] = info->get_user_account(); + } else { + data["status"] = "offline"; + } + m_res_data = data; + send_to_js(); } // SSWCP_MachineManage_Instance @@ -7251,7 +7291,25 @@ std::shared_ptr SSWCP::create_sswcp_instance(std::string cmd, co } // Handle incoming web messages +// Anything a PAGE can send arrives here, and everything below parses JSON and indexes +// into it. Nothing above catches: this is called straight out of a +// wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED handler, so an unexpected shape - a missing key, +// a string where a number was expected, an array shorter than the loop reading it - left +// a nlohmann exception to unwind through wxWidgets and terminate the app. The same shape +// of bug that made an unreachable printer abort Orca at launch, and the same fix: report +// it and carry on. A malformed command is the page's problem to see in the log, not the +// application's to die of. void SSWCP::handle_web_message(std::string message, wxWebView* webview) { + try { + handle_web_message_impl(message, webview); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << "[WCP] handle_web_message threw: " << e.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "[WCP] handle_web_message threw an unknown exception"; + } +} + +void SSWCP::handle_web_message_impl(std::string message, wxWebView* webview) { { if (!webview) { diff --git a/src/slic3r/GUI/SSWCP.hpp b/src/slic3r/GUI/SSWCP.hpp index e4ac535f7b3..f1e0060fa82 100644 --- a/src/slic3r/GUI/SSWCP.hpp +++ b/src/slic3r/GUI/SSWCP.hpp @@ -626,8 +626,14 @@ class SSWCP { public: // Handle incoming web messages + // Catches everything the parsing below can throw. A page that sends a malformed + // command must not be able to terminate the app - see the definition. static void handle_web_message(std::string message, wxWebView* webview); +private: + static void handle_web_message_impl(std::string message, wxWebView* webview); + +public: // Handle incoming web messages for Flutter debug (no webview required) static void handle_webmsg_for_debug(std::string message); From 6058a8ac53479a92b6ad738920267a1ce5cd0fea Mon Sep 17 00:00:00 2001 From: physicsG Date: Wed, 2 Sep 2026 19:56:16 +0200 Subject: [PATCH 5/5] The print-processing popup, rebuilt from the shipped one and proven on hardware (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds the print-processing popup — the dialog Orca opens between "Send to printer" and the print starting — from the shipped Flutter bundle, the same way the Device page was done, **and then drives it against a real U1 end to end**: upload, start, print, cancel. ## Why it needed rebuilding rather than finishing Reading the host's own handlers turned up more than "unfinished": - **The filament card read a shape the host has never returned.** It read `mapping.filaments[i]`; `sw_GetFileFilamentMapping` replies with **parallel arrays**. Only the popup's own mock invented an array of objects, so it drew four rows against the simulator and *"No filament requirements read from this file yet."* against Orca. - **The send failed on its first command.** `sw_StartLocalPrint` rejects `{}`. - **The file came through the wrong door.** `sw_GetPrintZip` serialises a `std::vector`, so a 12 MB zip crosses the bridge as ~40 MB of JSON integers. - **The progress bar was a `setTimeout`** counting to 100 whether or not anything uploaded. - **Five fields arrived and went undrawn**, chiefly `thumbnails[]` — the rendered plate. Written up with line numbers in [`04-requirements.md`](docs/u1-webui/03-print-processing/04-requirements.md). ## What the shipped dialog actually is The widget tree is all in `main.dart.js` and the constants resolve, so [`original-dialog-mockup.html`](docs/u1-webui/03-print-processing/original-dialog-mockup.html) is measurement rather than inference — every number cites its source, held by `check_mockup.py` (73 checks). **Three things neither shipped screenshot could show**, both having been captured with the filament section empty: - **Edit Filament is a Wrap of 80 × 100 cards**, a colour block over a toolhead picker. - **There is a fifth section** — a nozzle-mismatch banner (`A.R5`). - **A toolhead whose type _or_ nozzle does not match is passed `enabled: false` and cannot be picked at all.** That is behaviour; the drive script checks it by clicking a refused item and asserting nothing moved. ## The rebuild Follows the Device page's contract — a registry, a folder per panel, its own commands module each. Six primitives moved to `shared/` to be used by both surfaces: `dom`, `render`, `pending`, the modal sheet, the trace pane, and `connection.js`. `cssColor` is **not** reimplemented; it stays where the alpha-zero rule already lives. **One mock for both surfaces.** The U1 was already shared; Orca's half of this dialog now is too, on the same simulated printer. Not tidiness — this dialog matches the *file's* filaments against the *machine's*, and a mock that invented both lists could never report a mismatch. ## Driven against a real U1 `811002511261022618B3`, Orca closed, a person watching. The full account with the wire is [`05-hardware-e2e.md`](docs/u1-webui/03-print-processing/05-hardware-e2e.md). **The send contract, which Orca's source does not contain** — `sw_StartLocalPrint` passes params straight through, so only the firmware could say: - the upload is the page's and works cross-origin: `POST :7125/server/files/upload` → `HTTP 201` with the item it created - **`path` carries no root.** `item.path` and `item.root` are separate fields; `gcodes/x.zip` → `File not found`, `x.zip` → `Print started`. The page reads the stored path back off the upload's own reply instead of constructing one. - **the firmware unpacks the zip** — the start reply names the inner `.gcode`, and the archive does not survive. **A bug the simulator could not find.** `initialAssignment` fell back to *identity* when Orca had no `filament_extruder_map` — and outside Orca there never is one. On this machine that put filament 3 on the empty head and filament 4 on PETG: **two toolheads the picker itself refuses**, with nothing on screen saying so, because the warning mark means "nothing chosen" and something had been. Unassigned is the default now, which is the bundle's own answer. **A cancel that reported failure while it worked** — and the reason was not what I first said. Klipper runs G-code sequentially, so the cancel was queued behind the homing move. Measured with `G4` blocking the queue, moving nothing: | queue | `sw_MachinePrintCancel` | |---|---| | empty | 197 ms | | behind `G4 P3000` | 3323 ms | | behind `G4 P6000` | 6213 ms | The round trip is the queue plus ~250 ms every time. `u1_bridge.py` had already learned this (80 s, after a 31 s toolchange came back as "the printer refused the command"); the page's own client was still at 15 s for everything. `sswcp.js` now waits on two clocks, split by `PRINTER_BACKED`. ## Testing it, and a skill for next time ```bash R=resources/web/shared/tests python3 $R/run_webkit.py --size 714x750 --page 'web/print_processing/index.html?mock=1' \ --drive $R/drive/print-dialog.js # the simulator python3 $R/run_webkit.py --real --sn --size 714x750 --settle 25 \ --gcode ~/models/plate.gcode --page web/print_processing/index.html \ --drive $R/drive/print-dialog-machine.js # a real U1, read-only ``` `u1_bridge.py` answers Orca's half out of the `.gcode` passed to `--gcode`, parsing the metadata Orca's own slicer wrote, so the dialog sees a real plate rather than a fixture. `sw_StartLocalPrint` is gated behind `--allow-print`: a suite that can start a print is a suite that will. **`.claude/skills/u1-hardware-test`** holds the procedure — preconditions, the ladder of read-only rungs before anything that moves the machine, the rails on the one script that starts a print, the cleanup (the firmware unpacks the zip, so the *unpacked* name is what to delete), and how to read a slow reply. ## Three bugs found in existing code - **`sw_GetFileStream` was misclassified as printer-backed, and the check agreed.** The conformance suite took each handler's span up to the next `sw_*` signature; anything not named `sw_*` in between landed in the earlier one's span. List and check came from the same derivation, so they agreed with each other and not with the C++. - **`bridge-methods.json` had `sw_StartLocalPrint → method: null`** with an honest reason and a wrong conclusion — the method was three lines above the call, behind a named constant the extractor only looked for as a literal. - **The mock returned a `file_url` on port zero**, so the send path was *unexercisable* in the simulator. Nothing had noticed because no check had ever pressed Send there; the first thing to try it was a script being smoke-tested before it ran against hardware. ## One deliberate departure from the shipped dialog **Printer rows carry an address**, and the serial when that is what tells two rows apart. Orca's config routinely holds a stale record beside a live one — this machine has two identically-named entries on the same IP. Noted in both directions: the README says what and why, and the specification mockup says the reconstruction differs here. ## Checks | | | |---|---| | print dialog — simulator, `?path=4` / `?path=5` | 52/52, 5/5 | | print dialog — real bridge, real plate, no printer | 21/21 | | print dialog — **connected U1**, read-only | 15/15 | | cancel route, idle machine | 4/4 | | cancel latency under a blocked queue | 5/5 | | **send → print → cancel, real machine** | 6/6 | | specification mockup | 73/73 | | Device page | 61/61 | | conformance · unit_jsc · coverage | 165/165 · 228/228 · clean | ## Honest limits - **No plate has been left to run.** Everything is cancelled within seconds. - **That homing is what filled the queue is inference** from the timing; the `G4` measurement is direct, that observation is not. - **The cloud path is untouched** — `sw_StartCloudPrint`, `server.files.pull`, S3. - **Whether a started job honours the mapping and preferences** is unchecked; the macros are acknowledged, which is not the same thing. - **The multi-plate Model Information layout is not built** — unreachable through this host, recovered and drawn in the mockup. - **A file filament's nozzle is inferred**, the only inferred thing here. ## Also in this branch The first commit predates the brief being clarified: three interactive **design options** under `resources/web/print_processing/mockups/`. Kept deliberately — the reconstruction was asked for *first*, and these are where a design conversation starts once it ships. They are not wired into anything. --- CLAUDE.md | 52 +- docs/u1-webui/02-device-page/06-connection.md | 2 +- .../u1-webui/02-device-page/09-restructure.md | 6 +- .../02-device-page/10-multiace-filament.md | 2 +- .../camera-layout-ace-mockup.html | 2 +- .../02-device-page/multiace-actions.html | 2 +- .../02-device-page/multiace-cabinet.html | 4 +- .../multiace-filament-mockup.html | 2 +- .../03-print-processing/03-implementation.md | 7 + .../03-print-processing/04-requirements.md | 150 +++ .../03-print-processing/05-hardware-e2e.md | 173 +++ .../original-dialog-mockup.html | 981 ++++++++++++++++++ .../05-printer-protocol/05-cloud-api.md | 2 +- docs/u1-webui/STATUS.md | 45 +- docs/u1-webui/data/bridge-methods.json | 7 +- docs/u1-webui/tools/check_mockup.py | 185 ++++ docs/u1-webui/tools/extract_bridge_methods.py | 31 +- docs/u1-webui/tools/u1_bridge.py | 330 +++++- resources/.gitignore | 7 + resources/web/device_page/README.md | 8 +- resources/web/device_page/css/device.css | 2 +- resources/web/device_page/js/app.js | 14 +- resources/web/device_page/js/core/overlay.js | 156 +-- resources/web/device_page/js/core/session.js | 4 +- resources/web/device_page/js/shell.js | 4 +- .../device-control/camera/camera-view.js | 4 +- .../control/control-commands.js | 2 +- .../device-control/control/control-panel.js | 2 +- .../device-control/control/control-view.js | 4 +- .../filament/filament-commands.js | 7 +- .../device-control/filament/filament-view.js | 9 +- .../device-control/task/task-commands.js | 2 +- .../js/views/device-control/task/task-view.js | 4 +- .../device_page/js/views/fault/fault-view.js | 4 +- .../views/storage/storage/storage-commands.js | 2 +- .../js/views/storage/storage/storage-view.js | 4 +- resources/web/device_page/js/widgets/art.js | 2 +- .../web/device_page/js/widgets/format.js | 2 +- .../device_page/js/widgets/rail-commands.js | 2 +- resources/web/device_page/js/widgets/rail.js | 4 +- resources/web/print_processing/README.md | 258 ++++- .../web/print_processing/css/preprint.css | 627 ++++++++--- resources/web/print_processing/index.html | 55 +- resources/web/print_processing/js/app.js | 535 ++++++---- .../web/print_processing/js/core/mock.js | 30 + .../web/print_processing/js/core/send.js | 207 ++++ .../web/print_processing/js/core/session.js | 295 ++++++ .../web/print_processing/js/core/shell.js | 64 ++ resources/web/print_processing/js/mock.js | 88 -- resources/web/print_processing/js/registry.js | 52 + resources/web/print_processing/js/ui.js | 188 ---- .../js/views/filament/filament-commands.js | 40 + .../js/views/filament/filament-panel.js | 16 + .../js/views/filament/filament-view.js | 130 +++ .../js/views/model-info/model-info-panel.js | 17 + .../js/views/model-info/model-info-view.js | 68 ++ .../js/views/nozzle/nozzle-panel.js | 14 + .../js/views/nozzle/nozzle-view.js | 26 + .../views/preferences/preferences-commands.js | 22 + .../js/views/preferences/preferences-panel.js | 12 + .../js/views/preferences/preferences-view.js | 57 + .../js/views/printer/printer-commands.js | 16 + .../js/views/printer/printer-panel.js | 12 + .../js/views/printer/printer-view.js | 151 +++ .../web/print_processing/js/widgets/art.js | 73 ++ .../web/print_processing/js/widgets/format.js | 72 ++ .../web/print_processing/js/widgets/picker.js | 121 +++ .../web/print_processing/mockups/README.md | 80 ++ .../web/print_processing/mockups/fixture.js | 196 ++++ .../web/print_processing/mockups/index.html | 183 ++++ .../web/print_processing/mockups/mockup.css | 252 +++++ .../web/print_processing/mockups/option-a.css | 57 + .../print_processing/mockups/option-a.html | 49 + .../web/print_processing/mockups/option-a.js | 193 ++++ .../web/print_processing/mockups/option-b.css | 127 +++ .../print_processing/mockups/option-b.html | 63 ++ .../web/print_processing/mockups/option-b.js | 237 +++++ .../web/print_processing/mockups/option-c.css | 67 ++ .../print_processing/mockups/option-c.html | 48 + .../web/print_processing/mockups/option-c.js | 330 ++++++ .../web/print_processing/mockups/shell.js | 156 +++ .../js/core => shared/js}/connection.js | 8 +- resources/web/shared/js/dialog.js | 165 +++ .../{device_page/js/core => shared/js}/dom.js | 5 + resources/web/shared/js/mockhost.js | 183 +++- .../js/core => shared/js}/pending.js | 3 + resources/web/shared/js/protocol.js | 23 +- .../js/core => shared/js}/render.js | 3 + resources/web/shared/js/sswcp.js | 42 +- .../js/widgets => shared/js}/trace.js | 19 +- .../web/shared/tests/conformance_test.py | 40 +- resources/web/shared/tests/drive/README.md | 19 + .../shared/tests/drive/cancel-latency-real.js | 106 ++ .../shared/tests/drive/print-cancel-real.js | 103 ++ .../tests/drive/print-dialog-machine.js | 159 +++ .../shared/tests/drive/print-dialog-real.js | 122 +++ .../web/shared/tests/drive/print-dialog.js | 195 ++++ .../web/shared/tests/drive/print-mockups.js | 116 +++ .../tests/drive/print-send-cancel-real.js | 170 +++ resources/web/shared/tests/run_webkit.py | 69 +- resources/web/shared/tests/unit_jsc.py | 4 +- 101 files changed, 8130 insertions(+), 940 deletions(-) create mode 100644 docs/u1-webui/03-print-processing/04-requirements.md create mode 100644 docs/u1-webui/03-print-processing/05-hardware-e2e.md create mode 100644 docs/u1-webui/03-print-processing/original-dialog-mockup.html create mode 100644 resources/.gitignore create mode 100644 resources/web/print_processing/js/core/mock.js create mode 100644 resources/web/print_processing/js/core/send.js create mode 100644 resources/web/print_processing/js/core/session.js create mode 100644 resources/web/print_processing/js/core/shell.js delete mode 100644 resources/web/print_processing/js/mock.js create mode 100644 resources/web/print_processing/js/registry.js delete mode 100644 resources/web/print_processing/js/ui.js create mode 100644 resources/web/print_processing/js/views/filament/filament-commands.js create mode 100644 resources/web/print_processing/js/views/filament/filament-panel.js create mode 100644 resources/web/print_processing/js/views/filament/filament-view.js create mode 100644 resources/web/print_processing/js/views/model-info/model-info-panel.js create mode 100644 resources/web/print_processing/js/views/model-info/model-info-view.js create mode 100644 resources/web/print_processing/js/views/nozzle/nozzle-panel.js create mode 100644 resources/web/print_processing/js/views/nozzle/nozzle-view.js create mode 100644 resources/web/print_processing/js/views/preferences/preferences-commands.js create mode 100644 resources/web/print_processing/js/views/preferences/preferences-panel.js create mode 100644 resources/web/print_processing/js/views/preferences/preferences-view.js create mode 100644 resources/web/print_processing/js/views/printer/printer-commands.js create mode 100644 resources/web/print_processing/js/views/printer/printer-panel.js create mode 100644 resources/web/print_processing/js/views/printer/printer-view.js create mode 100644 resources/web/print_processing/js/widgets/art.js create mode 100644 resources/web/print_processing/js/widgets/format.js create mode 100644 resources/web/print_processing/js/widgets/picker.js create mode 100644 resources/web/print_processing/mockups/README.md create mode 100644 resources/web/print_processing/mockups/fixture.js create mode 100644 resources/web/print_processing/mockups/index.html create mode 100644 resources/web/print_processing/mockups/mockup.css create mode 100644 resources/web/print_processing/mockups/option-a.css create mode 100644 resources/web/print_processing/mockups/option-a.html create mode 100644 resources/web/print_processing/mockups/option-a.js create mode 100644 resources/web/print_processing/mockups/option-b.css create mode 100644 resources/web/print_processing/mockups/option-b.html create mode 100644 resources/web/print_processing/mockups/option-b.js create mode 100644 resources/web/print_processing/mockups/option-c.css create mode 100644 resources/web/print_processing/mockups/option-c.html create mode 100644 resources/web/print_processing/mockups/option-c.js create mode 100644 resources/web/print_processing/mockups/shell.js rename resources/web/{device_page/js/core => shared/js}/connection.js (96%) create mode 100644 resources/web/shared/js/dialog.js rename resources/web/{device_page/js/core => shared/js}/dom.js (73%) rename resources/web/{device_page/js/core => shared/js}/pending.js (97%) rename resources/web/{device_page/js/core => shared/js}/render.js (98%) rename resources/web/{device_page/js/widgets => shared/js}/trace.js (59%) create mode 100644 resources/web/shared/tests/drive/cancel-latency-real.js create mode 100644 resources/web/shared/tests/drive/print-cancel-real.js create mode 100644 resources/web/shared/tests/drive/print-dialog-machine.js create mode 100644 resources/web/shared/tests/drive/print-dialog-real.js create mode 100644 resources/web/shared/tests/drive/print-dialog.js create mode 100644 resources/web/shared/tests/drive/print-mockups.js create mode 100644 resources/web/shared/tests/drive/print-send-cancel-real.js diff --git a/CLAUDE.md b/CLAUDE.md index f739efc831d..408df1c0b15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,6 +248,51 @@ with `js/core/` for what every view needs (`dom`, `render`, `pending`, `store`, 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. @@ -274,8 +319,11 @@ python3 resources/web/shared/tests/run_webkit.py --original --sn --watch - `--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, and the no-printer branch. See its README - they were re-written from scratch - every time before. + 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 diff --git a/docs/u1-webui/02-device-page/06-connection.md b/docs/u1-webui/02-device-page/06-connection.md index 58b49b40fdf..85492b9f322 100644 --- a/docs/u1-webui/02-device-page/06-connection.md +++ b/docs/u1-webui/02-device-page/06-connection.md @@ -283,7 +283,7 @@ works with nobody signed in. ## The implementation -`resources/web/device_page/js/core/connection.js`. `connect(bridge, device, opts)` picks the +`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). diff --git a/docs/u1-webui/02-device-page/09-restructure.md b/docs/u1-webui/02-device-page/09-restructure.md index 681434af846..8211e4d9480 100644 --- a/docs/u1-webui/02-device-page/09-restructure.md +++ b/docs/u1-webui/02-device-page/09-restructure.md @@ -142,7 +142,7 @@ shape — state with no single home: 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/device_page/js/core/pending.js) is that one mechanism. A + [`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 @@ -173,12 +173,12 @@ shape — state with no single home: 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/device_page/js/core/pending.js).** See the LED table +**[`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/device_page/js/core/render.js).** Four disciplines +**[`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: diff --git a/docs/u1-webui/02-device-page/10-multiace-filament.md b/docs/u1-webui/02-device-page/10-multiace-filament.md index bb021fce194..60038928b9c 100644 --- a/docs/u1-webui/02-device-page/10-multiace-filament.md +++ b/docs/u1-webui/02-device-page/10-multiace-filament.md @@ -290,7 +290,7 @@ full table is in the study. Two rules apply without amendment: the settings that 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 -[`core/pending.js`](../../../resources/web/device_page/js/core/pending.js). +[`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 diff --git a/docs/u1-webui/02-device-page/camera-layout-ace-mockup.html b/docs/u1-webui/02-device-page/camera-layout-ace-mockup.html index 30a44c1730c..fd6ec96a4f2 100644 --- a/docs/u1-webui/02-device-page/camera-layout-ace-mockup.html +++ b/docs/u1-webui/02-device-page/camera-layout-ace-mockup.html @@ -1937,7 +1937,7 @@

What the panel can actually do, not just show

without amendment: never await the request, confirm against machine state (swap_in_progress, last_swap_result, head_source, dryer_status.status), and drive it through -core/pending.js. ace.event_seq and +shared/pending.js. ace.event_seq and ace.swap_phase exist and look purpose-built for exactly this — neither has been watched through a swap yet.
diff --git a/docs/u1-webui/02-device-page/multiace-actions.html b/docs/u1-webui/02-device-page/multiace-actions.html index 4a69a85a328..43c516830d1 100644 --- a/docs/u1-webui/02-device-page/multiace-actions.html +++ b/docs/u1-webui/02-device-page/multiace-actions.html @@ -2475,7 +2475,7 @@

02The panel, and an edge to pull

return f; } /* Repaint the highlight without a full render, so the field does not lose focus - mid-keystroke. This is the same reason core/render.js exists on the real page. */ + mid-keystroke. This is the same reason shared/render.js exists on the real page. */ function repaintPresets(f,key,vals,suffix){ const ck=key+'Custom', custom=DRYSET[ck]!==''; [...f.querySelectorAll('.vseg span')].forEach((x,i)=>{ diff --git a/docs/u1-webui/02-device-page/multiace-cabinet.html b/docs/u1-webui/02-device-page/multiace-cabinet.html index 1a801e314b3..61acfe4c907 100644 --- a/docs/u1-webui/02-device-page/multiace-cabinet.html +++ b/docs/u1-webui/02-device-page/multiace-cabinet.html @@ -1359,7 +1359,7 @@

02Two things left

return f; } /* Repaint the highlight without a full render, so the field does not lose focus - mid-keystroke. This is the same reason core/render.js exists on the real page. */ + mid-keystroke. This is the same reason shared/render.js exists on the real page. */ function repaintPresets(f,key,vals,suffix){ const ck=key+'Custom', custom=DRYSET[ck]!==''; [...f.querySelectorAll('.vseg span')].forEach((x,i)=>{ @@ -1860,7 +1860,7 @@

03The field

which is the habit the rest of this panel keeps: every control names its macro.
  • The highlight repaints without a full render, so the field does not lose focus mid-keystroke. That is the same reason - core/render.js + shared/render.js exists on the real page.
  • diff --git a/docs/u1-webui/02-device-page/multiace-filament-mockup.html b/docs/u1-webui/02-device-page/multiace-filament-mockup.html index 8fd096f91ae..053d2d02cfe 100644 --- a/docs/u1-webui/02-device-page/multiace-filament-mockup.html +++ b/docs/u1-webui/02-device-page/multiace-filament-mockup.html @@ -705,7 +705,7 @@

    05Every control, and where F4 puts it

    request, confirm against machine state (swap_in_progress, last_swap_result, head_source, dryer_status.status), and drive it through -core/pending.js. +shared/pending.js. ace.event_seq and ace.swap_phase look purpose-built for exactly this and neither has been watched through a swap yet. diff --git a/docs/u1-webui/03-print-processing/03-implementation.md b/docs/u1-webui/03-print-processing/03-implementation.md index 8db2993e281..db54b512b73 100644 --- a/docs/u1-webui/03-print-processing/03-implementation.md +++ b/docs/u1-webui/03-print-processing/03-implementation.md @@ -87,6 +87,13 @@ earlier documentation and client had it as `0` — see ## Honest limits +**Read [04-requirements.md](04-requirements.md) first.** The limits below were the known +ones; a later read of the host's own handlers found that the filament card is built on a +reply shape `sw_GetFileFilamentMapping` has never returned, and that the send would fail +on its first command. Three [mockups](../../../resources/web/print_processing/mockups/) +answer the design question that follows. + + - The printer picker is a stub: `sw_GetLocalDevices` is not wired, so "Click to select printer" resolves from `sw_GetConnectedMachine` only. - Upload progress is simulated locally, not driven by a real host progress channel. diff --git a/docs/u1-webui/03-print-processing/04-requirements.md b/docs/u1-webui/03-print-processing/04-requirements.md new file mode 100644 index 00000000000..9838171f49b --- /dev/null +++ b/docs/u1-webui/03-print-processing/04-requirements.md @@ -0,0 +1,150 @@ +# Print popup: what it needs before it can ship + +The [reconstruction](03-implementation.md) renders, and against a real Orca most of it +would not. This is what a read of the host's own handlers turned up — every item below is +a line of C++ in `SSWCP.cpp`, not a design opinion. The design questions are at the end, +and the [mockups](../../../resources/web/print_processing/mockups/) are how they get +answered. + +## The shape is wrong, and only the mock hides it + +`sw_GetFileFilamentMapping` returns **parallel arrays**. It has never returned a +`filaments[]` array of objects. `ui.js` reads `mapping.filaments[i]`, which exists only +because `mock.js` invented it — so the popup renders four rows against the simulator and +*"No filament requirements read from this file yet."* against Orca. + +This is the failure CLAUDE.md already names: a suite green while the page is broken. The +mock was written from the same head as the client instead of from the handler. + +What `SSWCP.cpp:3039` actually builds: + +| Key | Type | Note | +|---|---|---| +| `filename` / `filepath` | string | display name / the source `.gcode` on disk | +| `machine_model` | string | the **edited printer preset**, base name if it is a derivative | +| `estimated_time` | seconds | `ETimeMode::Normal` from the plate's slice result | +| `filament_type[]` | `["PLA", …]` | trimmed; length is `max(type, colour)` | +| `filament_color_rgba[]` | `["#RRGGBB"]` or `#RRGGBBAA` | the string form | +| `filament_color[]` | int | the same colour packed, for the Flutter side | +| `filament_color_multi[]` | array | **gradient / multi-colour spools** — `BuildPreprintColorMultiItem` | +| `filament_weight[]` | grams | per filament, `density × volume × 0.001` | +| `filament_weight_total` | grams | their sum | +| `filament_used_mm[]` | mm | length, from `filament_diameter` | +| `nozzle_diameters[]` | `["0.4", …]` | what the **file** was sliced for, one per extruder | +| `nozzle_info[]` | `["0.4", …]` | the current plate's, snapped to 0.2/0.4/0.6/0.8 | +| `filament_extruder_map` | `{"0":"0", …}` | **Orca's own** filament→extruder map, from `AppConfig` | +| `thumbnails[]` | `{url, width, height}` | `data:image/png;base64,…` — **the rendered plate** | + +Three of those change what the popup can be: + +- **`thumbnails[]` is a real picture of the plate.** The popup draws a grey box saying + "G-code" and always has, in the shipped bundle too. The picture is right there in the + reply that is already being made. +- **`nozzle_diameters[]` vs `nozzle_info[]`** is a check nothing performs. The file says + what it was sliced for; the plate says what is fitted. +- **`filament_extruder_map`** is the Prepare sidebar's assignment, already made, arriving + as the popup's starting point. The popup currently starts from nothing. + +## The send would fail + +```cpp +void SSWCP_MachineOption_Instance::sw_StartLocalPrint() +{ + if (!m_param_data.count("type") || !m_param_data.count("path")) { + handle_general_fail(-1, "param [type] or [path] required!"); +``` + +The reconstruction sends `{}`. On a real machine the send stops there, and because the +error arrives as a rejected request the popup would report `send failed:` and stay open. + +## There are two doors for the file, and the popup uses the wrong one + +Both hand over the same zip; they differ in how it crosses the bridge. + +| | `sw_GetPrintZip` | `sw_GetFileStream { is_zip: true }` | +|---|---|---| +| returns | `{name, content}` | `{file_name, file_url, origin_size, checksum}` | +| `content` is | `json = std::vector` | — | +| on the wire | **a JSON array of one integer per byte** | a `localhost` URL | +| a 12 MB zip becomes | ~40 MB of JSON, as a string | 90 bytes | +| integrity | none | SHA-256, base64 | + +`m_res_data["content"] = res["zip_data"]` where `zip_data` is a `std::vector` +serialises as `[80,75,3,4,…]`. `sw_GetFileStream` instead writes the zip to disk and +returns `make_wcp_download_url()` — a URL on Orca's own page HTTP server, which this popup +is already being served from. The page `fetch`es it. + +`check_coverage.py` files `sw_GetFileStream` under *"cloud file transfer"*. The handler +does not care: it is the general "hand the page the active file" door, and the LAN path +wants it more than the cloud one does. + +## Upload progress can be real + +The upload is **the page's job**, not Orca's. The LAN route is a multipart POST to +`/server/files/upload` on the printer — the bundle's own strings say so, and +`LavaDevice uploadFileAndPrint` reads the LAN IP to do it. The page already talks HTTP +straight to the machine (`aceOverridesUrl`, `gcodeStoreUrl` both do). + +So `XMLHttpRequest.upload.onprogress` gives a true byte count. The current bar is a +`setTimeout` loop counting to 100 in 600 ms whatever happens, including when nothing is +being uploaded at all — the one element on the screen that is purely decorative. + +Then, and only then, `sw_StartLocalPrint { type, path }`. + +## Three things that are stubs + +- **The printer picker.** `sw_GetLocalDevices` returns the saved-device array and + `sw_SubscribeLocalDevices` pushes it; neither is wired. "Click to select printer" does + nothing but set a status line. +- **The legality warning.** `sw_GetPrintLegal` is called and its `legal:false` draws one + line of prose. Nothing decides what a mismatch should *do*. +- **`filament_color_multi`.** Read, never drawn. A gradient spool is a flat swatch. + +## What the popup does not show, and is asking about + +The grid says *"Filament 3 → Toolhead 3"* while showing nothing about toolhead 3. The +destination is the whole question, and the answer is already in the state stream the +popup subscribes to: `state.filaments()` gives type, vendor, colour, tag and `loaded` per +head, and on a machine running [multiACE](../02-device-page/10-multiace-filament.md) a +head is fed by a bay, so the real sentence is *"bay B2, which feeds head 3"*. + +A mapping UI that does not draw its destination is asking the operator to remember what +is in the printer. That is the single largest design decision here, and it is what the +mockups differ on. + +## Two dialogs in a row + +`Plater.cpp:21966` opens `PrintHostSendDialog` — a native modal asking for the filename, +the post-upload action and *"open the device tab afterwards"* — and **then** opens this +one. The second dialog re-asks nothing but inherits every consequence: `post_action` is +what picks `?path=4` over `?path=5`, so the mode is decided before the page loads and +cannot be changed from inside it. + +Worth knowing while judging a design: the user has already answered three questions and +pressed OK once. + +## Still true from before + +- **Never driven against hardware.** Nothing in the send path has been observed. +- **No restructure.** Flat `app.js` + `ui.js`, where the Device page has `registry.js`, a + folder per panel and the `pending` / `render` primitives. Lifting them across is what + would show whether they generalise ([09-restructure](../02-device-page/09-restructure.md)). +- **`sw_UpdateMachineFilamentInfo` does not reach the printer.** `app.js` already sends + `SET_PRINT_EXTRUDER_MAP` / `SET_PRINT_USED_EXTRUDERS` instead, which is right; the + Device page's account of why is [12-orca-integration](../02-device-page/12-orca-integration.md). + +## The design questions + +Everything above is settled by reading. These are not: + +1. **Is the mapping the page, or a section of it?** Four cards is what shipped, and it + gives the mapping a quarter of a 714 × 750 dialog. +2. **What does an unsatisfiable plate look like?** A PETG filament with no PETG loaded, a + head with the wrong nozzle, a head with nothing in it. Block, warn, or say nothing? +3. **How much of the machine belongs in a print dialog?** Showing what is loaded is + obviously right; showing ACE bays, humidity and dryers is obviously not. The line is + somewhere between. +4. **What is the thumbnail for?** Confirming the right plate, or filling a corner. + +[The mockups](../../../resources/web/print_processing/mockups/) are three answers, at the +dialog's true size, with the scenarios above switchable. diff --git a/docs/u1-webui/03-print-processing/05-hardware-e2e.md b/docs/u1-webui/03-print-processing/05-hardware-e2e.md new file mode 100644 index 00000000000..db4c83e6125 --- /dev/null +++ b/docs/u1-webui/03-print-processing/05-hardware-e2e.md @@ -0,0 +1,173 @@ +# The print dialog against a real U1 + +Everything else about this surface was checked against a simulator built from the same +documentation, which proves internal consistency and not that a machine agrees. This is +what a machine said. Measured on **811002511261022618B3**, firmware's Moonraker on +`192.168.2.242:7125`, Orca closed, 2026-09-02. + +Four of the findings below could not have come from anywhere else, and two of them were +bugs. + +## The send contract, settled + +The last unmeasured stretch of this surface. `sw_StartLocalPrint` passes `m_param_data` +straight through to `server.files.start_local_print`, so what the firmware wants was not +in Orca's source at all. + +### 1. The upload is the page's, and it works cross-origin + +``` +POST http://192.168.2.242:7125/server/files/upload multipart, print=false + -> HTTP 201 + {"action":"create_file", + "item":{"path":"Test_Cube_PLA_4h15m.zip","root":"gcodes", + "size":1566071,"modified":1788367418.5,"permissions":"rw"}} +``` + +1.5 MB from a page served on `127.0.0.1` to the printer on `192.168.2.242`. Moonraker +reflects the Origin, so no proxy is needed — the same thing the ACE override store +relies on. + +### 2. `path` carries no root, and the reply says so + +`item.path` and `item.root` are **separate fields**. Gluing them together is the obvious +guess and it is wrong: + +| sent | answer | +|---|---| +| `{"type":"local","path":"gcodes/Test_Cube_PLA_4h15m.zip"}` | `{"state":"error","message":"File not found: gcodes/Test_Cube_PLA_4h15m.zip"}` | +| `{"type":"local","path":"Test_Cube_PLA_4h15m.zip"}` | `{"state":"success","message":"Print started","filename":"Test_Cube_PLA_4h15m.gcode"}` | + +So the page reads the stored path back off the upload's own reply rather than +constructing one — `storedPath()` in [`core/send.js`](../../../resources/web/print_processing/js/core/send.js). +`type: "local"` is accepted. + +### 3. The firmware unpacks the zip, and the zip does not survive + +The start reply names `Test_Cube_PLA_4h15m.**gcode**` — the file *inside* the archive. +Afterwards `gcodes/Test_Cube_PLA_4h15m.zip` does not exist and +`gcodes/Test_Cube_PLA_4h15m.gcode` does, at 7,918,912 bytes. Anything cleaning up after +a test has to delete the **unpacked** name. + +## Cancel + +Asked before anything was sent, which is the order that matters. + +| | | +|---|---| +| idle machine | `ok` in **109 ms** | +| running print | `print_stats` goes `printing` → `cancelled` in **~10 s** | + +On the running print the request itself **timed out at the client's 15 s** while the +machine stopped. The first reading of that was *"this firmware does not answer a cancel"* +— one data point and a guess, and wrong. + +### Klipper runs G-code sequentially, and the cancel queues behind it + +The better explanation, and it is testable without printing anything. `G4` is a dwell: it +blocks the queue for a known time and moves nothing, heats nothing, extrudes nothing. + +| queue | `sw_MachinePrintCancel` round trip | +|---|---| +| empty | **197 ms** | +| behind `G4 P3000` | **3323 ms** | +| behind `G4 P6000` | **6213 ms** | + +The round trip is **the queue plus about a quarter of a second**, every time. So the +cancel on that print was never lost: it was queued behind the homing move a starting job +makes, and the client gave up while the firmware was still working through it. + +`drive/cancel-latency-real.js` is the measurement, and it moves nothing. + +### The consequence: two clocks, not one + +`u1_bridge.py` learned this once already — its `RPC_TIMEOUT` is 80 s, set after a 31 s +toolchange came back as *"the printer refused the command"*. The page's own client was +still at 15 s for everything, which is the same lesson one layer up, unlearned. + +`shared/js/sswcp.js` now waits on two clocks, split by `PRINTER_BACKED` — the set that +already classifies which replies come back through the printer's envelope: + +| | | +|---|---| +| Orca answers it | 15 s. It comes back in milliseconds or not at all. | +| the printer answers it | 80 s, matching the bridge. It comes back when the queue drains. | + +**Confirming against `print_stats` rather than against the ack is still the right +belt-and-braces** — `shared/js/pending.js` exists for exactly that shape — but it is no +longer the only thing standing between an operator and a cancel that reports failure +while it works. + +## What the machine was holding + +``` +print_task_config.filament_type ["PLA","PLA","NONE","PETG"] +print_task_config.filament_vendor ["Jayo","Forshape","NONE","Kingroon"] +print_task_config.filament_exist [true,true,false,true] +extruder .. extruder3 nozzle_diameter 0.4 each +``` + +The plate wanted PLA throughout, so the toolhead picker offered heads 1 and 2 and refused +3 and 4 — with the two distinct tooltips, `dialog_filament_type_none_tips` for the empty +head and `dialog_filament_type_not_match_tips` for the PETG one. The refusal set is +recomputed from the raw objects inside the drive script, so the page cannot agree with +itself. + +## The bug hardware found + +`initialAssignment` fell back to **identity** when Orca had no `filament_extruder_map` — +and outside Orca there never is one. On this machine that put filament 3 on the empty head +and filament 4 on PETG: two toolheads **the picker itself refuses**, with nothing on +screen saying so, because the warning mark means *"nothing chosen"* and something had +been. + +Unassigned is the default now, which is the bundle's own answer: the card draws `!` in the +warning colour, and Send is refused until every filament has a home. **The simulator could +not have found this** — its mock always supplies a map. + +## Running it + +Use the `u1-hardware-test` skill, which walks the ladder and holds the rails. The short +form: + +```bash +R=resources/web/shared/tests +S=811002511261022618B3 + +# 1. read-only: what the machine holds, and does the refusal rule agree with it +python3 $R/run_webkit.py --real --sn $S --size 714x750 --settle 25 \ + --gcode ~/models/plate.gcode --page web/print_processing/index.html \ + --drive $R/drive/print-dialog-machine.js + +# 2. read-only: can this firmware be told to stop, asked while it is idle +python3 $R/run_webkit.py --real --sn $S --size 714x750 --settle 25 \ + --gcode ~/models/plate.gcode --page web/print_processing/index.html \ + --drive $R/drive/print-cancel-real.js + +# 3. THIS STARTS A PRINT. A person watches the machine. +python3 $R/run_webkit.py --real --sn $S --allow-print --size 714x750 --settle 25 \ + --gcode ~/models/plate.gcode --page web/print_processing/index.html \ + --drive $R/drive/print-send-cancel-real.js +``` + +**Orca must be closed** for all three: `--real` authenticates with the same saved +`clientId` and a broker evicts the older holder. **Pin `--sn`** — Orca's config keeps stale +records and the host tries every one it is handed. + +Step 3 leaves an unpacked `.gcode` on the printer. Remove it: + +```bash +curl -X DELETE "http://:7125/server/files/gcodes/.gcode" +``` + +## Still not proven + +- **A cancel measured against a real homing move.** The queue explanation is measured with + `G4`; that a starting print's homing is what filled the queue is inference from the + timing, not a separate observation. +- **A print left to run.** Everything here is cancelled within seconds; no plate has been + taken to completion, so nothing downstream of the first layer has been seen. +- **The cloud path.** `sw_StartCloudPrint`, `server.files.pull` and the S3 upload are + untouched — this is the LAN route only. +- **Preferences and the mapping reaching a print.** The macros are written and the machine + acknowledges them; whether the started job honours them has not been checked. diff --git a/docs/u1-webui/03-print-processing/original-dialog-mockup.html b/docs/u1-webui/03-print-processing/original-dialog-mockup.html new file mode 100644 index 00000000000..f83ba62e12d --- /dev/null +++ b/docs/u1-webui/03-print-processing/original-dialog-mockup.html @@ -0,0 +1,981 @@ + + + + + +The Upload and Print dialog, as shipped + + + + + + +
    + +

    Snapmaker U1 · reconstruction specification

    +

    The Upload and Print dialog, as shipped

    +

    + This is what ?path=4 and ?path=5 actually draw, rebuilt from + main.dart.js and checked against + screenshots/preuploadandprint.png. It is the target for the + reconstruction — the same exercise the Device page went through, before any design + question is opened. +

    +

    + The Edit Filament section has never been seen with data in it. Both + captures were taken with no filament requirements loaded, so the card was empty. The + bundle says it is a wrap of 80 × 100 cards, one per file filament, each a + colour block over a toolhead picker — not the row-per-filament grid the current + reconstruction draws. Switch “Filament data” below. +

    + +
    + Route +
    + + +
    + Filament data +
    + + + +
    + Open menu +
    + + + + +
    + Model info +
    + + +
    + Printer +
    + + +
    +
    + +
    +
    +
    +

    714 × 750 — the size WebPreprintDialog opens

    +
    +
    +

    Where each number came from

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ThingValueSource
    card border2 px #D9D9D9capture y=16–17; A.de(ax.ry,−1,2)
    card radius8A.aU(8)
    card padding12B.ch = EdgeInsets.all(12)
    title column120–180B.m4 = BoxConstraints(120,180,0,∞)
    title → content12B.bA = SizedBox(w 12)
    between cards8B.aB; capture 143→152
    above first card16B.aE; capture card top y=16
    thumbnail100 × 100, r16A.cR(url,…,100,…,100), A.aU(16)
    thumb → facts20B.lr
    fact spacing13, 10, 10B.ayg, B.c_
    printer dropdown300 × 50A.a2l(50,300,…)
    filament card80 × 100, r8A.A0(…,80,100,8,…)
    card splitflex 3 / rule / flex 4A.bH(stack,3), A.bH(picker,4)
    filament gridWrap, 12 / 12A.mO(…,12,12,…) via aiy.y
    target disc28A.aJO(…,28,…,28)
    preference row152–220 × 20B.VS
    preference wraprun 12, gap 170A.mO(…,12,170,…)
    toggle18, r9, 2pxA.a3(…,18,…,18), A.aU(9)
    toggle checked#4CAF50B.fx = 0xFF4CAF50
    progress bar8 tall, r4capture y=710–717; A.a99(…,8,…)
    Send button120 × 40 #2196F3A.al(120,40,…); capture
    bar padding16B.bV
    bar spacing5 / 30 / 10B.RH, B.ay6, B.RG
    mismatch bannerrgba(255,59,48,.05)B.YP = 0x0DFF3B30
    printer menuw300, item 50, elev 0, (0,0)B.aqV = MenuItemStyle(50, B.c8); bEo(…,0,null,B.o,B.a4,null,300)
    toolhead menuw200, maxH 300, item 48, elev 8, (−50,0), r8B.aqU = MenuItemStyle(48, B.c8); bEo(…,8,300,B.asz,null,HI,200)
    toolhead scrollbar6 wide, r40, always shownA.HI(thumbVisibility true, thickness 6, radius B.l5)
    plate menuw=button, maxH 480, item 80, elev 4, (0,−2)B.aqW = MenuItemStyle(80, B.rT); bEo(…,4,480,B.as3,B.a4,…)
    plate menu radius0 / 0 / 4 / 4B.VC = BorderRadius(O, O, 4, 4)
    plate button90 tall, pad LTRB(8,6,8,6)A.a2l(90, null, B.rT, …)
    plate chip10px, pad 3, r2A.aj4.a8F; #E5E5E5 outline under 1.5 contrast
    Send button statesblue / amber / green / red / greyA.Jb.aKO: B.eM #2196F3, B.iZ #FF9800, B.fx #4CAF50, B.oL #F44336, B.ci #9E9E9E
    empty grid slot80 × 100, invisibleB.aya = SizedBox(80,100)
    +
    +
    + +

    What the bundle says that no screenshot showed

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    BehaviourEvidence
    There is a fifth section: a nozzle-mismatch banner under Print Preferences.A.R5 — draws nozzle_mismatch_tip when the plate's nozzle set is not a subset of the machine's. Silent when either list is empty.
    A toolhead whose type or nozzle does not match is not selectable — the menu item is disabled, drawn at 50% with a warning icon and a tooltip.A.aJQ.$1: r = type == type && nozzleDiameter == nozzleDiameter, then A.bEn(item, r, index)r is the enabled flag.
    Two distinct tooltips: dialog_filament_type_none_tips when the head reads NONE, dialog_filament_type_not_match_tips otherwise.A.aJQ.$1
    An unassigned filament card is bordered in the warning colour and shows ! where the toolhead number goes.A.A0.E: s ? ""+(a2+1) : "!", border s ? ax.to : ax.fy.
    Filament colour is three paint modes, not one: a solid, a row of equal bands (mode 0), or a linear gradient (mode 1). Alpha 0 draws a hatch, not black.A.abXEk / VO / the gradient branch, off filamentColorMulti.mode.
    A file filament with no material used is dropped from the grid.A.bEE: keep when used > 0 || usedMm > 0 || usedMm === −1.
    Estimated Materials is two decimals; the percentage is zero.toStringAsFixed(2) + " g"; toStringAsFixed(0) + "%".
    The printer list appends a synthetic “add device” row, and each row can carry a “Lan Mode” label and a green check on the connected one.A.bbC.$3, A.bbz.
    Refresh on Edit Filament is debounced.A.Nc("refreshFilament", B.d0, …).
    Auto Leveling writes bed_level — not auto_bed_leveling.A.bc_.$1
    Model Information has a second layout for multi-plate files: a plate dropdown with per-plate time, weight, nozzle and filament chips.A.J9.aFv / A.aj4, off partitions.
    The Send button has an 8-state colour machine and shows a spinner while working.A.Jb.aKO — states 1 / 2,3,4 / 5 / 6 / 7,0.
    + +

    Not drawn here

    +

    + Icons are Material Icons codepoints (57686 check, 58123 help, + 57912 warning, 61757 caret); the shapes below are stand-ins.
    + One deliberate departure in the rebuild. This page draws a device row the way the + bundle does — cover, name, “Lan Mode”, check — so two saved records with the same name + are two identical rows. The reconstruction adds the address under the name, and the + serial when that is what tells two rows apart; see deviceMeta() in + print_processing/js/views/printer/printer-view.js. Everything else on that + page is this one.
    + The spinner in the Send button while a send is in flight + (B.ayd = SizedBox(16,16, CircularProgressIndicator(strokeWidth 2))), and the + five button colours behind its eight states, are recovered but not animated here.
    + The empty-target disc — a circle with a −45° slash over a grey vertical gradient + (A.a6K.aF3) — is only reachable when the widget is built disabled, which this + surface never does.
    + Dark theme: the dialog reads Theme.of(context) throughout; only the light + values are measured, because only the light one was captured. +

    + + + + diff --git a/docs/u1-webui/05-printer-protocol/05-cloud-api.md b/docs/u1-webui/05-printer-protocol/05-cloud-api.md index a1b445566c0..d7759a79158 100644 --- a/docs/u1-webui/05-printer-protocol/05-cloud-api.md +++ b/docs/u1-webui/05-printer-protocol/05-cloud-api.md @@ -123,7 +123,7 @@ HTTP 403 Cloudflare error 1010 "browser_signature_banned" which is a user-agent rule, not an authentication result — the bearer token is never evaluated. Reaching it legitimately means letting the page make the call from Orca's webview, -where `fetchCloudCert()` in `device_page/js/core/connection.js` already implements it. Until then, +where `fetchCloudCert()` in `shared/js/connection.js` already implements it. Until then, these remain unconfirmed: the endpoint hostname, the real topic strings, and therefore whether the SN-prefixed C++ can address a WAN device at all. diff --git a/docs/u1-webui/STATUS.md b/docs/u1-webui/STATUS.md index 634e2d4740d..ccf2c3d799e 100644 --- a/docs/u1-webui/STATUS.md +++ b/docs/u1-webui/STATUS.md @@ -71,10 +71,10 @@ they are three because they answer three different questions: The third exists separately for a reason worth carrying: **a request stored in the thing that mirrors the machine gets overwritten by the next push.** That bug was found in three unrelated controls before it got one mechanism — -[`core/pending.js`](../../resources/web/device_page/js/core/pending.js). +[`shared/pending.js`](../../resources/web/shared/js/pending.js). Two more single-answer modules, each replacing several hand-rolled ones: -[`core/render.js`](../../resources/web/device_page/js/core/render.js) (build once, key +[`shared/render.js`](../../resources/web/shared/js/render.js) (build once, key structural change on a signature, reconcile lists by key) and [`core/session.js`](../../resources/web/device_page/js/core/session.js) (connect, staleness, retry, heartbeat, the state stream — no panel reads any of it). @@ -1106,7 +1106,7 @@ carrying: it timed out and reported itself lost, because nothing pushes the object that would confirm it. - **Fifteen macros, none awaited.** `ACE_BG_UNLOAD`'s own help says ~3 min, so every one - goes through `core/pending.js` and is confirmed against machine state. + goes through `shared/pending.js` and is confirmed against machine state. - **`check_coverage.py` now has a second surface.** The panel's controls are not bridge commands at all, so the old accounting could not see them and a macro deliberately not offered would have been silent rather than merely unbuilt. All 21 ACE macros are now @@ -1191,12 +1191,39 @@ Then, in rough order of value: is Orca's own Bonjour sweep, so the MQTT probes cannot reach it; it needs the app. 4. **`sw_SetSubscribeFilter` fails at boot** and is fired best-effort. Worth confirming it is genuinely optional rather than papered over. -5. **The print-processing popup has never been driven against hardware**, and has not had - the restructure either — it still shares `shared/` but keeps its own flat `app.js`. - Its send path (`sw_GetPrintZip` → `sw_StartLocalPrint` → the close protocol) is - unproven. The primitives the Device page grew (`pending`, `render`, the panel - contract) are the obvious things to lift across, and doing so is what would prove - they generalise. +5. **The print-processing popup is rebuilt, and has not met a connected printer.** + It is the shipped dialog now, recovered widget by widget from `main.dart.js` and + specified in + [original-dialog-mockup.html](03-print-processing/original-dialog-mockup.html), which + cites the source for every measurement. The page is restructured the way the Device + page is - a registry, a folder per panel, its own commands module each - and the + primitives it needed moved to `shared/`: `dom`, `render`, `pending`, the modal sheet + and the trace pane. **The simulated U1 and Orca are one host for both surfaces**, so + the two cannot be shown machines that disagree; the file's filaments and the machine's + come from one model, which is what makes the match test mean anything. + What the old page had wrong, and why, is + [04-requirements.md](03-print-processing/04-requirements.md). + + **It runs outside Orca both ways.** `--page` against the simulator, and `--real + --gcode ` against a printer with `u1_bridge.py` answering Orca's half out of a + real sliced plate - the filament list, the weights and the embedded thumbnail all come + from the file rather than a fixture. `sw_StartLocalPrint` is gated behind + `--allow-print`. + + **It has now run end to end against 811002511261022618B3** - upload, start, print, + cancel - which settled the send contract Orca's source does not contain and found a + bug the simulator could not: an identity fallback that placed two filaments on + toolheads the picker itself refuses. The account is + [03-print-processing/05-hardware-e2e.md](03-print-processing/05-hardware-e2e.md) and + the procedure is the `u1-hardware-test` skill. + + A cancel that worked reported failure, and the reason turned out to be the client's + own clock: Klipper runs G-code sequentially, so a command queues behind whatever is in + flight - measured at **dwell + ~250 ms** with `G4` blocking the queue. `sswcp.js` now + waits 80 s for a `PRINTER_BACKED` command and 15 s for one Orca answers, which is the + split `u1_bridge.py` already made. No plate has been left to run, and the cloud path is + untouched. + 6. **`sw_DeleteMachineFile` is implemented in Orca and unreferenced here.** Storage deliberately has no delete for print files — Delete sat a few pixels from a one-click Print and only one of them is reversible. If it is wanted, that should be a considered diff --git a/docs/u1-webui/data/bridge-methods.json b/docs/u1-webui/data/bridge-methods.json index 3e81b858611..a3c2babeef3 100644 --- a/docs/u1-webui/data/bridge-methods.json +++ b/docs/u1-webui/data/bridge-methods.json @@ -602,11 +602,10 @@ "params": [] }, "sw_StartLocalPrint": { - "forwarded": false, + "forwarded": true, "handler": "async_start_local_print", - "method": null, - "params": [], - "why": "no JSON-RPC method found behind async_start_local_print" + "method": "server.files.start_local_print", + "params": [] }, "sw_StartMachineFind": { "forwarded": false, diff --git a/docs/u1-webui/tools/check_mockup.py b/docs/u1-webui/tools/check_mockup.py index c635a2ad21d..1c7f40aae80 100755 --- a/docs/u1-webui/tools/check_mockup.py +++ b/docs/u1-webui/tools/check_mockup.py @@ -2058,6 +2058,191 @@ })(); """ +CHECKS["original-dialog-mockup.html"] = r""" +(function(){ + const out=[]; + try{ + const say=(n,g,w)=>out.push(`${g===w?'PASS':'FAIL'} ${n}`+(g===w?'':` got ${JSON.stringify(g)} want ${JSON.stringify(w)}`)); + const q=(s)=>document.querySelector(s); + const qq=(s)=>[...document.querySelectorAll(s)]; + const R=(e)=>e?e.getBoundingClientRect():null; + const W=(s)=>Math.round(R(q(s)).width), H=(s)=>Math.round(R(q(s)).height); + const seg=(g,v)=>{const b=q(`#seg-${g} button[data-v="${v}"]`); + if(!b) throw new Error('no control '+g+'/'+v); b.click();}; + const dlg=()=>R(q('#dlg')); + const at=(e)=>{const r=R(e),d=dlg();return [Math.round(r.left-d.left),Math.round(r.top-d.top)];}; + + say('title', document.title, 'The Upload and Print dialog, as shipped'); + say('no horizontal page scroll', document.documentElement.scrollWidth <= window.innerWidth+1, true); + let dup=0; const seen={}; + qq('[id]').forEach(e=>{ if(seen[e.id]) dup++; seen[e.id]=1; }); + say('no duplicate ids', dup, 0); + + // ---- the dialog is the size WebPreprintDialog opens ---- + say('dialog is 714 wide', Math.round(dlg().width), 714); + say('dialog is 750 tall', Math.round(dlg().height), 750); + + // ---- the four cards, against the capture ---- + // preuploadandprint.png: card 1 border rows at y=16..142, card 2 top at y=152. + seg('route','print'); seg('data','four'); seg('dev','one'); + say('four sections on path=4', qq('#dlg .card').length, 4); + const c=qq('#dlg .card'); + say('card width 680, as captured (x=16..696)', Math.round(R(c[0]).width), 680); + say('first card sits 16 below the top (B.aE)', at(c[0])[1], 17); // +1 dialog border + say('cards are 8 apart (B.aB)', + Math.round(R(c[1]).top - R(c[0]).bottom), 8); + say('card border is 2px (A.de(ax.ry,-1,2))', + getComputedStyle(c[0]).borderTopWidth, '2px'); + say('card padding is 12 (B.ch)', getComputedStyle(c[0]).paddingTop, '12px'); + say('title column clamps to its 120 minimum (B.m4)', + Math.round(R(q('#dlg .card > .title')).width), 120); + + // ---- Model Information ---- + say('thumbnail is 100x100 (A.cR ... 100, 100)', + [W('#dlg .mi svg'),H('#dlg .mi svg')].join('x'), '100x100'); + const facts=qq('#dlg .facts .f').map(e=>e.textContent.split(':')[0]); + say('three facts, in the shipped order', facts.join('/'), + 'Filename/Estimated Time/Estimated Materials'); + say('materials is two decimals (toStringAsFixed(2))', + /31\.40 g$/.test(qq('#dlg .facts .f')[2].textContent), true); + + // ---- Select Printer ---- + say('printer dropdown is 300x50 (A.a2l(50,300,...))', + [W('#dlg .picker'),H('#dlg .picker')].join('x'), '300x50'); + + // ---- Edit Filament: the part no screenshot ever showed ---- + say('one card per file filament', qq('#dlg .fcard').length, 4); + say('filament card is 80x100 (A.A0(...,80,100,8,...))', + [W('#dlg .fcard'),H('#dlg .fcard')].join('x'), '80x100'); + say('card radius 8', getComputedStyle(q('#dlg .fcard')).borderTopLeftRadius, '8px'); + // flex 3 : 1px rule : flex 4 over 100px of card, less 2px border + const sh=H('#dlg .fcard .swatch'), ph=H('#dlg .fcard .pickrow'); + say('colour block takes 3 of the 7 parts', Math.abs(sh-42)<=4, true); + say('picker takes 4 of the 7 parts', Math.abs(ph-56)<=4, true); + say('a 1px rule between them', H('#dlg .fcard .rule'), 1); + say('target disc is 28 (A.aJO(...,28,...,28))', W('#dlg .fcard .disc'), 28); + say('grid gap is 12 both ways (Wrap 12/12)', + getComputedStyle(q('#dlg .grid')).gap, '12px'); + // a segmented spool paints one band per colour, a gradient paints none + say('mode 0 paints one band per colour', + qq('#dlg .fcard')[3].querySelectorAll('.swatch .bands span').length, 3); + + // ---- an unassigned filament ---- + seg('data','mismatch'); + say('an unassigned card is bordered in the warning colour', + qq('#dlg .fcard.unset').length, 1); + say('and shows ! where the toolhead number goes', + q('#dlg .fcard.unset .disc .n').textContent, '!'); + say('mode 1 paints a gradient rather than bands', + /linear-gradient/.test(getComputedStyle( + qq('#dlg .fcard')[3].querySelector('.swatch')).backgroundImage), true); + + // ---- the fifth section: A.R5 ---- + // The plate wants 0.4 and every head has 0.4 or 0.2, so 0.4 IS present: no banner. + say('no banner while the plate nozzle is one the machine has', + qq('#dlg .nozwarn').length, 0); + + // ---- Print Preferences ---- + seg('data','four'); + say('three preferences', qq('#dlg .pref').length, 3); + say('preference row is 220x20 (B.VS max)', + [W('#dlg .pref'),H('#dlg .pref')].join('x'), '220x20'); + say('they stack one per line at this width', + at(qq('#dlg .pref')[0])[1] !== at(qq('#dlg .pref')[1])[1], true); + say('12 between rows (runSpacing)', + Math.round(R(qq('#dlg .pref')[1]).top - R(qq('#dlg .pref')[0]).bottom), 12); + say('only the first carries a help icon', + qq('#dlg .pref .help').length, 1); + say('toggle is 18x18', [W('#dlg .pref .box'),H('#dlg .pref .box')].join('x'), '18x18'); + qq('#dlg .pref')[2].click(); + say('a checked toggle turns B.fx green', + getComputedStyle(qq('#dlg .pref')[2].querySelector('.box')).backgroundColor, + 'rgb(76, 175, 80)'); + qq('#dlg .pref')[2].click(); + + // ---- the send bar ---- + say('progress bar is 8 tall', H('#dlg .track'), 8); + say('Send is 120x40', [W('#dlg .send'),H('#dlg .send')].join('x'), '120x40'); + say('Send is #2196F3 when live', + getComputedStyle(q('#dlg .send')).backgroundColor, 'rgb(33, 150, 243)'); + say('bar padding is 16 (B.bV)', getComputedStyle(q('#dlg .sendbar')).padding, '16px'); + say('percent reads zero decimals', q('#dlg .pct').textContent, '0%'); + + // ---- path=5 drops the print half ---- + seg('route','upload'); + say('upload-only keeps two sections', qq('#dlg .card').length, 2); + say('and names them', qq('#dlg .card > .title').length ? + qq('#dlg .card > .title').map(e=>e.firstChild.textContent).join('/') : '', + 'Model Information/Select Printer'); + + // ---- the three dropdown menus ---- + // DropdownButton2 geometry: MenuItemStyleData(height,padding) + DropdownStyleData + // (maxHeight,width,padding,decoration,elevation,offset). Every number is a constant. + seg('data','four'); seg('dev','one'); + + seg('menu','printer'); + const pm=q('#dlg .menu.m-printer'); + say('printer menu is 300 wide', Math.round(R(pm).width), 300); + say('printer items are 50 tall (B.aqV)', Math.round(R(pm.children[0]).height), 50); + say('printer item padding is 16 (B.c8)', getComputedStyle(pm.children[0]).paddingLeft, '16px'); + say('the saved devices plus the synthetic add-device row', pm.children.length, 3); + say('the connected one carries a green check', + getComputedStyle(pm.querySelector('.tick')).color, 'rgb(76, 175, 80)'); + say('add device carries no Lan Mode label', + pm.children[2].querySelector('.lan'), null); + + seg('menu','head'); + const hm=q('#dlg .menu.m-head'); + say('toolhead menu is 200 wide', Math.round(R(hm).width), 200); + say('toolhead items are 48 tall (B.aqU)', Math.round(R(hm.children[0]).height), 48); + say('toolhead menu caps at 300', parseInt(getComputedStyle(hm).maxHeight,10), 300); + say('and is radius 8', getComputedStyle(hm).borderTopLeftRadius, '8px'); + // offset (-50, 0) off the trigger's left edge. The trigger sits inside a flex + // row, so its left edge is fractional; allow the one pixel that costs. + say('offset is -50 from the trigger (B.asz)', + Math.abs((R(hm).left - R(q('#trg-head')).left) + 50) <= 1, true); + say('one item per toolhead', hm.children.length, 4); + // a head matches on type AND nozzle; head 3 is NONE, head 4 has a 0.2 nozzle + say('two heads are unpickable', qq('#dlg .menu.m-head .mitem[aria-disabled="true"]').length, 2); + say('a NONE head gets its own tooltip', hm.children[2].title, 'dialog_filament_type_none_tips'); + say('a wrong-nozzle head gets the other', hm.children[3].title, 'dialog_filament_type_not_match_tips'); + say('a matching head has no tooltip', hm.children[0].title, ''); + say('the assigned head carries a check', !!hm.children[0].querySelector('.tick'), true); + say('every unpickable head carries a warning', + qq('#dlg .menu.m-head .mitem[aria-disabled="true"] .warnic').length, 2); + + seg('menu','plate'); + say('opening the plate menu switches Model Information to its partitions layout', + q('#seg-mi button[aria-pressed="true"]').dataset.v, 'multi'); + const qm=q('#dlg .menu.m-plate'); + say('plate items are 80 tall (B.aqW)', Math.round(R(qm.children[0]).height), 80); + say('plate item padding is LTRB(8,6,8,6) (B.rT)', + [getComputedStyle(qm.children[0]).paddingTop, + getComputedStyle(qm.children[0]).paddingLeft].join('/'), '6px/8px'); + say('plate menu caps at 480', parseInt(getComputedStyle(qm).maxHeight,10), 480); + say('and rounds only its bottom corners (B.VC)', + [getComputedStyle(qm).borderTopLeftRadius, + getComputedStyle(qm).borderBottomLeftRadius].join('/'), '0px/4px'); + say('plate menu is as wide as its button', + Math.round(R(qm).width), Math.round(R(q('#trg-plate')).width)); + say('the plate button is 90 tall', Math.round(R(q('#trg-plate')).height), 90); + say('the selected plate carries a check', !!qm.children[0].querySelector('.tick'), true); + say('a plate row lists its filaments as chips', + qm.children[0].querySelectorAll('.chip').length, 4); + seg('menu','none'); + say('closing removes every menu', qq('#dlg .menu').length, 0); + + // ---- as captured: no filament data ---- + seg('mi','single'); seg('route','print'); seg('data','empty'); + say('with no filament requirements the grid is empty, as both captures show', + qq('#dlg .fcard').length, 0); + say('but the section is still drawn', qq('#dlg .card').length, 4); + + }catch(e){ out.push('FAIL check threw: '+(e&&e.message||e)); } + window.__report = out.join('\n'); +})(); +""" + CHECK = CHECKS.get(os.path.basename(PATH)) if CHECK is None: print(f"no checks for {os.path.basename(PATH)}. Known: {', '.join(sorted(CHECKS))}") diff --git a/docs/u1-webui/tools/extract_bridge_methods.py b/docs/u1-webui/tools/extract_bridge_methods.py index fd32cb53c47..55e07c436c5 100644 --- a/docs/u1-webui/tools/extract_bridge_methods.py +++ b/docs/u1-webui/tools/extract_bridge_methods.py @@ -63,15 +63,44 @@ def member_bodies(lines, cls): yield name, "\n".join(lines[i:end]) +def method_constants(text): + """`static const std::string METHOD_X = "printer.y";` -> {"METHOD_X": "printer.y"}. + + Not every method name is a literal at the call site. Some are hoisted to a file-scope + constant and the call reads `send_to_request(METHOD_START_LOCAL_PRINT, ...)`, which a + search for a quoted string right after the paren cannot see. + + `sw_StartLocalPrint` was the one that mattered: it came out with `method: null` and + the honest reason "no JSON-RPC method found behind async_start_local_print", so + u1_bridge refused to forward it and the send path stopped one command short of + starting a print. The reason was true and the conclusion was wrong - the method is + `server.files.start_local_print`, three lines above the call. + """ + return dict(re.findall( + r'\bconst\s+std::string\s+(\w+)\s*=\s*"([^"]+)"\s*;', text)) + + def moonraker_methods(): """async_* -> {method, params, forwarded}.""" + src = read(MOONRAKER) + consts = method_constants("\n".join(src)) out = {} - for name, body in member_bodies(read(MOONRAKER), "Moonraker_Mqtt"): + for name, body in member_bodies(src, "Moonraker_Mqtt"): if not name.startswith("async_"): continue m = (re.search(r'method\s*=\s*"([^"]+)"', body) or re.search(r'send_to_request\(\s*"([^"]+)"', body)) if not m: + # ...or the method is one of those constants, named rather than quoted. + named = (re.search(r'\bmethod\s*=\s*(\w+)\s*;', body) + or re.search(r'send_to_request\(\s*(\w+)\s*,', body)) + if named and named.group(1) in consts: + keys = sorted(set(re.findall(r'params\[\s*"([^"]+)"\s*\]', body))) + cand = {"method": consts[named.group(1)], "params": keys, + "forwarded": not keys} + prev = out.get(name) + if prev is None or (cand["forwarded"] and not prev["forwarded"]): + out[name] = cand continue keys = sorted(set(re.findall(r'params\[\s*"([^"]+)"\s*\]', body))) # `send_to_request(method, params_from_caller, ...)` with no params[...] of its diff --git a/docs/u1-webui/tools/u1_bridge.py b/docs/u1-webui/tools/u1_bridge.py index 16b3b3ce267..814852756ae 100644 --- a/docs/u1-webui/tools/u1_bridge.py +++ b/docs/u1-webui/tools/u1_bridge.py @@ -33,7 +33,11 @@ import json import socket import os +import zipfile +import hashlib +import base64 import queue +import re import ssl import sys import tempfile @@ -205,13 +209,205 @@ def write(text, suffix): # ----------------------------------------------------------------------------- host + +class PreprintJob: + """Orca's half of the print dialog, read out of a real sliced .gcode. + + The dialog asks Orca four things about the job - which file, what filaments it needs, + whether the preset matches the machine, and where to fetch the packaged file - and + outside Orca there is no sliced plate to ask. There IS a .gcode, and Orca's own + slicer wrote everything the dialog wants into its trailing comment block: + + ; filament_type = PLA;PLA;PETG;ABS + ; filament colour = #E03131;#1971C2;... + ; filament used [g] = 12.40,9.10,... + ; nozzle_diameter = 0.4,0.4,0.4,0.4 + ; estimated printing time (normal mode) = 1h 13m 32s + ; printer_model = Snapmaker U1 + ; thumbnail begin 512x512 ... (base64, one comment per line) + + So this parses rather than invents, which is the difference between exercising the + dialog and exercising a fixture. A field the file does not carry is left out of the + reply, exactly as the C++ leaves it out - `sw_GetFileFilamentMapping` builds its + response key by key from whatever the config has. + """ + + # `; key = value` at the end of an Orca/PrusaSlicer gcode. + _KV = re.compile(r"^;\s*([^=;]+?)\s*=\s*(.*)$") + + def __init__(self, path, log=_log): + self.path = os.path.abspath(path) + self.log = log + self.name = os.path.basename(self.path) + self.size = os.path.getsize(self.path) + self.meta, self.thumbnails = self._parse() + self.zip_path = None + # What the page reported through the close protocol, so a drive script can read + # back what the dialog actually said rather than trusting that it said it. + self.preprint_status = None + self.mapping_outcome = None + self.closed = False + self.started_with = None + + # ---- parsing --------------------------------------------------------- + + def _parse(self): + """Read the trailing comment block and any embedded thumbnails. + + Only the tail is read: a sliced plate is tens of megabytes of moves and the + metadata is in the last few kilobytes. The thumbnails are near the HEAD, so + they get their own bounded scan. + """ + meta = {} + with open(self.path, "rb") as fh: + fh.seek(max(0, self.size - 65536)) + tail = fh.read().decode("utf-8", "replace") + for line in tail.splitlines(): + m = self._KV.match(line) + if m: + meta[m.group(1).strip()] = m.group(2).strip() + + thumbs = [] + with open(self.path, "rb") as fh: + head = fh.read(2 * 1024 * 1024).decode("utf-8", "replace") + cur = None + for line in head.splitlines(): + t = line.strip() + if t.startswith("; thumbnail begin"): + bits = t.split() + dims = bits[3] if len(bits) > 3 else "0x0" + w, _, h = dims.partition("x") + cur = {"w": w, "h": h, "b64": []} + elif t.startswith("; thumbnail end"): + if cur: + thumbs.append(cur) + cur = None + elif cur is not None and t.startswith(";"): + cur["b64"].append(t[1:].strip()) + return meta, thumbs + + def _list(self, key, sep=None): + raw = self.meta.get(key) + if raw is None: + return None + # Orca writes filament_type with ';' and the numeric lists with ','. + parts = raw.split(sep) if sep else re.split(r"[;,]", raw) + return [p.strip() for p in parts if p.strip() != ""] + + def _seconds(self): + """`estimated printing time (normal mode) = 1h 13m 32s` -> 4412.""" + raw = self.meta.get("estimated printing time (normal mode)") \ + or self.meta.get("estimated printing time") + if not raw: + return None + total = 0 + for n, unit in re.findall(r"(\d+)\s*([dhms])", raw): + total += int(n) * {"d": 86400, "h": 3600, "m": 60, "s": 1}[unit] + return total or None + + # ---- what the dialog asks -------------------------------------------- + + def active_file(self, is_zip=False): + if is_zip: + return {"file_name": os.path.splitext(self.name)[0] + ".zip", + "file_path": self.zip(), "origin_size": self.size, "metadata": {}} + return {"filename": self.name, "metadata": {}} + + def filament_mapping(self): + """`sw_GetFileFilamentMapping`'s reply, key for key as SSWCP.cpp:3039 builds it. + + PARALLEL ARRAYS. There is no `filaments[]` here and there never has been. + """ + out = {"filename": self.name, "filepath": self.path} + + types = self._list("filament_type") + if types: + out["filament_type"] = types + + colors = self._list("filament colour") or self._list("filament_colour") + if colors: + out["filament_color_rgba"] = colors + out["filament_color"] = [ + int(c.lstrip("#")[:6], 16) if re.fullmatch(r"#?[0-9a-fA-F]{6,8}", c) else 0 + for c in colors] + + used_g = self._list("filament used [g]", sep=",") + if used_g: + out["filament_weight"] = [float(x) for x in used_g] + out["filament_weight_total"] = round(sum(out["filament_weight"]), 4) + used_mm = self._list("filament used [mm]", sep=",") + if used_mm: + out["filament_used_mm"] = [float(x) for x in used_mm] + + nozzles = self._list("nozzle_diameter", sep=",") + if nozzles: + # The reply carries them per FILAMENT; a single-value header means one + # nozzle for all of them, which is what the C++ ends up with too. + n = len(types or nozzles) + out["nozzle_diameters"] = (nozzles * n)[:n] if len(nozzles) == 1 else nozzles + out["nozzle_info"] = sorted(set(nozzles)) + + secs = self._seconds() + if secs: + out["estimated_time"] = secs + model = self.meta.get("printer_model") or self.meta.get("printer_settings_id") + if model: + out["machine_model"] = model + + if self.thumbnails: + t = self.thumbnails[-1] # the largest Orca wrote + out["thumbnails"] = [{"url": "data:image/png;base64," + "".join(t["b64"]), + "width": int(t["w"] or 0), "height": int(t["h"] or 0)}] + else: + out["thumbnails"] = [] + + # Orca's own filament->extruder map lives in its config, which this host does not + # have; identity is what the popup falls back to when the key is absent. + return out + + def zip(self): + """Package the gcode the way `generate_zip_path` / `create_zip_with_miniz` do. + + One entry, named after the DISPLAY name. Cached, because the real one is too - + `get_or_create_zip_json` reuses an existing zip rather than rebuilding it. + """ + if self.zip_path and os.path.exists(self.zip_path): + return self.zip_path + out = os.path.join(tempfile.gettempdir(), + os.path.splitext(self.name)[0] + ".zip") + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + z.write(self.path, arcname=self.name) + self.zip_path = out + self.log(f"[bridge] packaged {self.name} -> {out} " + f"({os.path.getsize(out)} bytes)") + return out + + def checksum(self): + h = hashlib.sha256() + with open(self.path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return base64.b64encode(h.digest()).decode() + + class Bridge: - def __init__(self, send, log=_log, devices=None, trace=120): + def __init__(self, send, log=_log, devices=None, trace=120, gcode=None, + allow_print=False): # send(msg, as_string=True) - the WCP envelope travels as a JSON string, which # is what send_to_js posts; the legacy `command` messages travel as an object. self.send, self.log = send, log self.trace = trace # how much of each command's params to log self.devices = devices if devices is not None else orca_devices() + # The print dialog's Orca half. `gcode` is a real sliced file; everything the + # dialog asks about the job is read out of it rather than invented. Without one, + # those commands refuse in as many words - see PreprintJob. + self.job = PreprintJob(gcode, log=log) if gcode else None + # sw_StartLocalPrint STARTS A PRINT on a real machine. Off unless asked for. + self.allow_print = allow_print + # How a local path becomes a URL the page can fetch. run_webkit already serves + # resources/ over HTTP and passes a function in; without one, sw_GetFileStream + # says so rather than handing back a URL that goes nowhere. + self.file_url = None self.clients = {} # id -> Client self.subs = {} # (client id, topic) -> [event_id] self.status_events = [] # from sw_SubscribeMachineState @@ -261,6 +457,17 @@ def __init__(self, send, log=_log, devices=None, trace=120): "sw_GetCache": self._get_cache, "sw_SetCache": self._set_cache, "sw_RemoveCache": self._remove_cache, + # ---- the print dialog's Orca half. Everything here is answered out of + # the .gcode handed to --gcode, so the dialog sees a real plate rather than + # a fixture; without one they refuse and say why. + "sw_GetActiveFile": self._active_file, + "sw_GetFileFilamentMapping": self._file_filament_mapping, + "sw_GetFileStream": self._file_stream, + "sw_GetPrintZip": self._print_zip, + "sw_GetPrintLegal": self._print_legal, + "sw_FinishPreprint": self._finish_preprint, + "sw_SetFilamentMappingComplete": self._mapping_complete, + "sw_FinishFilamentMapping": self._finish_mapping, # Orca answers this about itself, so the honest answer here is us. "sw_GetSoftwareInfo": lambda s, p, e: self._ok( s, {"version": "u1_bridge", "build_number": ""}), @@ -323,6 +530,23 @@ def handle(self, text): if cmd in REFUSED: return self._err(seqid, -1, f"{cmd}: {REFUSED[cmd]}") + # The one command on this host that MOVES A MACHINE. Everything else here reads, + # lists or subscribes; this one starts a print, and a suite that can start a + # print is a suite that will, on somebody's bed, at 3am. Off unless asked for. + if cmd in ("sw_StartLocalPrint", "sw_StartCloudPrint") and not self.allow_print: + return self._err(seqid, -1, + f"{cmd} would START A PRINT on the real machine. " + f"Pass --allow-print to let it through; without it the " + f"dialog's send path is exercised right up to this point.") + if cmd == "sw_StartLocalPrint": + # Its own precondition, enforced here too - a host that accepted `{}` is how + # the reconstruction came to send `{}` and never find out. + if params.get("type") is None or params.get("path") is None: + return self._err(seqid, -1, "param [type] or [path] required!") + if self.job: + self.job.started_with = {"type": params.get("type"), + "path": params.get("path")} + entry = self.table.get(cmd) if entry and entry.get("method"): self._call(seqid, cmd, entry, params) @@ -535,6 +759,110 @@ def probe_devices(self): self.log(f"[bridge] {d.get('dev_name')} at {d.get('ip')}: " f"{'reachable' if up else 'not answering'}") + # ---- the print dialog's Orca half ----------------------------------- + # + # These are answered by ORCA, never by the printer, so none of them is forwarded and + # none comes back in a JSON-RPC envelope. What they say about the JOB comes out of + # the real .gcode passed to --gcode. Without one they refuse in as many words: to + # the page a silent refusal and an unimplemented command look identical, and being + # able to tell them apart is the whole point of running outside Orca. + + def _need_job(self, seqid, cmd): + if self.job: + return True + self._err(seqid, -1, + f"{cmd} needs a sliced plate, which lives inside Orca. " + f"Pass --gcode to answer it from a real one.") + return False + + def _active_file(self, seqid, params, event_id): + if not self._need_job(seqid, "sw_GetActiveFile"): + return + self._ok(seqid, self.job.active_file(bool(params.get("is_zip")))) + + def _file_filament_mapping(self, seqid, params, event_id): + if not self._need_job(seqid, "sw_GetFileFilamentMapping"): + return + reply = self.job.filament_mapping() + self.log(f"[bridge] mapping: {len(reply.get('filament_type') or [])} filament(s), " + f"{len(reply.get('thumbnails') or [])} thumbnail(s)") + self._ok(seqid, reply) + + def _file_stream(self, seqid, params, event_id): + """The right door for the file: a URL, a size and a SHA-256. + + Orca returns a localhost URL on its own page server. This host serves the zip + over the same HTTP server the page itself came from - run_webkit passes one in - + so the page's `fetch` works unchanged. + """ + if not self._need_job(seqid, "sw_GetFileStream"): + return + want_zip = bool(params.get("is_zip")) + path = self.job.zip() if want_zip else self.job.path + name = os.path.basename(path) + if not self.file_url: + self._err(seqid, -1, "no local file server to hand the page a URL from") + return + self._ok(seqid, {"file_name": name, + "file_url": self.file_url(path), + "origin_size": os.path.getsize(path), + "checksum": self.job.checksum()}) + + def _print_zip(self, seqid, params, event_id): + """The other door, implemented honestly so its cost is visible. + + `m_res_data["content"] = res["zip_data"]` where zip_data is a std::vector, + which nlohmann serialises as ONE JSON INTEGER PER BYTE. This sends the same + shape; a 12 MB zip really does cross as ~40 MB of JSON here too. + """ + if not self._need_job(seqid, "sw_GetPrintZip"): + return + path = self.job.zip() + data = open(path, "rb").read() + self.log(f"[bridge] sw_GetPrintZip: {len(data)} bytes as a JSON array of " + f"integers - this is the door the dialog should NOT use") + self._ok(seqid, {"name": os.path.basename(path), "content": list(data)}) + + def _print_legal(self, seqid, params, event_id): + """Orca compares the EDITED printer preset against the connected model. + + Outside Orca there is no edited preset; the plate's own `printer_model` is what + it was sliced for, which is the same question asked of the file instead of the + application. + """ + if not self._need_job(seqid, "sw_GetPrintLegal"): + return + preset = self.job.meta.get("printer_model") \ + or self.job.meta.get("printer_settings_id") or "" + connected = params.get("connected_model") or "" + self._ok(seqid, {"preset_model": preset, "legal": preset == connected}) + + # The close protocol. There is no modal to end here, so these RECORD what the page + # reported - which is what a drive script needs to check the ordering. + def _finish_preprint(self, seqid, params, event_id): + if self.job: + self.job.preprint_status = params.get("status") + self.log(f"[bridge] sw_FinishPreprint status={params.get('status')!r}") + self._ok(seqid, {}) + + def _mapping_complete(self, seqid, params, event_id): + status = params.get("status") + if status not in ("success", "canceled"): + # Orca raises a native "setting failed" dialog and records nothing. + self._err(seqid, -1, f"setting failed (status {status!r})") + return + if self.job: + self.job.mapping_outcome = status + self.log(f"[bridge] sw_SetFilamentMappingComplete status={status!r} " + f"- recorded, dialog NOT closed") + self._ok(seqid, {}) + + def _finish_mapping(self, seqid, params, event_id): + if self.job: + self.job.closed = True + self.log("[bridge] sw_FinishFilamentMapping - this is the one that closes it") + self._ok(seqid, {}) + def _get_devices(self, seqid, params, event_id): if event_id: self.subs.setdefault(("__devices__", ""), []).append(event_id) diff --git a/resources/.gitignore b/resources/.gitignore new file mode 100644 index 00000000000..0a9bc0bbecf --- /dev/null +++ b/resources/.gitignore @@ -0,0 +1,7 @@ +# Where run_webkit.py drops a packaged plate so the page can fetch it. +# +# `sw_GetFileStream` hands the page a URL rather than the bytes, so the zip has to be +# reachable over HTTP - the harness copies it under the directory it is already serving, +# which is THIS one. It first went in resources/web/, one level too deep, and the file +# showed up untracked on the next commit. +_bridge_files/ diff --git a/resources/web/device_page/README.md b/resources/web/device_page/README.md index 060552e022c..a2e61be6633 100644 --- a/resources/web/device_page/README.md +++ b/resources/web/device_page/README.md @@ -62,11 +62,11 @@ js/views/device-control/camera/ | `js/page-commands.js` | The few commands that belong to no single panel | | `js/views///` | One directory per panel — see above | | `js/core/store.js` | **What the page remembers** — view, tab, fetched lists, chosen tool | -| `js/core/pending.js` | What was asked for and not yet confirmed. Neither state nor store | -| `js/core/render.js` | The one update discipline: rebuild on a signature, reconcile by key | -| `js/core/dom.js` | `$`, `el`, `icon` — the three primitives every view builds with | +| `../shared/js/pending.js` | What was asked for and not yet confirmed. Neither state nor store. **Shared** — the print dialog uses it too | +| `../shared/js/render.js` | The one update discipline: rebuild on a signature, reconcile by key. **Shared** | +| `../shared/js/dom.js` | `$`, `el`, `icon` — the three primitives every view builds with. **Shared** | | `js/core/session.js` | Having a printer on the other end: connect, staleness, retry, the stream | -| `js/core/connection.js` | The connect path itself: pairing, mTLS, the MQTT engine | +| `../shared/js/connection.js` | The connect path itself: pairing, mTLS, the MQTT engine | | `js/core/overlay.js` | Menus, dialogs and popovers | | `js/core/diag.js` | The `?diag=1` beacon | | `js/core/mock.js` | Installs the shared simulator, so the page runs with no hardware | diff --git a/resources/web/device_page/css/device.css b/resources/web/device_page/css/device.css index 18c198485cd..780556d5e64 100644 --- a/resources/web/device_page/css/device.css +++ b/resources/web/device_page/css/device.css @@ -549,7 +549,7 @@ body { .ace-src:hover:not(:disabled) { color: var(--accent); } .ace-src:disabled { opacity: .42; cursor: not-allowed; } .ace-src:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } -/* Held on screen until the machine agrees it happened - see core/pending.js. */ +/* Held on screen until the machine agrees it happened - see shared/pending.js. */ .ace-src[data-pend="sent"] { box-shadow: inset 0 0 0 1px var(--accent); } .ace-src[data-pend="lost"] { box-shadow: inset 0 0 0 1px var(--warn); } diff --git a/resources/web/device_page/js/app.js b/resources/web/device_page/js/app.js index 042c8a317b3..0c8ce1b9ad8 100644 --- a/resources/web/device_page/js/app.js +++ b/resources/web/device_page/js/app.js @@ -14,25 +14,25 @@ import { CMD, SUBSCRIBE_OBJECTS, NAMED, LIMITS, TASK_CONFIG, PRINT_PREFERENCES, asDeviceList, deviceLabel, DEVICE, hasTlsMaterial, CAMERA_DOMAIN, CAMERA_INTERVAL, cssColor, timelapseUrl } from '../../shared/js/protocol.js'; -import { openMenu, openDialog, openBlockingDialog, toggleField, numberField } - from './core/overlay.js'; -import { connect as connectDevice, disconnect as disconnectDevice } from './core/connection.js'; +import { openMenu } from './core/overlay.js'; +import { openDialog, openBlockingDialog, toggleField, numberField } from '../../shared/js/dialog.js'; +import { connect as connectDevice, disconnect as disconnectDevice } from '../../shared/js/connection.js'; import { Sswcp, isTimeout } from '../../shared/js/sswcp.js'; import { MachineState } from '../../shared/js/state.js'; import { machineActivity, isBusy } from '../../shared/js/activity.js'; import { installMock } from './core/mock.js'; -import { Pending } from './core/pending.js'; +import { Pending } from '../../shared/js/pending.js'; import { createStore } from './core/store.js'; import { createSession } from './core/session.js'; import { createOrcaSync } from './core/orcasync.js'; import { DIAG, createLog } from './core/diag.js'; import { mountBuildBadge } from '../../shared/js/buildinfo.js'; -import { $ } from './core/dom.js'; +import { $ } from '../../shared/js/dom.js'; import { renderRail } from './widgets/rail.js'; -import { makeTrace } from './widgets/trace.js'; +import { makeTrace } from '../../shared/js/trace.js'; import { buildShell, paint } from './shell.js'; // aliased: this file has its own render() -import * as renderPrims from './core/render.js'; +import * as renderPrims from '../../shared/js/render.js'; import * as cmdPage from './page-commands.js'; import * as cmdControl from './views/device-control/control/control-commands.js'; import * as cmdTask from './views/device-control/task/task-commands.js'; diff --git a/resources/web/device_page/js/core/overlay.js b/resources/web/device_page/js/core/overlay.js index 6b6254ac18f..ff5419d1fca 100644 --- a/resources/web/device_page/js/core/overlay.js +++ b/resources/web/device_page/js/core/overlay.js @@ -1,20 +1,20 @@ /* - * overlay.js - menus and modal dialogs for the Device tab. + * overlay.js - anchored menus and popovers for the Device tab. * - * The shipped page opens a small anchored menu from the device selector and - * modal sheets for editing values. This provides both, so the reconstruction - * behaves like the original instead of falling back to window.prompt(). + * The shipped page opens a small anchored menu from the device selector, and popovers + * that follow the state they draw. Both are this surface's. + * + * The MODAL SHEET moved to shared/js/dialog.js when the print dialog needed one: a + * second scrim and a second Escape handler is not a thing to have two of. The menus + * stayed, because the print dialog's pickers are a different widget - value lists with + * a per-item enabled flag - and not a variant of this one. */ 'use strict'; -let openMenuEl = null; +import { el } from '../../../shared/js/dom.js'; +import { closeDialog } from '../../../shared/js/dialog.js'; -function el(tag, cls, text) { - const n = document.createElement(tag); - if (cls) n.className = cls; - if (text != null) n.textContent = text; - return n; -} +let openMenuEl = null; export function closeMenu() { if (openMenuEl) { openMenuEl.remove(); openMenuEl = null; } @@ -27,7 +27,8 @@ document.addEventListener('click', (e) => { } }); document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { closeMenu(); closePopover(); if (!blocking) closeDialog(); } + // Dialogs bring their own Escape handling with them - see shared/js/dialog.js. + if (e.key === 'Escape') { closeMenu(); closePopover(); } }); /** @@ -90,137 +91,6 @@ export function openMenu(anchor, items, { head = null } = {}) { return m; } -/* ---- modal dialog --------------------------------------------------- */ - -let openDialogEl = null; -let blocking = false; // a blocking dialog ignores Escape, the scrim and the X - -export function closeDialog() { - if (openDialogEl) { openDialogEl.remove(); openDialogEl = null; } - blocking = false; -} - -/** - * Modal sheet. `build(body)` fills the body; `onConfirm()` returning false - * keeps it open (so a validator can reject). - * - * `cancel: false` drops the Cancel button, for a sheet that only tells you something: - * offering both Cancel and Close for one dismissal is a choice that is not one. - */ -export function openDialog({ title, build, confirmLabel = 'OK', onConfirm, wide = false, - cancel = true }) { - closeDialog(); - const scrim = el('div', 'scrim'); - const box = el('div', 'dialog' + (wide ? ' wide' : '')); - - const head = el('div', 'dialog-head'); - head.appendChild(el('h3', null, title)); - const x = el('button', 'dialog-x', '×'); - x.onclick = closeDialog; - head.appendChild(x); - box.appendChild(head); - - const body = el('div', 'dialog-body'); - build(body); - box.appendChild(body); - - const foot = el('div', 'dialog-foot'); - if (cancel) { - const no = el('button', 'btn', 'Cancel'); - no.onclick = closeDialog; - foot.appendChild(no); - } - const ok = el('button', 'btn primary', confirmLabel); - ok.onclick = () => { if (onConfirm() !== false) closeDialog(); }; - foot.appendChild(ok); - box.appendChild(foot); - - scrim.appendChild(box); - scrim.onclick = (e) => { if (e.target === scrim && !blocking) closeDialog(); }; - document.body.appendChild(scrim); - openDialogEl = scrim; - - const first = box.querySelector('input, select, button.primary'); - if (first) first.focus(); - return box; -} - -/** A labelled number field, returned with a .value getter. */ -export function numberField(parent, { label, value, min, max, unit, hint }) { - const wrap = el('label', 'field'); - wrap.appendChild(el('span', 'field-label', label)); - const row = el('div', 'field-row'); - const input = el('input'); - input.type = 'number'; - input.value = String(value ?? 0); - if (min != null) input.min = String(min); - if (max != null) input.max = String(max); - row.appendChild(input); - if (unit) row.appendChild(el('span', 'field-unit', unit)); - wrap.appendChild(row); - if (hint) wrap.appendChild(el('span', 'field-hint', hint)); - parent.appendChild(wrap); - return input; -} - -export function toggleField(parent, { label, checked }) { - const row = el('label', 'field-toggle'); - row.appendChild(el('span', null, label)); - const input = el('input'); - input.type = 'checkbox'; - input.checked = !!checked; - row.appendChild(input); - parent.appendChild(row); - return input; -} - -/** - * A modal that cannot be dismissed, for an operation the machine is in the middle of. - * - * The shipped page blocks the surface while a toolchange runs, and it is right to: the - * gantry is moving, and a second command sent into that window is not something the - * user meant. No close button, no scrim click, no Escape - it goes away when the caller - * says the operation is over. - * - * Returns { update(text), fail(text), close() }. - */ -export function openBlockingDialog({ title, message }) { - closeDialog(); - const scrim = el('div', 'scrim'); - const box = el('div', 'dialog blocking'); - - const head = el('div', 'dialog-head'); - head.appendChild(el('h3', null, title)); - box.appendChild(head); - - const body = el('div', 'dialog-body'); - const spinner = el('div', 'spinner'); - body.appendChild(spinner); - const msg = el('p', 'blocking-msg', message || ''); - body.appendChild(msg); - box.appendChild(body); - - scrim.appendChild(box); - document.body.appendChild(scrim); - openDialogEl = scrim; - blocking = true; - - return { - update(text) { msg.textContent = text; }, - fail(text) { - msg.textContent = text; - spinner.remove(); - blocking = false; // let the user out again - const foot = el('div', 'dialog-foot'); - const ok = el('button', 'btn primary', 'Close'); - ok.onclick = closeDialog; - foot.appendChild(ok); - box.appendChild(foot); - }, - close() { blocking = false; closeDialog(); }, - }; -} - /* ---- anchored popover ------------------------------------------------ */ let openPopEl = null; diff --git a/resources/web/device_page/js/core/session.js b/resources/web/device_page/js/core/session.js index 7255a624988..f79ffb5156e 100644 --- a/resources/web/device_page/js/core/session.js +++ b/resources/web/device_page/js/core/session.js @@ -25,8 +25,8 @@ import { CMD, SUBSCRIBE_OBJECTS, DEVICE, deviceLabel, hasTlsMaterial } from '../../../shared/js/protocol.js'; -import { openDialog } from './overlay.js'; -import { connect as connectDevice, disconnect as disconnectDevice } from './connection.js'; +import { openDialog } from '../../../shared/js/dialog.js'; +import { connect as connectDevice, disconnect as disconnectDevice } from '../../../shared/js/connection.js'; /** * @param bridge () => the live Sswcp client. A getter, because the session is built diff --git a/resources/web/device_page/js/shell.js b/resources/web/device_page/js/shell.js index 184167b1ce9..249e27bac2d 100644 --- a/resources/web/device_page/js/shell.js +++ b/resources/web/device_page/js/shell.js @@ -23,8 +23,8 @@ */ 'use strict'; -import { $, el, icon } from './core/dom.js'; -import { text, data } from './core/render.js'; +import { $, el, icon } from '../../shared/js/dom.js'; +import { text, data } from '../../shared/js/render.js'; import { repaintPopover } from './core/overlay.js'; import { PANELS, VIEWS } from './registry.js'; diff --git a/resources/web/device_page/js/views/device-control/camera/camera-view.js b/resources/web/device_page/js/views/device-control/camera/camera-view.js index 587304ddc32..9834a157bc0 100644 --- a/resources/web/device_page/js/views/device-control/camera/camera-view.js +++ b/resources/web/device_page/js/views/device-control/camera/camera-view.js @@ -11,14 +11,14 @@ */ 'use strict'; -import { $, el } from '../../../core/dom.js'; +import { $, el } from '../../../../../shared/js/dom.js'; import { DEVICE, MOONRAKER_HTTP_PORT, CAMERA_FRAME_ROOT, CAMERA_FRAME_FILE, CAMERA_TRANSPORT, CAMERA_TRANSPORT_ORDER, CAMERA_TRANSPORT_TEXT, CAMERA_VIEW, CAMERA_VIEW_TILES, CAMERA_FPS_CHOICES, transportUsable, cameraLabel, snapshotUrl } from '../../../../../shared/js/protocol.js'; import { illustration } from '../../../widgets/art.js'; -import { rebuildOn, text } from '../../../core/render.js'; +import { rebuildOn, text } from '../../../../../shared/js/render.js'; import { openPopover } from '../../../core/overlay.js'; /** diff --git a/resources/web/device_page/js/views/device-control/control/control-commands.js b/resources/web/device_page/js/views/device-control/control/control-commands.js index 48c5d8e8cc9..9a36edb8b84 100644 --- a/resources/web/device_page/js/views/device-control/control/control-commands.js +++ b/resources/web/device_page/js/views/device-control/control/control-commands.js @@ -17,7 +17,7 @@ import { CMD, NAMED, LIMITS, PRINT_PREFERENCES, prefsLine } from '../../../../../shared/js/protocol.js'; -import { openDialog, openBlockingDialog, toggleField } from '../../../core/overlay.js'; +import { openDialog, openBlockingDialog, toggleField } from '../../../../../shared/js/dialog.js'; import { isTimeout } from '../../../../../shared/js/sswcp.js'; import { machineActivity, isBusy } from '../../../../../shared/js/activity.js'; diff --git a/resources/web/device_page/js/views/device-control/control/control-panel.js b/resources/web/device_page/js/views/device-control/control/control-panel.js index 21e7ba6f716..5abf34f24d0 100644 --- a/resources/web/device_page/js/views/device-control/control/control-panel.js +++ b/resources/web/device_page/js/views/device-control/control/control-panel.js @@ -7,7 +7,7 @@ */ 'use strict'; -import { el } from '../../../core/dom.js'; +import { el } from '../../../../../shared/js/dom.js'; import { renderControlMain, renderStatusCard } from './control-view.js'; export default { diff --git a/resources/web/device_page/js/views/device-control/control/control-view.js b/resources/web/device_page/js/views/device-control/control/control-view.js index f31df5e3497..ee16145070c 100644 --- a/resources/web/device_page/js/views/device-control/control/control-view.js +++ b/resources/web/device_page/js/views/device-control/control/control-view.js @@ -7,11 +7,11 @@ */ 'use strict'; -import { $, el, icon } from '../../../core/dom.js'; +import { $, el, icon } from '../../../../../shared/js/dom.js'; import { LIMITS, PURIFIER_MODES } from '../../../../../shared/js/protocol.js'; import { openPopover, closePopover } from '../../../core/overlay.js'; import { placeholder } from '../../../widgets/art.js'; -import { rebuildOn, text, data } from '../../../core/render.js'; +import { rebuildOn, text, data } from '../../../../../shared/js/render.js'; // Print speed moves in whole 50% steps: 50 / 100 / 150 across LIMITS.printSpeed. const SPEED_STEP = 50; diff --git a/resources/web/device_page/js/views/device-control/filament/filament-commands.js b/resources/web/device_page/js/views/device-control/filament/filament-commands.js index 45cd4f80581..59ebb73dfed 100644 --- a/resources/web/device_page/js/views/device-control/filament/filament-commands.js +++ b/resources/web/device_page/js/views/device-control/filament/filament-commands.js @@ -15,7 +15,7 @@ * * Nothing is awaited. An instant `ok` is indistinguishable from success - * ACE_BG_UNLOAD's own help says ~3 min - so what was asked for is held by - * core/pending.js and confirmed against machine state, never against the ack. + * shared/pending.js and confirmed against machine state, never against the ack. * * `ace` is not on the subscription, so the mirror does not move on its own. Anything * that changes the ACE re-reads it: confirmAce() below is the whole of that, and @@ -37,9 +37,8 @@ import { ACE, FEEDER, DRY_MINUTES_PER_HOUR, aceUnitId, aceLine, aceOverridesUrl, parseAceOverrides, aceGlyph, channelStep, channelWord, gcodeStoreUrl, lastPrinterError, aceModeChange, aceModeRefusal } from '../../../../../shared/js/multiACE.js'; -import { openDialog, openBlockingDialog, numberField, toggleField } - from '../../../core/overlay.js'; -import { el } from '../../../core/dom.js'; +import { openDialog, openBlockingDialog, numberField, toggleField } from '../../../../../shared/js/dialog.js'; +import { el } from '../../../../../shared/js/dom.js'; /** diff --git a/resources/web/device_page/js/views/device-control/filament/filament-view.js b/resources/web/device_page/js/views/device-control/filament/filament-view.js index cf5ef0d5910..e01b6476332 100644 --- a/resources/web/device_page/js/views/device-control/filament/filament-view.js +++ b/resources/web/device_page/js/views/device-control/filament/filament-view.js @@ -38,7 +38,7 @@ */ 'use strict'; -import { el, icon } from '../../../core/dom.js'; +import { el, icon } from '../../../../../shared/js/dom.js'; import { cssColor, isDarkColor } from '../../../../../shared/js/protocol.js'; // Everything about the ACE comes from one module - see shared/js/multiACE.js for why. import { ACE_MODES, ACE_MODE_LABELS, DRY_TEMPS, DRY_HOURS, DRY_LIMITS, @@ -47,8 +47,9 @@ import { ACE_MODES, ACE_MODE_LABELS, DRY_TEMPS, DRY_HOURS, DRY_LIMITS, aceVerbs, channelStep, channelWord, headOccupied, aceSourceOptions, aceModeChange, acePendingMode } from '../../../../../shared/js/multiACE.js'; -import { keyedList, rebuildOn } from '../../../core/render.js'; -import { openDialog, closeDialog, openMenu } from '../../../core/overlay.js'; +import { keyedList, rebuildOn } from '../../../../../shared/js/render.js'; +import { openMenu } from '../../../core/overlay.js'; +import { openDialog, closeDialog } from '../../../../../shared/js/dialog.js'; /** The toolhead artwork, at half. 64x140 becomes 32x70, which is what fits twice over. */ const HEAD_SCALE = 0.5; @@ -1476,7 +1477,7 @@ function openDryer(ctx, u) { inp.setAttribute('aria-label', `${label}, custom value, ${min} to ${max}`); inp.title = `${min}–${max}${suffix}`; // Repainted without rebuilding, so the field does not lose focus mid-keystroke - - // the same reason core/render.js exists on the rest of the page. + // the same reason shared/render.js exists on the rest of the page. inp.oninput = () => { set[`${key}Custom`] = inp.value.trim(); paint(); }; inp.onchange = () => { if (inp.value.trim() !== '') { diff --git a/resources/web/device_page/js/views/device-control/task/task-commands.js b/resources/web/device_page/js/views/device-control/task/task-commands.js index 36502f32dff..2c9c5fff28c 100644 --- a/resources/web/device_page/js/views/device-control/task/task-commands.js +++ b/resources/web/device_page/js/views/device-control/task/task-commands.js @@ -12,7 +12,7 @@ import { CMD } from '../../../../../shared/js/protocol.js'; -import { openDialog } from '../../../core/overlay.js'; +import { openDialog } from '../../../../../shared/js/dialog.js'; import { pickThumb } from '../../../core/thumbs.js'; export function create(deps) { diff --git a/resources/web/device_page/js/views/device-control/task/task-view.js b/resources/web/device_page/js/views/device-control/task/task-view.js index 0955d336997..73b2d9bc80d 100644 --- a/resources/web/device_page/js/views/device-control/task/task-view.js +++ b/resources/web/device_page/js/views/device-control/task/task-view.js @@ -23,9 +23,9 @@ */ 'use strict'; -import { $, el } from '../../../core/dom.js'; +import { $, el } from '../../../../../shared/js/dom.js'; import { PRINT_STATE } from '../../../../../shared/js/protocol.js'; -import { rebuildOn, text, attr } from '../../../core/render.js'; +import { rebuildOn, text, attr } from '../../../../../shared/js/render.js'; /** * What the header badge says. diff --git a/resources/web/device_page/js/views/fault/fault-view.js b/resources/web/device_page/js/views/fault/fault-view.js index c131593ef70..b7561b993d9 100644 --- a/resources/web/device_page/js/views/fault/fault-view.js +++ b/resources/web/device_page/js/views/fault/fault-view.js @@ -3,9 +3,9 @@ */ 'use strict'; -import { $, el, icon } from '../../core/dom.js'; +import { $, el, icon } from '../../../../shared/js/dom.js'; import { lookupFault } from '../../../../shared/js/errors.js'; -import { rebuildOn, data } from '../../core/render.js'; +import { rebuildOn, data } from '../../../../shared/js/render.js'; /** * A fault comes from `server.exception.query`, and from nothing else. diff --git a/resources/web/device_page/js/views/storage/storage/storage-commands.js b/resources/web/device_page/js/views/storage/storage/storage-commands.js index 022c8fd0c2c..6559f665d0c 100644 --- a/resources/web/device_page/js/views/storage/storage/storage-commands.js +++ b/resources/web/device_page/js/views/storage/storage/storage-commands.js @@ -19,7 +19,7 @@ import { CMD, timelapseUrl } from '../../../../../shared/js/protocol.js'; -import { openDialog } from '../../../core/overlay.js'; +import { openDialog } from '../../../../../shared/js/dialog.js'; import { pickThumb } from '../../../core/thumbs.js'; export function create(deps) { diff --git a/resources/web/device_page/js/views/storage/storage/storage-view.js b/resources/web/device_page/js/views/storage/storage/storage-view.js index 60578082c58..6bf5792f45f 100644 --- a/resources/web/device_page/js/views/storage/storage/storage-view.js +++ b/resources/web/device_page/js/views/storage/storage/storage-view.js @@ -3,11 +3,11 @@ */ 'use strict'; -import { $, el, icon } from '../../../core/dom.js'; +import { $, el, icon } from '../../../../../shared/js/dom.js'; import { clock } from '../../../widgets/format.js'; import { illustration, placeholder } from '../../../widgets/art.js'; import { jobThumbUrl } from '../../../../../shared/js/protocol.js'; -import { rebuildOn, keyedList, text, data } from '../../../core/render.js'; +import { rebuildOn, keyedList, text, data } from '../../../../../shared/js/render.js'; /** Klipper's own job outcomes; anything else is shown verbatim. */ const JOB_STATUS = { diff --git a/resources/web/device_page/js/widgets/art.js b/resources/web/device_page/js/widgets/art.js index 88ed306a420..766e6839ff3 100644 --- a/resources/web/device_page/js/widgets/art.js +++ b/resources/web/device_page/js/widgets/art.js @@ -4,7 +4,7 @@ */ 'use strict'; -import { el } from '../core/dom.js'; +import { el } from '../../../shared/js/dom.js'; export function illustration(root) { const wrap = el('div'); diff --git a/resources/web/device_page/js/widgets/format.js b/resources/web/device_page/js/widgets/format.js index 1a85b205f23..17be76d6d87 100644 --- a/resources/web/device_page/js/widgets/format.js +++ b/resources/web/device_page/js/widgets/format.js @@ -4,7 +4,7 @@ */ 'use strict'; -import { $ } from '../core/dom.js'; +import { $ } from '../../../shared/js/dom.js'; import { illustration } from './art.js'; /** hh:mm:ss from seconds, the granularity the shipped page uses for a job. */ diff --git a/resources/web/device_page/js/widgets/rail-commands.js b/resources/web/device_page/js/widgets/rail-commands.js index 8aa909227ed..1419b3dcb46 100644 --- a/resources/web/device_page/js/widgets/rail-commands.js +++ b/resources/web/device_page/js/widgets/rail-commands.js @@ -15,7 +15,7 @@ import { CMD, deviceLabel, DEVICE } from '../../../shared/js/protocol.js'; -import { openDialog, toggleField, numberField } from '../core/overlay.js'; +import { openDialog, toggleField, numberField } from '../../../shared/js/dialog.js'; export function create(deps) { // `bridge` is deliberately NOT destructured: it does not exist yet when these are diff --git a/resources/web/device_page/js/widgets/rail.js b/resources/web/device_page/js/widgets/rail.js index 3d53468b337..5347b318676 100644 --- a/resources/web/device_page/js/widgets/rail.js +++ b/resources/web/device_page/js/widgets/rail.js @@ -3,9 +3,9 @@ */ 'use strict'; -import { $ } from '../core/dom.js'; +import { $ } from '../../../shared/js/dom.js'; import { DEVICE, deviceLabel } from '../../../shared/js/protocol.js'; -import { data } from '../core/render.js'; +import { data } from '../../../shared/js/render.js'; /** * The selector names the machine even when it is not connected — Orca keeps diff --git a/resources/web/print_processing/README.md b/resources/web/print_processing/README.md index 6aa9dd5982e..379f6c072df 100644 --- a/resources/web/print_processing/README.md +++ b/resources/web/print_processing/README.md @@ -1,57 +1,239 @@ -# Print processing — clean-room reimplementation +# The print-processing popup -A dependency-free HTML/CSS/JS reconstruction of the Snapmaker U1 **print-processing -popup** — the modal Orca opens between "Send to printer" and the job starting — built -from the reverse engineering in [`docs/u1-webui/`](../../../docs/u1-webui/). +The dialog Orca opens between "Send to printer" and the print starting, rebuilt from the +shipped Flutter bundle. `WebPreprintDialog` gives it 714 × 750 DIP and one of two routes: -This is **one of two** reconstructed surfaces. The bridge client, the state model, the -protocol constants and the simulated host live in [`../shared/`](../shared/) and are -shared with [`../device_page/`](../device_page/); only this surface's own `app.js`, -`ui.js`, `mock.js` and `preprint.css` are here. +| Orca | here | shows | +|---|---|---| +| `?path=4` `preUploadAndPrint` | `?mode=print` *(default)* | all five sections | +| `?path=5` `preUpload` | `?mode=upload` | Model Information + Select Printer | -A build badge in the bottom-right corner names the surface and the build it reports — -the marker that tells this reconstruction apart from the shipped Flutter page. +**The specification is +[`docs/u1-webui/03-print-processing/original-dialog-mockup.html`](../../../docs/u1-webui/03-print-processing/original-dialog-mockup.html)** — +the shipped dialog recovered widget by widget, with the source cited for every +measurement, and held to those numbers by `check_mockup.py`. This page is built against +it; `drive/print-dialog.js` asserts the same numbers survive a real layout. -## Running it +No rebuild is needed: `build/resources` is a symlink to `resources/`, the page server +sends no cache headers, so edit and reload. -ES modules need a real origin, so serve rather than opening the file directly: +## Structure + +The Device page's contract, because it is the same idea and the primitives are now +shared. **One panel is one directory**, holding all of what it is: -```bash -python3 -m http.server 8099 --directory resources/web +``` +js/views/filament/ + filament-panel.js its declaration, and mount/update + filament-view.js its DOM: built once, then patched + filament-commands.js everything it can ask the machine to do ``` -| Mode | URL | Mirrors | -|---|---|---| -| Upload **and** print | `http://127.0.0.1:8099/print_processing/index.html?mock=1` | Orca's `?path=4` `preUploadAndPrint` | -| Upload **only** | `http://127.0.0.1:8099/print_processing/index.html?mock=1&mode=upload` | Orca's `?path=5` `preUpload` | +[`js/registry.js`](js/registry.js) lists the five, in the order `A.bi3` builds them; the +shell is built from it and nothing else names them. + +| | | +|---|---| +| `js/core/session.js` | the two sources this surface reconciles, and the match rule | +| `js/core/send.js` | packaging, uploading, starting, and the close protocol | +| `js/core/shell.js` | the sections and the spacing between them | +| `js/core/mock.js` | a thin adapter over the shared simulation | +| `js/widgets/picker.js` | the dropdown all three pickers use | +| `js/widgets/art.js` | the colour block and the target disc | +| `js/widgets/format.js` | this dialog's own number formats | + +### What is shared rather than local + +Everything that is not about this dialog in particular: + +| from `../shared/js/` | | +|---|---| +| `dom.js` `render.js` `pending.js` | the primitives that grew on the Device page | +| `dialog.js` | the modal sheet - the help control uses it | +| `trace.js` | the WCP trace pane | +| `sswcp.js` `protocol.js` `state.js` | the bridge, the constants, the machine model | +| `mockhost.js` | **the simulated U1 *and* Orca**, shared with the Device page | +| `buildinfo.js` | the build badge | + +`cssColor` is *not* reimplemented here. It is in `protocol.js`, because the alpha-zero +rule — multiACE wipes a head's identity with `00000000` while the spool is still in it, +so alpha zero is an absence and not black — must have exactly one implementation. + +## The two sources, and why neither is derived from the other -Drop `?mock=1` when running inside Orca — the page uses the real bridge whenever -`window.wx` is present, and only falls back to the simulator when it is not. +This dialog exists to match **the file's** filaments against **the machine's**: -## What it covers +* the file — `sw_GetFileFilamentMapping`, answered by Orca out of the sliced plate +* the machine — `print_task_config` on the state stream, plus each extruder's + `nozzle_diameter` -- **Model Information** — filename, estimated time, estimated materials -- **Select Printer** — resolved from `sw_GetConnectedMachine`, with the - `sw_GetPrintLegal` preset-vs-machine check -- **Edit Filament** — one row per filament with its colour, type, weight and toolhead - assignment, written back as `extruder_map_table` -- **Print Preferences** — Extrusion Flow Calibration, Time-lapse Camera, Auto Leveling -- **Send** — the real three-command close protocol, in order +The bundle names a third command for the machine side, `sw_GetMachineFilamentMapping` +(index 29). Orca does not implement it (`implemented_in_cpp: false`), so +`print_task_config` is the only source there is. -Upload-only mode hides the filament and preference sections entirely and keeps Send -disabled until a printer resolves, matching the shipped popup. +**The match rule is the bundle's, and it refuses**: a toolhead may take a file filament +only when its *type* and its *nozzle* both match, and a head that fails is passed +`enabled: false` — it cannot be picked at all, only shown at half opacity with one of two +tooltips. That is behaviour, not decoration, and `drive/print-dialog.js` checks it by +clicking a refused item and asserting nothing moved. + +## Testing it — mock, and a real U1 + +Both, the same way the Device page does it, through `run_webkit.py --page`. + +**Against the simulator**, no printer and no Orca: + +```bash +R=resources/web/shared/tests +python3 $R/run_webkit.py --size 714x750 --page 'web/print_processing/index.html?mock=1' \ + --drive $R/drive/print-dialog.js +``` + +**Against a real U1**, with Orca closed — `u1_bridge.py` is a second host speaking Orca's +contract. The printer answers the machine half; the *job* half is read out of a real +sliced `.gcode`, because outside Orca there is no plate to ask: + +```bash +python3 $R/run_webkit.py --real --size 714x750 --watch \ + --gcode ~/models/plate_1.gcode \ + --page web/print_processing/index.html +``` -## Tests +`--gcode` is what makes the Orca-side commands answerable. The bridge parses the file's +own trailing metadata — `filament_type`, `filament colour`, `filament used [g]`, +`nozzle_diameter`, the estimate and the embedded thumbnail — so the dialog sees a real +plate rather than a fixture. Without it those commands refuse and say why. -The shared conformance test guards this surface too, since both import the same -constants: +**With no printer at all**, which is the branch worth forcing on anything touching the +device record: ```bash -python3 resources/web/shared/tests/conformance_test.py +python3 $R/run_webkit.py --real --device-ip 192.0.2.1 --size 714x750 \ + --gcode ~/models/plate_1.gcode --page web/print_processing/index.html \ + --drive $R/drive/print-dialog-real.js ``` -## Limits +### Bringing the transport up + +`sw_GetMachineState` answers out of a Moonraker host, and **something has to attach one +first** — `sw_mqtt_set_engine` is what does it. Inside Orca the Device tab usually has, +and this popup inherits it. Opened without the Device tab having connected, or run +outside Orca, nothing has, and every state command comes back + +``` +no engine attached yet (sw_mqtt_set_engine has not run) +``` + +So the dialog brings it up itself, with the Device page's own connect path +(`shared/js/connection.js`): it tries, and on that error connects and tries again. +**Choosing a printer does the same**, because nothing has connected to *that* machine +either. + +Getting this wrong is quiet rather than loud. An unattached transport draws four +toolheads reading `NONE` with no nozzles — which is exactly what a printer with nothing +loaded looks like, and is plausible enough for an operator to map filaments against. When +the connect fails the dialog says so and Send stays refused; a device *record* is not a +printer that answered. + +### The one command that moves a machine + +`sw_StartLocalPrint` starts a print. The bridge refuses it unless `--allow-print` is +passed, so the send path is exercised right up to that command and stopped there. A suite +that can start a print is a suite that will, on somebody's bed, at 3am. + +## Where this departs from the shipped dialog + +One place, deliberately, and it is worth listing because everything else is a +reproduction. + +**The printer rows carry an address.** The shipped dialog draws a device as a cover, a +name, a "Lan Mode" label and a check — so two saved records with the same name are two +identical rows. That is not hypothetical: Orca's config routinely keeps a stale record +beside a live one, and the machine this was built against has exactly that — two `U1 G` +entries on the same address, differing only by serial (`811002511261022618B3` against a +`moonraker` placeholder). Picking the wrong one connects to nothing, and the dialog can +only report that the printer did not answer. + +The address is on the record already — `connection.js` refuses to connect without one — +so drawing it costs a second line in a row that has 50 px for it. **The serial appears +only when it is the thing that tells two rows apart**; where a name and an address are +already unique it is noise, and it is not drawn. `deviceMeta()` in +[`printer-view.js`](js/views/printer/printer-view.js) is that rule. + +## The send + +Four things here are not what the previous reconstruction did, each read off the host: + +1. **The file comes as a URL.** `sw_GetFileStream {is_zip:true}` returns + `{file_name, file_url, origin_size, checksum}`. `sw_GetPrintZip` returns the *bytes*, + from a `std::vector` that serialises as one JSON integer per byte — a 12 MB zip + crosses the bridge as ~40 MB of JSON. +2. **The upload is the page's.** A multipart POST to `/server/files/upload` on the + printer, so the bar is a byte count from `upload.onprogress` and not a timer. +3. **`sw_StartLocalPrint` needs `{type, path}`** and fails the request without them. +4. **Closing is two commands and only the second closes.** + `sw_SetFilamentMappingComplete` records; `sw_FinishFilamentMapping` ends the modal. + +## What a real machine showed + +Driven against `811002511261022618B3` on 2026-09-02, Orca closed. It paired over +`mqtt://…:1884`, exchanged keys, opened `mqtts://…:8883`, attached the engine, and +`print_task_config` arrived: + +``` +filament_type ["PLA","PLA","NONE","PETG"] +filament_vendor ["Jayo","Forshape","NONE","Kingroon"] +filament_exist [true,true,false,true] +extruder .. 3 nozzle 0.4 each +``` -The printer picker is a stub, upload progress is simulated locally, and everything is -verified against the shared simulator rather than real hardware. See -[the implementation notes](../../../docs/u1-webui/03-print-processing/03-implementation.md). +The plate wanted PLA throughout, so the picker offered heads 1 and 2 and refused 3 and 4 +— with the two different tooltips, `_none_tips` for the empty head and `_not_match_tips` +for the PETG one. The refusal set was recomputed from the raw objects in the drive script +rather than read off the page, so the page could not agree with itself. + +**It found a bug that the simulator could not.** `initialAssignment` fell back to +*identity* when Orca had no opinion — and this host has none, since +`filament_extruder_map` is Orca's own config. On this machine that put filament 3 on the +empty head and filament 4 on PETG: two toolheads **the picker itself refuses**, with +nothing on screen saying so, because the warning mark means *"nothing chosen"* and +something had been. Unassigned is now the default, which is the bundle's own answer, and +Send is refused while any filament has no home. + +**Cancel was proven before anything was sent.** `sw_MachinePrintCancel` routes to this +firmware in 109 ms and answers `ok` on an idle machine. That is the route, not a promise: +`ok` is what a Klipper macro says to an argument it does not have, and this project has +been caught by that before. **That a running print stops has not been shown** and cannot +be without running one. + +**The send path ran end to end**, and settled the two things Orca's source could not say. +The upload is a cross-origin multipart POST and answers `HTTP 201` with the item it +created; `start_local_print` wants that reply's `item.path` **without** the root glued on +(`gcodes/x.zip` gives `File not found`); and the firmware unpacks the archive, so the +start reply names the inner `.gcode`. A print was started and cancelled — `print_stats` +went `printing` → `cancelled` in about ten seconds, **while the cancel request itself +timed out**. All of it, with the wire, is +[05-hardware-e2e.md](../../../docs/u1-webui/03-print-processing/05-hardware-e2e.md); the +procedure is the `u1-hardware-test` skill. + +## Honest limits + +- **Confirming a machine command against its ack is still wrong**, even though the + timeout that made it visible is fixed. `shared/js/pending.js` exists for that shape and + the print dialog uses it for the mapping and the preferences; the Device page's task + panel still reads a cancel's outcome off the reply. +- **No plate has been left to run.** Everything is cancelled within seconds, so nothing + past the first moments of a job has been observed. +- **The cloud path is untouched** — `sw_StartCloudPrint`, `server.files.pull` and the S3 + upload. This is the LAN route only. +- **Whether a started job honours the mapping and the preferences** has not been checked. + The macros are written and acknowledged; that is not the same thing. +- **The multi-plate Model Information layout is not built.** `A.J9` has a second + branch for a file with `partitions`, and `sw_GetFileFilamentMapping` as Orca + implements it has no such key — the branch is unreachable through this host. It is + recovered and drawn in the mockup. +- **A file filament's nozzle is inferred**, and it is the only inferred thing here: + `nozzle_diameters` is per-extruder and `filament_type` per-filament, and the reply does + not say how to pair them. See `fileFilaments` in `core/session.js`. +- **`filament_color_multi` is read and drawn**, but no plate observed so far has carried + a gradient or segmented spool, so only the simulator has exercised those two branches. diff --git a/resources/web/print_processing/css/preprint.css b/resources/web/print_processing/css/preprint.css index 3d6a6e8ed21..5b693a4499b 100644 --- a/resources/web/print_processing/css/preprint.css +++ b/resources/web/print_processing/css/preprint.css @@ -1,214 +1,507 @@ /* * preprint.css - the print-processing popup. * - * Layered on ../shared/css/base.css. Sized to the dialog Orca actually opens: - * WebPreprintDialog sets 714x750 DIP. + * Every number in here is a measurement, not a preference: either a constant resolved + * out of the shipped bundle or a pixel read off screenshots/preuploadandprint.png. The + * specification is docs/u1-webui/03-print-processing/original-dialog-mockup.html, which + * cites the source for each and is held to them by check_mockup.py. + * + * Layered on ../shared/css/base.css for the trace pane and the build badge; the dialog + * itself does NOT use those tokens, because it is reproducing a specific screen rather + * than joining a design system. */ -.sheet { - max-width: 714px; - margin: 0 auto; - min-height: 100vh; - /* bottom room so content can scroll clear of the fixed build badge */ - padding: 10px 12px 44px; +:root { + --f-bg: #ffffff; /* page + card fill */ + --f-border: #d9d9d9; /* card border, 2px A.de(ax.ry, -1, 2) */ + --f-title: #4b4b4b; /* section titles and body text */ + --f-hint: #999999; /* the picker placeholder (ax.k3 at 204/255) */ + --f-blue: #2196f3; /* Send, state 1 B.eM 0xFF2196F3 */ + --f-amber: #ff9800; /* Send, states 2/3/4 B.iZ 0xFFFF9800 */ + --f-green: #4caf50; /* Send state 5, checked box B.fx 0xFF4CAF50 */ + --f-red: #f44336; /* Send state 6 B.oL 0xFFF44336 */ + --f-grey: #9e9e9e; /* Send states 7/0 B.ci 0xFF9E9E9E */ + --f-track: #eeeeee; /* progress track B.bQ 0xFFEEEEEE */ + --f-warn: #ff3b30; /* ax.fy, from B.YP = 0x0DFF3B30 */ + --f-warn-bg: rgba(255, 59, 48, .051); /* B.YP: alpha 13/255 */ + --f-divider: #e0e0e0; + --f-sans: Roboto, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +/* The dialog is opened at 714 x 750 DIP and does not scroll as a whole; its body does. */ +html, body.dlg { height: 100%; } +body.dlg { + margin: 0; display: flex; flex-direction: column; - gap: 10px; + overflow: hidden; + background: var(--f-bg); + color: var(--f-title); + font-family: var(--f-sans); + font-size: 14px; + line-height: 1.4; } -.topbar { +/* 16px inset each side - the capture puts the card's left border at x=16. */ +.dlg-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 16px; position: relative; } + +.sp-16 { height: 16px; } /* B.aE */ +.sp-8 { height: 8px; } /* B.aB */ +.sp-20 { height: 20px; } /* B.eS */ +.sp-13 { height: 13px; } /* B.ayg */ +.sp-10 { height: 10px; } /* B.c_ */ +.gap-20 { flex: none; width: 20px; } /* B.lr */ + +/* ---- a section --------------------------------------------------------- */ +.card { + display: flex; + align-items: flex-start; /* B.a6 = CrossAxisAlignment.start */ + border: 2px solid var(--f-border); + border-radius: 8px; /* A.aU(8) */ + padding: 12px; /* B.ch = EdgeInsets.all(12) */ + background: var(--f-bg); +} +/* A.eQ(B.m4, ...) -> ConstrainedBox(minWidth 120, maxWidth 180) */ +.card-title { + flex: none; + min-width: 120px; + max-width: 180px; display: flex; align-items: center; - justify-content: space-between; - gap: 10px; - font-family: var(--mono); - font-size: 11px; - color: var(--muted); + gap: 4px; + font-size: 14px; + font-weight: 600; /* B.ba = w600 */ + color: var(--f-title); +} +.card-gap { flex: none; width: 12px; } /* B.bA */ +.card-body { flex: 1; min-width: 0; } + +/* the refresh control, 20px, inside the title column */ +.card-refresh { + flex: none; + width: 20px; height: 20px; + padding: 0; + border: 0; + background: none; + color: var(--f-title); + cursor: pointer; + background-repeat: no-repeat; + background-position: center; + background-size: 20px 20px; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%234b4b4b'%3E%3Cpath d='M17.65 6.35A8 8 0 1019.73 14h-2.08A6 6 0 1112 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z'/%3E%3C/svg%3E"); +} +.card-refresh:focus-visible { outline: 2px solid var(--f-blue); outline-offset: 2px; } + +/* ---- Model Information ------------------------------------------------- */ +.mi { display: flex; align-items: flex-start; } +.mi-thumb { + flex: none; + width: 100px; height: 100px; + border-radius: 16px; /* A.aU(16) */ + object-fit: cover; + display: block; + background: var(--f-track); } -.status.ok { color: var(--ok); } -.status.warn { color: var(--warn); } -.status.err { color: var(--err); } -.status.mock { color: var(--accent); } -.mode { - padding: 2px 7px; - border: 1px solid var(--line); - border-radius: 999px; - letter-spacing: .04em; +/* No thumbnail is a state, not a broken image. */ +.mi-thumb.empty { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cg fill='%23c9c9c9'%3E%3Crect x='22' y='62' width='56' height='6'/%3E%3Ccircle cx='38' cy='44' r='12'/%3E%3Cpath d='M50 56l14-18 12 18z'/%3E%3C/g%3E%3C/svg%3E"); + background-size: 100px 100px; +} +.mi-facts { flex: 1; min-width: 0; } +.mi-fact { + font-size: 14px; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.mode.mock { color: var(--accent); border-color: var(--accent); } -.card { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); +/* ---- Select Printer ---------------------------------------------------- */ +.sp { display: flex; align-items: center; } +.sp-cover { + flex: none; + width: 100px; height: 100px; + border-radius: 16px; + background: var(--f-track) center / 100px 100px no-repeat + url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%23c9c9c9' stroke-width='3'%3E%3Cpath d='M50 28l22 12v24L50 76 28 64V40z'/%3E%3Cpath d='M28 40l22 12 22-12M50 52v24'/%3E%3C/g%3E%3C/svg%3E"); } -.card[hidden] { display: none; } -.card-head { +/* A.a2l(50, 300, B.c8, ...) - height 50, width 300, padding LTRB(16,0,16,0) */ +.picker { + width: 300px; + height: 50px; display: flex; align-items: center; gap: 8px; - padding: 12px 14px 0; -} -.card-head h2 { - margin: 0; - font-size: 13px; - font-weight: 600; - color: var(--ink); -} -.card-body { padding: 10px 14px 14px; } - -.icon-btn { - border: 1px solid var(--line); - background: var(--panel-2); - color: var(--ink-2); - border-radius: 6px; - width: 24px; - height: 24px; - line-height: 1; + padding: 0 10px 0 16px; + border: 1px solid #777; + border-radius: 0; + background: var(--f-bg); + font: inherit; + color: var(--f-title); + text-align: left; cursor: pointer; - font-size: 13px; -} -.icon-btn:hover { border-color: var(--accent); color: var(--accent); } - -/* ---- model / printer rows ---- */ -.model-row { display: flex; gap: 14px; align-items: flex-start; } -.thumb { - flex: 0 0 96px; - height: 76px; - border-radius: 6px; - background: var(--panel-2); - border: 1px solid var(--line); +} +.picker:focus-visible { outline: 2px solid var(--f-blue); outline-offset: 2px; } +.picker-body { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; } +.picker .dev-text { gap: 0; } +.picker-hint { font-size: 12px; color: var(--f-hint); } +.picker-caret { + flex: none; width: 24px; height: 24px; + background: center / 24px 24px no-repeat + url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23757575'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); +} +.legal-line { margin: 9px 0 0; font-size: 12px; color: var(--f-warn); } + +/* ---- Edit Filament ----------------------------------------------------- */ +/* Wrap(spacing 12, runSpacing 12) - A.mO via aiy.y */ +.fil-grid { display: flex; flex-wrap: wrap; gap: 12px; } +/* B.aya: an invisible 80x100 holds the row open when there is nothing to draw. */ +.fil-grid[data-empty="1"]::before { content: ""; display: block; width: 80px; height: 100px; } + +/* A.A0: BoxConstraints(80, 80, 100, 100), radius 8, 1px border */ +.fil-card { + flex: none; + width: 80px; height: 100px; display: flex; + flex-direction: column; + border: 1px solid var(--f-border); + border-radius: 8px; + overflow: hidden; + background: var(--f-bg); +} +/* An unassigned filament wears the warning colour - it is the only thing on this + surface that says a decision is outstanding. */ +.fil-card.unset { border-color: var(--f-warn); } + +.fil-block { + flex: 3 1 0; /* A.bH(stack, 3) */ + min-height: 0; + position: relative; + display: flex; + flex-direction: column; align-items: center; justify-content: center; + padding: 3px 2px; /* B.RL */ + overflow: hidden; } -.thumb-tag { - font-family: var(--mono); - font-size: 10px; - color: var(--muted); - letter-spacing: .06em; +.fil-type { + position: relative; + font-size: 12px; + font-weight: 500; /* B.H */ + line-height: 1.15; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.printer-thumb { background: var(--panel-2); } - -.facts { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; } -.facts dt { color: var(--muted); font-size: 12px; } -.facts dd { margin: 0; font-size: 12px; color: var(--ink); } - -.picker { flex: 1; min-width: 0; } -.select { - width: 100%; - text-align: left; - padding: 9px 11px; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel-2); - color: var(--ink-2); - font: inherit; +.fil-used { + position: relative; font-size: 12px; + font-weight: 400; /* B.w */ + line-height: 1.15; + margin-top: 1px; /* B.ayj */ +} +.fil-rule { flex: none; height: 1px; background: var(--f-divider); } /* A.aHd(_,1,_) */ +.fil-pick { + flex: 4 1 0; /* A.bH(picker, 4) */ + min-height: 0; + display: flex; + align-items: center; + gap: 6px; /* B.fJ */ + padding: 0 2px 0 6px; + border: 0; + background: none; + font: inherit; cursor: pointer; } -.select:hover { border-color: var(--accent); } -.warn-line { margin: 8px 0 0; font-size: 12px; color: var(--warn); } -.empty { margin: 0; font-size: 12px; color: var(--muted); } +.fil-pick:focus-visible { outline: 2px solid var(--f-blue); outline-offset: -2px; } +.fil-caret { + flex: none; width: 16px; height: 16px; margin-left: auto; + background: center / 16px 16px no-repeat + url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23757575'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); +} -/* ---- filament ---- */ -.fil-grid { display: flex; flex-direction: column; gap: 8px; } -.fil-row { +/* ---- the colour art ---------------------------------------------------- */ +/* Bands are flex children, one per colour, exactly as A.abX.VO builds them. */ +.sw-banded { display: flex; flex-direction: row; } +.fil-block.sw-banded { align-items: stretch; } +.sw-band { flex: 1; } +.fil-block.sw-banded .fil-type, +.fil-block.sw-banded .fil-used { position: absolute; } +.fil-block.sw-banded .fil-type { top: 3px; } +.fil-block.sw-banded .fil-used { bottom: 3px; } +/* Alpha zero is an absence, not black. A.abX draws a hatch for it. */ +.sw-hatch { + background: + repeating-linear-gradient(45deg, #8d8d8d 0 3px, #acacac 3px 6px); +} + +/* A.a6K - the 28px target disc */ +.disc { + flex: none; + position: relative; + width: 28px; height: 28px; + border-radius: 50%; + border: 1px solid var(--f-border); + overflow: hidden; display: flex; align-items: center; - gap: 10px; - padding: 8px 10px; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel-2); + justify-content: center; } -.chip { - width: 18px; height: 18px; - border-radius: 50%; - border: 1px solid rgba(0, 0, 0, .18); - flex: 0 0 auto; -} -.fil-meta { display: flex; flex-direction: column; min-width: 0; flex: 1; } -.fil-name { font-size: 12px; color: var(--ink); } -.fil-sub { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } -.arrow { color: var(--muted); } -.slot { - font: inherit; +.disc.unset { border-color: var(--f-warn); } +.disc-n { + position: relative; font-size: 12px; - padding: 5px 8px; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel); - color: var(--ink); + font-weight: 800; /* B.nn */ + line-height: 1; } +.disc.unset .disc-n { color: var(--f-warn); } +/* In the menu the number is 14px w600, not 12px w800 - A.aJQ vs A.A0. */ +.disc-menu .disc-n { font-size: 14px; font-weight: 600; } -/* ---- preferences ---- */ -.prefs { display: flex; flex-direction: column; } -.pref-row { +/* ---- Print Preferences ------------------------------------------------- */ +/* Wrap(runSpacing 12, spacing 170) of ConstrainedBox(152..220 x 20) */ +.prefs { display: flex; flex-wrap: wrap; row-gap: 12px; column-gap: 170px; } +.pref { + flex: none; + width: 220px; + height: 20px; display: flex; align-items: center; - justify-content: space-between; - padding: 7px 0; - font-size: 12px; + gap: 4px; /* B.a4Y */ + padding: 0; + border: 0; + background: none; + font: inherit; + color: var(--f-title); + text-align: left; + cursor: pointer; +} +.pref:focus-visible { outline: 2px solid var(--f-blue); outline-offset: 3px; } +.pref-label { + flex: 1; min-width: 0; + font-size: 14px; + font-weight: 500; /* B.H */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pref-help { + flex: none; width: 20px; height: 20px; cursor: pointer; + background: center / 20px 20px no-repeat + url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%239e9e9e'%3E%3Cpath d='M12 2a10 10 0 100 20 10 10 0 000-20zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26A1.96 1.96 0 0014 9a2 2 0 10-4 0H8a4 4 0 118 0c0 .88-.36 1.68-.93 2.25z'/%3E%3C/svg%3E"); +} +.pref-help:focus-visible { outline: 2px solid var(--f-blue); outline-offset: 2px; } +/* An 18px ROUND CHECKBOX, radius 9, 2px border - not a switch. */ +.pref-box { + flex: none; + width: 18px; height: 18px; + border: 2px solid var(--f-border); + border-radius: 9px; + background: var(--f-bg); +} +.pref[aria-checked="true"] .pref-box { + background: var(--f-green) center / 14px 14px no-repeat + url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z'/%3E%3C/svg%3E"); + border-color: var(--f-green); +} + +/* ---- the nozzle banner (A.R5) ------------------------------------------ */ +.nozzle-warn { + display: flex; + align-items: flex-start; + gap: 4px; /* B.aI */ + width: 100%; + padding: 18px 12px; /* B.zp */ + border-radius: 4px; /* A.aU(4) */ + background: var(--f-warn-bg); +} +.nozzle-icon { + flex: none; width: 14px; height: 14px; margin-top: 1px; + background: center / 14px 14px no-repeat + url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ff3b30'%3E%3Cpath d='M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z'/%3E%3C/svg%3E"); +} +.nozzle-text { flex: 1; font-size: 12px; font-weight: 400; line-height: 1.35; color: var(--f-warn); } + +/* ---- the dropdowns ----------------------------------------------------- */ +.menu { + position: absolute; + z-index: 40; + background: var(--f-bg); + overflow-y: auto; + overflow-x: hidden; +} +/* B.aqV(50, B.c8) + bEo(..., 0, null, B.o, B.a4, null, 300) */ +.menu-printer { width: 300px; border: 1px solid rgba(0, 0, 0, .118); } +.menu-printer .menu-item { height: 50px; padding: 0 16px; gap: 12px; } +/* B.aqU(48, B.c8) + bEo(..., 8, 300, B.asz, null, HI, 200) */ +.menu-head { + width: 200px; + max-height: 300px; + border-radius: 8px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, .2), 0 8px 10px 1px rgba(0, 0, 0, .14), + 0 3px 14px 2px rgba(0, 0, 0, .12); /* Material elevation 8 */ + scrollbar-width: thin; } -.pref-row + .pref-row { border-top: 1px solid var(--line); } -.pref-label { color: var(--ink-2); } +.menu-head::-webkit-scrollbar { width: 6px; } /* A.HI: thickness 6 */ +.menu-head::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, .32); border-radius: 40px; } +.menu-head .menu-item { height: 48px; padding: 0 16px; gap: 8px; } -/* ---- send bar ---- */ -.sendbar { - margin-top: auto; +.menu-item { display: flex; align-items: center; - gap: 10px; - padding-top: 6px; -} -.progress { - flex: 1; - height: 6px; - border-radius: 999px; - background: var(--panel-2); - border: 1px solid var(--line); - overflow: hidden; + width: 100%; + border: 0; + background: none; + font: inherit; + color: var(--f-title); + text-align: left; + cursor: pointer; } -#progress-fill { height: 100%; width: 0; background: var(--accent); } -.pct { - font-family: var(--mono); - font-size: 11px; - color: var(--ok); +.menu-item:hover { background: rgba(0, 0, 0, .04); } +.menu-item:focus-visible { outline: 2px solid var(--f-blue); outline-offset: -2px; } +/* Not merely discouraged: openMenu refuses to pick it. */ +.menu-item[aria-disabled="true"] { opacity: .5; cursor: not-allowed; } + +.menu-cover { flex: none; width: 24px; height: 24px; border-radius: 4px; background: var(--f-track); } +.menu-name { font-size: 14px; } +.menu-lan { font-size: 12px; color: var(--f-title); } + +/* A device over two lines: the name, then how to tell it from the others. + The shipped dialog draws one line - see deviceMeta() in printer-view.js for why this + one does not. The 50px row (B.aqV) has the room; nothing else moves. */ +.dev-text { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; } +.dev-name { + font-size: 14px; line-height: 1.25; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.dev-meta { + font-size: 11px; line-height: 1.25; color: var(--f-hint); font-variant-numeric: tabular-nums; - min-width: 34px; - text-align: right; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.menu-grow { flex: 1; } +.menu-type { + flex: 1; min-width: 0; + font-size: 14px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.menu-type.bad { color: var(--f-warn); } +.menu-tick { + flex: none; width: 20px; height: 20px; + background: center / 20px 20px no-repeat + url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%234caf50'%3E%3Cpath d='M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z'/%3E%3C/svg%3E"); +} +.menu-warn { + flex: none; width: 16px; height: 16px; + background: center / 16px 16px no-repeat + url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ff3b30'%3E%3Cpath d='M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z'/%3E%3C/svg%3E"); +} + +/* ---- the send bar ------------------------------------------------------ */ +.dlg-foot { + flex: none; + display: flex; + align-items: center; + padding: 16px; /* B.bV */ } -.btn { +.bar { flex: 1; min-width: 0; } +.bar-track { height: 8px; border-radius: 4px; background: var(--f-track); overflow: hidden; } +.bar-fill { height: 100%; width: 0; background: var(--f-blue); transition: width .15s linear; } +.pct { flex: none; margin-left: 5px; font-size: 14px; font-variant-numeric: tabular-nums; } /* B.RH */ + +.send { + flex: none; + width: 120px; height: 40px; /* A.al(120, 40, ...) */ + margin: 0 10px 0 30px; /* B.ay6 before, B.RG after */ + border: 0; + border-radius: 4px; + background: var(--f-grey); + color: #fff; font: inherit; - font-size: 13px; - padding: 9px 26px; - border-radius: 6px; - border: 1px solid var(--line); - background: var(--panel-2); - color: var(--ink); + font-size: 14px; + font-weight: 500; cursor: pointer; } -.btn.primary { - background: var(--accent); - border-color: var(--accent); - color: var(--accent-in); - font-weight: 600; -} -.btn:disabled { opacity: .45; cursor: not-allowed; } - -/* ---- trace ---- */ -.trace-wrap { font-family: var(--mono); font-size: 11px; } -.trace-wrap summary { color: var(--muted); cursor: pointer; padding: 4px 0; } -#trace { - max-height: 130px; - overflow: auto; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 6px; - padding: 6px 8px; -} -#trace .t { white-space: pre; color: var(--ink-2); } -#trace .t-mock-rx { color: var(--accent); } -#trace .t-mock-push { color: var(--muted); } +/* A.Jb.aKO() - five colours over eight states. */ +.send[data-state="ready"] { background: var(--f-blue); } +.send[data-state="packaging"], +.send[data-state="uploading"], +.send[data-state="starting"] { background: var(--f-amber); } +.send[data-state="done"] { background: var(--f-green); } +.send[data-state="failed"] { background: var(--f-red); } +.send:disabled { cursor: not-allowed; } +.send:focus-visible { outline: 2px solid var(--f-blue); outline-offset: 2px; } +/* A.Jb.aEL() - a 16x16 stroke-2 spinner replaces the label while in flight. */ +.send[data-busy="1"] { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='none' stroke='%23fff' stroke-opacity='.35' stroke-width='2'/%3E%3Cpath d='M8 2a6 6 0 016 6' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + animation: send-spin 900ms linear infinite; +} +@keyframes send-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .send[data-busy="1"] { animation: none; } + .bar-fill { transition: none; } +} + +/* ---- the help dialog --------------------------------------------------- */ +.explain { + position: fixed; inset: 0; z-index: 60; + display: grid; place-items: center; + background: rgba(0, 0, 0, .32); +} +.explain-box { + width: 320px; + padding: 20px 24px 12px; + border-radius: 4px; + background: var(--f-bg); + box-shadow: 0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14); +} +.explain-box h2 { margin: 0 0 12px; font-size: 16px; font-weight: 500; } +.explain-box p { margin: 0 0 8px; font-size: 14px; line-height: 1.45; } +.explain-ok { + display: block; margin-left: auto; + padding: 8px 12px; + border: 0; background: none; + color: var(--f-blue); + font: inherit; font-weight: 500; text-transform: uppercase; + cursor: pointer; +} + +/* ---- status, trace and the build badge --------------------------------- + * + * None of these is part of the dialog: they are the reconstruction's own, and on a + * 714 x 750 modal there is very little room for them. They stack above the send bar + * rather than over it - the badge's shared position is bottom-right, which on this + * surface is exactly where the Send button is, and a marker that hides the control + * being pressed is worse than no marker. drive/print-dialog.js asserts that none of + * them overlaps it. + */ +.build-badge { bottom: 88px !important; } + +.status { + position: fixed; + left: 16px; bottom: 62px; + z-index: 50; + max-width: 460px; + padding: 4px 9px; + border-radius: 4px; + background: rgba(20, 28, 38, .88); + color: #fff; + font-family: var(--mono); + font-size: 11px; +} +.status[data-kind="err"] { background: rgba(163, 43, 43, .92); } +.status[data-kind="warn"] { background: rgba(154, 91, 18, .92); } +.status[data-kind="ok"] { background: rgba(31, 122, 77, .92); } + +.trace-wrap { position: fixed; right: 10px; bottom: 62px; z-index: 50; font-size: 11px; } +.trace-wrap summary { cursor: pointer; color: var(--f-hint); font-family: var(--mono); } +.trace-wrap #trace { + max-height: 180px; width: 320px; overflow: auto; + background: rgba(20, 28, 38, .92); color: #cfe; + font-family: var(--mono); padding: 6px; border-radius: 4px; +} +.trace-wrap .t { white-space: pre; } diff --git a/resources/web/print_processing/index.html b/resources/web/print_processing/index.html index 61c6c54c3f3..85c8bf1bb33 100644 --- a/resources/web/print_processing/index.html +++ b/resources/web/print_processing/index.html @@ -2,46 +2,47 @@ - Snapmaker U1 — Print processing - -
    + -
    - starting… - -
    + + +
    +
    +
    +
    +
    + 0% +
    -
    + - diff --git a/resources/web/print_processing/js/app.js b/resources/web/print_processing/js/app.js index 05cf6f11a5f..487df83b930 100644 --- a/resources/web/print_processing/js/app.js +++ b/resources/web/print_processing/js/app.js @@ -1,237 +1,400 @@ /* - * app.js - wiring for the print-processing popup. + * app.js - the print-processing popup. * - * Startup mirrors the shipped popup's own order (docs: - * 03-print-processing/02-lifecycle.md): - * 1. sw_GetActiveFile which file Orca is about to send - * 2. sw_GetConnectedMachine which printer is attached - * 3. sw_GetPrintLegal does the preset match that printer - * 4. sw_GetFileFilamentMapping what filaments the file needs - * 5. sw_SetSubscribeFilter + sw_SubscribeMachineState - * so print_task_config stays live + * Orca opens this modal at ?path=4 or ?path=5 and the route decides one thing: whether + * the print half is drawn. Everything else is the same dialog. * - * Two modes, matching Orca's two routes: - * ?mode=print -> ?path=4 preUploadAndPrint (filament + preferences shown) - * ?mode=upload -> ?path=5 preUpload (those sections hidden) + * ?mode=print ?path=4 preUploadAndPrint (default) + * ?mode=upload ?path=5 preUpload + * ?mock=1 force the simulator + * + * Startup reads the two sources this surface reconciles - the FILE from Orca, the + * MACHINE from the state stream - and then does nothing on its own until something + * changes. See core/session.js for why neither is derived from the other. */ 'use strict'; -import { CMD, SUBSCRIBE_OBJECTS, MAPPING_STATUS, PRINT_PREFERENCES, PRINT_TASK, - plainLine, prefsLine } - from '../../shared/js/protocol.js'; +import { CMD, PRINT_PREFERENCES } from '../../shared/js/protocol.js'; import { Sswcp } from '../../shared/js/sswcp.js'; import { MachineState } from '../../shared/js/state.js'; import { mountBuildBadge } from '../../shared/js/buildinfo.js'; -import { installMock } from './mock.js'; -import * as ui from './ui.js'; +import { $, el } from '../../shared/js/dom.js'; +import { text, data } from '../../shared/js/render.js'; +import { Pending } from '../../shared/js/pending.js'; +import { installMock } from './core/mock.js'; +import { buildShell } from './core/shell.js'; +import { readJob, subscribeState, fileFilaments, machineToolheads, + nozzleMismatch, initialAssignment, matchOf } from './core/session.js'; +import { runSend, close as closeDialog, SEND_STATE, BUSY } from './core/send.js'; +import { closePicker } from './widgets/picker.js'; +import { makeTrace } from '../../shared/js/trace.js'; +import { openDialog } from '../../shared/js/dialog.js'; +import { percent0 } from './widgets/format.js'; +import * as printerCmds from './views/printer/printer-commands.js'; +import * as filamentCmds from './views/filament/filament-commands.js'; +import * as prefCmds from './views/preferences/preferences-commands.js'; +import { MAPPING_STATUS } from '../../shared/js/protocol.js'; const qs = new URLSearchParams(location.search); -const wantMock = qs.get('mock') === '1'; -/** upload-only is ?path=5 in Orca; anything else is the print flow. */ -const MODE = qs.get('mode') === 'upload' ? 'upload' : 'print'; -const WITH_PRINT_SETUP = MODE === 'print'; +const ROUTE = qs.get('mode') === 'upload' ? 'upload' : 'print'; +const WITH_PRINT_SETUP = ROUTE === 'print'; +const WANT_MOCK = qs.get('mock') === '1'; const state = new MachineState(); +/** Every status line this page has shown, oldest first. Read by --drive scripts. */ +const said = []; +/* + * A request is held until the machine reports it. `onChange` fires when one starts or is + * refused - the two moments there is something new to show and nothing on the state + * stream about to prompt a repaint. + */ +const pending = new Pending({ onChange: () => render() }); + +/** Everything the panels read. One object, so a panel cannot reach past it. */ +const model = { + file: null, mapping: null, legal: null, device: null, devices: [], + filaments: [], toolheads: [], assignment: {}, prefs: {}, + nozzleMismatch: false, errors: [], connected: false, + send: { state: SEND_STATE.IDLE, progress: 0, detail: '' }, +}; + let bridge = null; -let device = null; -let legal = null; -let file = { filename: '', predictionTime: 0, weight: 0 }; -let mapping = null; -let prefs = {}; +let mockHost = null; +let mounted = []; let trace = () => {}; -let uploadPct = 0; -function setStatus(text, kind = '') { - const n = ui.$('#status'); - n.textContent = text; - n.className = 'status ' + kind; +/* ---- the context every panel is handed ------------------------------- */ +const ctx = { + get: () => model, + dialog: () => document.body, + + /* + * Picking a printer is not only bookkeeping: nothing has brought a transport up to + * THAT machine, so the machine half has to come up again against it. Without this the + * dialog would show a chosen printer and four toolheads reading NONE - which looks + * like a printer with nothing loaded, and is the one wrong state that is plausible + * enough to be acted on. + */ + chooseDevice(id) { + const d = model.devices.find( + (x) => String(x.dev_id || x.sn || x.ip || x.dev_name) === String(id)); + if (!d || d === model.device) return; + model.device = d; + model.connected = false; + render(); + bringUpMachine(); + }, + addDevice() { + printerCmds.addDevice(bridge).catch((e) => say(`add device: ${e.message}`, 'err')); + }, + + /** + * Assign a file filament to a toolhead. + * + * Held through `pending` until `print_task_config.extruder_map_table` reports it: the + * machine echoes about a second later, and writing the click into the object that + * mirrors the machine is how the next push puts the old value back. That bug has been + * found three times on the Device page; this is the fourth control and it gets the + * mechanism for free. + */ + assign(fil, toolhead) { + model.assignment[fil.key] = toolhead; + // `track` records the request and wires the command's own outcome into it, so a + // refusal stops the hold immediately rather than waiting out the timeout. + pending.track(`map:${fil.key}`, toolhead, filamentCmds.writeAssignment(bridge, { + fileIndex: fil.index, + toolhead, + allToolheads: model.filaments.map((f) => model.assignment[f.key]), + })).catch((e) => say(`could not set the mapping: ${e.message}`, 'err')); + }, + + prefValue: (key) => !!model.prefs[key], + togglePreference(key, value) { + model.prefs[key] = value; + pending.track(`pref:${key}`, value, prefCmds.writePreference(bridge, key, value)) + .catch((e) => say(`could not set ${key}: ${e.message}`, 'err')); + }, + + /** + * The help control on Extrusion Flow Calibration opens a DIALOG, not a tooltip - + * `A.rB(...)` with a title, a paragraph and an Ok. The sheet is the shared one the + * Device page uses; `cancel: false` because offering both Cancel and Ok for one + * dismissal is a choice that is not one. + */ + explain({ title, body }) { + openDialog({ + title, + build: (root) => root.appendChild(el('p', null, body)), + confirmLabel: 'Ok', + cancel: false, + onConfirm: () => true, + }); + }, + + async refresh(panelId) { + if (panelId !== 'filament') return; + try { + const m = await filamentCmds.refreshMapping( + bridge, (model.file && model.file.filename) || ''); + if (m) { model.mapping = m; recomputeFile(); render(); } + } catch (e) { say(`refresh: ${e.message}`, 'err'); } + }, +}; + +/* ---- status line ------------------------------------------------------ */ +/* + * The one-line host status. Every call is also kept on `__preprint.said`, because a + * drive script needs to know WHAT the page reported and not merely what is on screen + * now - "connecting", then "no route to host" is a different story from never trying, + * and only the second line survives to be read off the DOM. + */ +function say(message, kind = '') { + said.push(`${kind || '-'}: ${message}`); + const n = $('#status'); + n.hidden = !message; + text(n, message || ''); + data(n, 'kind', kind || null); } -async function boot() { - trace = ui.makeTrace(ui.$('#trace')); +/* ---- derive ----------------------------------------------------------- */ +function recomputeFile() { + model.filaments = fileFilaments(model.mapping); + const fresh = initialAssignment(model.mapping, model.filaments); + // Keep anything the operator has already chosen; only fill in what is new. + model.filaments.forEach((f) => { + if (model.assignment[f.key] == null) model.assignment[f.key] = fresh[f.key]; + }); +} - let mock = null; - if (wantMock || !Sswcp.hasHost()) { - mock = installMock({ log: trace, onClose: (ok) => setStatus( - ok ? 'dialog closed: success' : 'dialog closed: canceled', ok ? 'ok' : 'warn') }); - window.__preprint.mock = mock; - setStatus('simulated host (no Orca)', 'mock'); - } else { - setStatus('connected to Orca', 'ok'); - } - ui.$('#mode').textContent = (mock ? 'MOCK' : 'LIVE') + ' · ' - + (WITH_PRINT_SETUP ? 'path=4 upload+print' : 'path=5 upload only'); - ui.$('#mode').className = 'mode ' + (mock ? 'mock' : 'live'); +function recomputeMachine() { + model.toolheads = machineToolheads(state); + model.nozzleMismatch = WITH_PRINT_SETUP && nozzleMismatch( + model.filaments.map((f) => f.nozzle), + model.toolheads.map((h) => h.nozzleDiameter)); - bridge = new Sswcp({ log: trace }); + /* + * `resolve(key, mirror)` is the tick: hand it what the machine reports and it returns + * what to show. It confirms a value the machine has caught up with, keeps holding one + * still in flight, and gives up on one the machine never echoed - which is the case + * that matters, because an instant `ok` and a silently ignored setpoint look the same. + */ + const tc = state.objects['print_task_config'] || {}; + PRINT_PREFERENCES.forEach(({ key }) => { + model.prefs[key] = !!pending.resolve(`pref:${key}`, !!tc[key]).value; + }); - try { - const f = await bridge.request(CMD.GET_ACTIVE_FILE, {}); - if (f && f.filename) file.filename = f.filename; - } catch (e) { console.warn('[preprint] active file:', e.message); } + // `extruder_map_table` is a flat array of THIRTY-TWO - one entry per possible tool, + // not one per slot. Measured on 811002511261022618B3. + const table = Array.isArray(tc.extruder_map_table) ? tc.extruder_map_table : null; + model.filaments.forEach((f) => { + // `extruder_map_table` is the machine's own map and is always fully populated - it + // is 32 entries whether or not a plate is loaded - so it cannot be read as "Orca + // assigned this". Only a value the operator or Orca actually chose counts; the + // mirror is what a HELD value is confirmed against, not a source of assignments. + const held = pending.resolve(`map:${f.key}`, + table && table[f.index] != null + ? Number(table[f.index]) : null); + if (held.state === 'sent' || held.asked != null) { + model.assignment[f.key] = held.value; + } + }); +} - try { - device = await bridge.request(CMD.GET_CONNECTED_MACHINE, {}); - } catch (e) { - console.warn('[preprint] no connected machine:', e.message); - setStatus('no connected machine', 'warn'); - } +/* ---- render ----------------------------------------------------------- */ +let raf = 0; +function render() { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + recomputeMachine(); + mounted.forEach(({ panel, root }) => panel.update(root, model, ctx)); + renderSendBar(); + }); +} - if (device) { - try { - legal = await bridge.request(CMD.GET_PRINT_LEGAL, { - connected_model: device.model_name || device.machineType || '', - }); - } catch (e) { console.warn('[preprint] legality check:', e.message); } +function renderSendBar() { + const btn = $('#send'); + const s = model.send; + data(btn, 'state', s.state); + $('#bar-fill').style.width = `${Math.round(s.progress * 100)}%`; + text($('#pct'), percent0(s.progress)); + + const busy = BUSY.has(s.state); + // `A.Jb.aEL()` shows a spinner in place of the label whenever the state is in flight. + data(btn, 'busy', busy ? '1' : null); + text(btn, busy ? '' : (s.state === SEND_STATE.DONE ? 'Sent' + : ROUTE === 'upload' ? 'Send' : 'Send')); + // A device RECORD is not a printer that answered. Sending to one that never did + // would fail at the upload, after the dialog had implied the mapping was checked. + /* + * Every filament needs a home. This is the page's own gate, not one recovered from + * the bundle: the picker already refuses a toolhead that cannot print a filament, so + * the only bad state left is one still unassigned - the `!` on the card - and sending + * a plate with one would leave the machine to discover it. + */ + const unplaced = WITH_PRINT_SETUP + && model.filaments.some((f) => model.assignment[f.key] == null); + btn.disabled = busy || s.state === SEND_STATE.DONE || !model.device || !model.connected + || unplaced; +} + +/* ---- boot ------------------------------------------------------------- */ +async function boot() { + trace = makeTrace($('#trace')); + + let mock = null; + if (WANT_MOCK || !Sswcp.hasHost()) { + mockHost = mock = installMock({ + log: trace, + onDialogClose: (ok) => say(ok ? 'dialog closed: success' : 'dialog closed: canceled', + ok ? 'ok' : 'warn'), + }); + say('simulated host (no Orca)', 'mock'); } + bridge = new Sswcp({ log: trace }); - if (WITH_PRINT_SETUP) await loadMapping(); + ctx.dialog = () => document.body; + mounted = buildShell($('#body'), ROUTE, ctx); + data(document.body, 'route', ROUTE); - // print_task_config arrives over the shared state subscription - the same - // objects and field filter the Device tab uses. - try { - await bridge.request(CMD.SET_SUBSCRIBE_FILTER, { objects: SUBSCRIBE_OBJECTS }); - const snap = await bridge.request(CMD.GET_MACHINE_STATE, { objects: SUBSCRIBE_OBJECTS }); - state.applyPayload(snap); - await bridge.subscribe(CMD.SUBSCRIBE_MACHINE_STATE, {}, (d) => state.applyPayload(d)); - } catch (e) { console.warn('[preprint] state:', e.message); } + const job = await readJob(bridge, { log: (what, e) => trace('warn', { what, e }) }); + Object.assign(model, { + file: job.file, mapping: job.mapping, legal: job.legal, errors: job.errors, + devices: job.devices, + }); + // sw_GetConnectedMachine returns a device only when one is connected; the saved list + // is what the picker offers when none is. + model.device = job.device && Object.keys(job.device).length ? job.device + : (job.devices || []).find((d) => d.connected) || null; + recomputeFile(); - prefs = Object.assign({}, state.taskConfig()); + await bringUpMachine(); + // On the repaint is not good enough for anything whose consequence is elsewhere, but + // this one only repaints - see device_page/js/core/orcasync.js for the other case. state.onChange(render); + + if (job.errors.length) say(job.errors[0], 'warn'); render(); - mountBuildBadge(ui.$('#build-badge'), 'Print processing', bridge) + mountBuildBadge($('#build-badge'), 'Print processing', bridge) .then((info) => { window.__preprint.build = info; }) .catch(() => {}); + + window.__preprint.mock = mock; + window.__preprint.ready = true; } -async function loadMapping() { +/** + * Bring the machine half up against whatever printer is currently chosen. + * + * This can legitimately fail - a printer that is off, a device that was never paired - + * and when it does the dialog must SAY so. An unattached transport draws four toolheads + * reading NONE with no nozzles, which is indistinguishable from a printer with nothing + * loaded, and is the one wrong state plausible enough to be acted on. + */ +async function bringUpMachine() { + if (!model.device) { + model.connected = false; + say('No printer chosen. Pick one to read what is loaded.', 'warn'); + render(); + return; + } try { - mapping = await bridge.request(CMD.GET_FILE_FILAMENT_MAPPING, - { filename: file.filename }); - if (mapping) { - file.predictionTime = mapping.prediction_time ?? file.predictionTime; - file.weight = mapping.weight ?? file.weight; - } + await subscribeState(bridge, state, model.device, { + log: (w, e) => trace('warn', { w, e }), + onStep: (m) => say(m), + }); + model.connected = true; + say(''); } catch (e) { - console.warn('[preprint] filament mapping:', e.message); + model.connected = false; + say(`could not read the printer: ${e.message}`, 'warn'); } + render(); } -/* ---- handlers ------------------------------------------------------ */ - -const handlers = { - pickPrinter: () => setStatus('printer picker not implemented in this reconstruction', 'warn'), - - refreshFilament: async () => { await loadMapping(); render(); }, - - /* - * Both of these write `print_task_config`, and both did it with - * `sw_UpdateMachineFilamentInfo` - which does not reach the printer. It is Orca's own - * filament record, it wants `{objects:[{key,value}]}`, and a flat patch fails its first - * `if`. The macros below are what the shipped popup sends for the same two actions, - * recovered from `setPrePrintConfiguration` in main.dart.js; it emits all three lines in - * one script, so they are joined the same way here. - */ - assignSlot: (filamentIndex, toolhead) => { - if (!mapping || !mapping.filaments[filamentIndex]) return; - mapping.filaments[filamentIndex].extruder = toolhead; - // EXTRUDERS is the set of TOOLHEADS in use, not the file's filament indices - the - // bundle joins the map's values, and `extruders_used` on the machine is one flag per - // toolhead. De-duplicated because two filaments can share one head. - const heads = [...new Set(mapping.filaments.map((f) => Number(f.extruder) || 0))]; - const script = [ - // Bare, not quoted: the bundle builds these two by plain concatenation, and only - // SET_PRINT_FILAMENT_CONFIG goes through the quoting map. - plainLine(PRINT_TASK.EXTRUDER_MAP, - { CONFIG_EXTRUDER: filamentIndex, MAP_EXTRUDER: Number(toolhead) || 0 }), - plainLine(PRINT_TASK.USED_EXTRUDERS, { EXTRUDERS: heads.join(',') }), - ].join('\n'); - bridge.request(CMD.SEND_GCODES, { script }) - .catch((e) => console.warn('[preprint] update mapping:', e.message)); - render(); - }, - - setPreference: (key, value) => { - prefs[key] = value; - const pref = PRINT_PREFERENCES.find((p) => p.key === key); - const script = pref ? prefsLine({ [pref.arg]: value }) : ''; - if (script) { - bridge.request(CMD.SEND_GCODES, { script }) - .catch((e) => console.warn('[preprint] update preference:', e.message)); - } - render(); - }, - - /** - * Send. Mirrors the real close protocol: report the outcome, record the - * result, then close - three separate commands, in that order. - */ - send: async () => { - const btn = ui.$('#send'); - btn.disabled = true; - setStatus('uploading…'); - try { - await bridge.request(CMD.GET_PRINT_ZIP, {}); - for (let p = 0; p <= 100; p += 10) { - uploadPct = p; - ui.renderSend(uploadPct, false, 'Sending…'); - await new Promise((r) => setTimeout(r, 60)); - } - if (WITH_PRINT_SETUP) await bridge.request(CMD.START_LOCAL_PRINT, {}); - await bridge.request(CMD.FINISH_PREPRINT, { status: 'success' }); - await bridge.request(CMD.SET_FILAMENT_MAPPING_COMPLETE, - { status: MAPPING_STATUS.SUCCESS }); - await bridge.request(CMD.FINISH_FILAMENT_MAPPING, {}); - setStatus('sent', 'ok'); - ui.renderSend(100, false, 'Sent'); - } catch (e) { - setStatus(`send failed: ${e.message}`, 'err'); - await bridge.request(CMD.FINISH_PREPRINT, { status: 'failed' }).catch(() => {}); - ui.renderSend(uploadPct, true, 'Send'); - } - }, -}; - -/* ---- render -------------------------------------------------------- */ - -let raf = 0; -function render() { - if (raf) return; - raf = requestAnimationFrame(() => { - raf = 0; - const tc = Object.assign({}, state.taskConfig(), prefs); - - ui.renderModel(ui.$('#model'), file); - ui.renderPrinter(ui.$('#printer'), device, legal, handlers); - - // path=5 (upload only) drops filament mapping and print preferences - // entirely - that is the whole difference between the two routes. - const filCard = ui.$('#filament'); - const prefCard = ui.$('#preferences'); - filCard.hidden = !WITH_PRINT_SETUP; - prefCard.hidden = !WITH_PRINT_SETUP; - if (WITH_PRINT_SETUP) { - ui.renderFilament(filCard, mapping, tc, handlers); - ui.renderPreferences(prefCard, tc, handlers); - } +/** + * The simulator's stand-in for the printer's HTTP upload. + * + * It reports progress in the same shape `XMLHttpRequest.upload.onprogress` does, at a + * pace fast enough not to slow a suite down. It is not pretending the upload succeeded + * on a machine - there is no machine - it is letting the sequence around it be tested. + */ +async function simulatedUpload({ blob, filename, onProgress, onReply }) { + const total = (blob && blob.size) || 1; + for (let i = 1; i <= 8; i++) { + if (onProgress) onProgress(Math.round((total * i) / 8), total); + await new Promise((r) => setTimeout(r, 40)); + } + // Moonraker's own 201 shape, measured on 811002511261022618B3: `path` and `root` are + // separate fields and `path` carries NO root prefix. The send reads the stored path + // back off this rather than constructing one, so the simulator has to answer in the + // same shape or it stops testing that. + const body = JSON.stringify({ + action: 'create_file', + item: { path: filename, root: 'gcodes', size: total, + modified: Date.now() / 1000, permissions: 'rw' }, + }); + if (onReply) onReply(201, body); + return body; +} - // Send needs a printer, and a legal preset match when one was checked. - const ready = !!device && (!legal || legal.legal !== false); - ui.renderSend(uploadPct, ready, 'Send'); +/* ---- the send --------------------------------------------------------- */ +async function doSend() { + closePicker(); + say(''); + const ok = await runSend({ + bridge, + device: model.device, + withPrintSetup: WITH_PRINT_SETUP, + /* + * The upload is a POST to the PRINTER, so against the simulator there is nothing on + * the other end - the simulated U1 answers MQTT, not HTTP. `runSend` takes the + * upload as a seam for exactly this: the simulator supplies one that reports the + * same byte progress a real one would, so the whole sequence runs end to end + * without a machine. Against a real host this is undefined and the real POST goes. + */ + upload: mockHost ? simulatedUpload : undefined, + onState: (s, detail) => { + model.send.state = s; + model.send.detail = detail || ''; + if (s === SEND_STATE.FAILED) say(detail || 'send failed', 'err'); + render(); + }, + onProgress: (f) => { model.send.progress = f; render(); }, + // The wire, kept where a --drive script can read it. This surface's send has never + // been observed against a machine, so what each step ANSWERED is the evidence. + onNote: (m) => { trace('send', { note: m }); said.push(`send: ${m}`); }, }); + if (ok) say('sent', 'ok'); } window.addEventListener('DOMContentLoaded', () => { - ui.$('#send').onclick = () => handlers.send(); + $('#send').onclick = () => doSend(); boot().catch((e) => { console.error(e); - setStatus(`startup failed: ${e.message}`, 'err'); + say(`startup failed: ${e.message}`, 'err'); }); }); +/** Handed to --drive scripts. The model is live, not a copy. */ window.__preprint = { - get state() { return state; }, get bridge() { return bridge; }, - get mapping() { return mapping; }, get device() { return device; }, - mode: MODE, handlers, mock: null, + route: ROUTE, + get state() { return state; }, + get bridge() { return bridge; }, + get model() { return model; }, + get pending() { return pending; }, + ctx, render, mock: null, ready: false, + /** What the page has reported, in order. See `say`. */ + get said() { return said; }, + /* The rules a --drive script needs to reason about the same things the page does, + rather than reimplementing them and agreeing with itself by accident. */ + session: { matchOf }, + /* The command table, so a script names a command the way the page does. A drive + script that spells `sw_MachinePrintCancel` itself is one rename away from testing + nothing - and the cancel is the same command the Device page's task panel sends, + `CMD.PRINT_CANCEL`, not a second one belonging to this surface. */ + CMD, + /** Cancelling takes the close protocol's other outcome. */ + cancel: () => closeDialog(bridge, MAPPING_STATUS.CANCELED), }; diff --git a/resources/web/print_processing/js/core/mock.js b/resources/web/print_processing/js/core/mock.js new file mode 100644 index 00000000000..1bb4cae4ef0 --- /dev/null +++ b/resources/web/print_processing/js/core/mock.js @@ -0,0 +1,30 @@ +/* + * mock.js - this surface's simulated host. + * + * A thin adapter, like the Device tab's, and for the same reason: the U1 AND Orca's own + * side of the print dialog are both simulated in `shared/js/mockhost.js`, so the two + * surfaces cannot be shown machines that disagree with each other. + * + * That is not a tidiness argument. This dialog's job is to match the FILE's filaments + * against the MACHINE's, and if a surface-local mock invented both lists the match could + * never fail - the check would be testing its own fixture. In the shared host the + * machine half is `printer.filamentType` / `toolheads[].nozzle_diameter`, the file half + * is `printer.job`, and they start out agreeing; a check that wants a mismatch has to + * create one. + * + * The previous mock for this surface invented `{filaments:[{type,color,used_g}]}` - a + * shape `sw_GetFileFilamentMapping` has never returned - and agreed with the client that + * was written from the same head. Both were wrong together, all the way past a real Orca. + * + * If a command genuinely belongs to this surface alone, it goes here. Nothing does yet. + */ +'use strict'; + +import { installMockHost } from '../../../shared/js/mockhost.js'; + +/** Commands only the print dialog issues, that the shared host does not answer. */ +const PREPRINT_HANDLERS = {}; + +export function installMock({ log = () => {}, onDialogClose = null } = {}) { + return installMockHost({ log, handlers: PREPRINT_HANDLERS, onDialogClose }); +} diff --git a/resources/web/print_processing/js/core/send.js b/resources/web/print_processing/js/core/send.js new file mode 100644 index 00000000000..aaaf5597899 --- /dev/null +++ b/resources/web/print_processing/js/core/send.js @@ -0,0 +1,207 @@ +/* + * send.js - packaging, uploading, starting, and ending the dialog. + * + * Four things this gets right that the previous reconstruction did not, all of them + * read off the host rather than guessed: + * + * 1. THE FILE COMES AS A URL. `sw_GetFileStream {is_zip:true}` writes the zip to disk and + * returns `{file_name, file_url, origin_size, checksum}` - a localhost URL on Orca's + * own page server, the one already serving this document. `sw_GetPrintZip` returns + * the BYTES, from a `std::vector` that nlohmann serialises as one integer per + * byte: a 12 MB zip crosses the webview bridge as ~40 MB of JSON. The URL is 90. + * + * 2. THE UPLOAD IS OURS. Orca does not upload; the LAN route is a multipart POST to + * `/server/files/upload` on the printer, which is why the bundle reads the LAN IP to + * do it. So the progress bar is a byte count from `XMLHttpRequest.upload.onprogress` + * and not a timer - the old one counted to 100 in 600ms whether or not anything was + * being sent. + * + * 3. `sw_StartLocalPrint` REFUSES `{}`. Its first statement wants `{type, path}` and + * fails the request without them (SSWCP.cpp:2595). The old page sent `{}`, which + * means the send stopped on its first real command every time. + * + * 4. CLOSING IS TWO COMMANDS AND ONLY THE SECOND CLOSES. `sw_SetFilamentMappingComplete` + * records the outcome and deliberately does not end the modal; + * `sw_FinishFilamentMapping` reads what was recorded and calls SafeEndModal. Doing + * them in one step is the race SafeEndModal exists to survive. + * See docs/u1-webui/03-print-processing/02-lifecycle.md + */ +'use strict'; + +import { CMD, MAPPING_STATUS } from '../../../shared/js/protocol.js'; + +/** + * The Send button's eight states. + * + * `A.Jb.aKO()` switches on the view-model's state and returns a colour; `A.Jb.aEL()` + * shows a spinner instead of a label whenever the state is "in flight". Both are the + * bundle's, which is why there are five colours for eight states. + */ +export const SEND_STATE = { + IDLE: 'idle', // 7/0 -> grey + PACKAGING: 'packaging', // 2 -> amber + UPLOADING: 'uploading', // 3 -> amber + STARTING: 'starting', // 4 -> amber + READY: 'ready', // 1 -> blue + DONE: 'done', // 5 -> green + FAILED: 'failed', // 6 -> red +}; + +export const BUSY = new Set([SEND_STATE.PACKAGING, SEND_STATE.UPLOADING, + SEND_STATE.STARTING]); + +/** + * POST the file to the printer, reporting real bytes. + * + * Kept behind an injectable `xhrFactory` for one reason: the only honest way to test an + * upload's progress is to drive the events, and a test that stubs `window.fetch` is + * testing its own stub. A drive script hands in an XHR whose `upload` emits what it + * wants to see. + */ +export function uploadToPrinter({ url, blob, filename, onProgress, xhrFactory, onReply }) { + return new Promise((resolve, reject) => { + const xhr = (xhrFactory || (() => new XMLHttpRequest()))(); + xhr.open('POST', url, true); + xhr.upload.onprogress = (e) => { + // `lengthComputable` is false for a chunked body; reporting a fraction we do not + // have is how a bar comes to move on its own. + if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); + }; + xhr.onload = () => { + // The host's own answer, kept rather than discarded. A 2xx is not proof the file + // landed: Moonraker replies about what it stored and WHERE, and the first real + // upload from this page produced a 2xx with no file on the printer afterwards. + if (onReply) onReply(xhr.status, String(xhr.responseText || '').slice(0, 400)); + if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.responseText); + else reject(new Error(`upload failed: HTTP ${xhr.status} ` + + `${String(xhr.responseText || '').slice(0, 200)}`)); + }; + xhr.onerror = () => reject(new Error('upload failed: network error')); + const form = new FormData(); + form.append('file', blob, filename); + // Moonraker prints on upload when told to; the popup starts the job itself so that + // the mapping and the preferences are written first. + form.append('print', 'false'); + xhr.send(form); + }); +} + +/** + * Run the whole send. + * + * `deps` is every seam that talks to something outside this module, so the sequence + * itself can be driven without a printer, a host or a network. + */ +export async function runSend(deps) { + const { bridge, device, onState, onProgress, onNote, fetchFn = fetch, + upload = uploadToPrinter, withPrintSetup = true } = deps; + const set = (s, detail) => onState && onState(s, detail); + + try { + set(SEND_STATE.PACKAGING); + const stream = await bridge.request(CMD.GET_FILE_STREAM, { is_zip: true }); + if (!stream || !stream.file_url) { + throw new Error('sw_GetFileStream returned no file_url'); + } + + // Orca's own HTTP server, same origin family as this page. + const res = await fetchFn(stream.file_url); + if (!res.ok) throw new Error(`could not read the packaged file: HTTP ${res.status}`); + const blob = await res.blob(); + + set(SEND_STATE.UPLOADING); + const host = printerHost(device); + if (!host) throw new Error('no printer address to upload to'); + const uploadUrl = `${host}/server/files/upload`; + if (onNote) onNote(`POST ${uploadUrl} (${stream.file_name}, ${blob.size} bytes)`); + const uploaded = await upload({ + url: uploadUrl, + blob, + filename: stream.file_name, + onProgress: (loaded, total) => onProgress && onProgress(loaded / total, loaded, total), + onReply: (status, body) => onNote && onNote(`upload -> HTTP ${status} ${body}`), + }); + onProgress && onProgress(1, stream.origin_size, stream.origin_size); + + /* + * WHERE THE FILE WENT, according to the machine rather than according to us. + * + * Moonraker answers a 201 with the item it created, and `path` and `root` are + * SEPARATE fields: + * + * {"action":"create_file","item":{"path":"plate.zip","root":"gcodes",…}} + * + * Sending `gcodes/plate.zip` - the root glued onto the front, which is the obvious + * guess and what this did first - got `File not found: gcodes/plate.zip` from + * `server.files.start_local_print` on 811002511261022618B3. The upload had worked; + * only the name was wrong. Reading it back off the reply beats constructing it. + */ + const stored = storedPath(uploaded) || stream.file_name; + + if (withPrintSetup) { + set(SEND_STATE.STARTING); + // The two parameters the handler will not run without. + /* + * INFERRED, and the last unverified pair on this surface. The handler passes + * `m_param_data` straight through to `server.files.start_local_print`, so what + * this firmware wants for `type` and `path` is not in Orca's source at all. + */ + const startWith = { type: 'local', path: stored }; + if (onNote) onNote(`sw_StartLocalPrint ${JSON.stringify(startWith)}`); + const started = await bridge.request(CMD.START_LOCAL_PRINT, startWith); + if (onNote) onNote(`start -> ${JSON.stringify(started)}`); + } + + await report(bridge, 'success'); + await close(bridge, MAPPING_STATUS.SUCCESS); + set(SEND_STATE.DONE); + return true; + } catch (e) { + // The outcome report is separate from closing, and a failure reports without + // closing: `sw_FinishPreprint` with anything but "success" clears the dialog's + // switch-to-device flag, and the operator keeps a dialog they can retry from. + await report(bridge, 'failed').catch(() => {}); + set(SEND_STATE.FAILED, e.message); + return false; + } +} + +async function report(bridge, status) { + await bridge.request(CMD.FINISH_PREPRINT, { status }); +} + +/** + * The close protocol. Record, then end - two commands, in that order. + * + * Exported because cancelling takes the same path with the other outcome, and a second + * spelling of it is how the ordering gets lost. + */ +export async function close(bridge, status) { + await bridge.request(CMD.SET_FILAMENT_MAPPING_COMPLETE, { status }); + await bridge.request(CMD.FINISH_FILAMENT_MAPPING, {}); +} + +/** The path Moonraker says it stored the file at. Its own words, not our arithmetic. */ +export function storedPath(responseText) { + try { + const j = JSON.parse(responseText); + const item = j && j.item; + return (item && item.path) || null; + } catch (e) { + return null; + } +} + +/** + * Where to POST. + * + * The device record is Orca's `DeviceInfo`, and the page already talks straight to the + * printer elsewhere on this port - the ACE override store and the G-code console are + * both plain HTTP against Moonraker on 7125. + */ +export function printerHost(device) { + if (!device) return null; + const ip = device.ip || device.dev_ip || device.address; + if (!ip) return null; + return `http://${ip}:7125`; +} diff --git a/resources/web/print_processing/js/core/session.js b/resources/web/print_processing/js/core/session.js new file mode 100644 index 00000000000..f5e3ad63186 --- /dev/null +++ b/resources/web/print_processing/js/core/session.js @@ -0,0 +1,295 @@ +/* + * session.js - everything the dialog knows, and where it came from. + * + * The dialog has TWO sources and the whole surface exists to reconcile them: + * + * the FILE sw_GetFileFilamentMapping, answered by Orca out of the sliced plate + * the MACHINE print_task_config on the state stream, plus each extruder's + * nozzle_diameter + * + * Neither is derived from the other, and that is the point: the toolhead a filament may + * be assigned to is one whose TYPE and NOZZLE both match, so a page that built one list + * out of the other could never report a mismatch. The shipped bundle names a third + * command for the machine side - `sw_GetMachineFilamentMapping`, index 29 - which + * Orca does not implement (`implemented_in_cpp: false` in data/wcp-commands.json), so + * `print_task_config` is the only source there is. + * + * What this module does NOT do is decide anything. It reads, it normalises the two + * shapes into one vocabulary, and it holds the operator's edits. Which head is offered, + * which is refused and what the Send button does are the panels' and send.js's. + */ +'use strict'; + +import { CMD, SUBSCRIBE_OBJECTS, TOOLHEADS } from '../../../shared/js/protocol.js'; +import { connect } from '../../../shared/js/connection.js'; +import { cssColor, nozzleStr } from '../widgets/format.js'; + +/** + * One file filament, from the reply's PARALLEL ARRAYS. + * + * There is no `filaments[]` in this reply and there never has been. The previous + * reconstruction read one, its own mock returned one, and the two agreed with each other + * all the way past a real Orca - which answers with `filament_type[]`, + * `filament_color_rgba[]`, `filament_weight[]` and the rest. SSWCP.cpp:3039. + * + * `A.bEE` is the bundle's own filter: a filament the plate does not actually use is + * dropped from the grid rather than drawn empty. + */ +export function fileFilaments(mapping) { + if (!mapping) return []; + const arr = (k) => (Array.isArray(mapping[k]) ? mapping[k] : []); + const types = arr('filament_type'); + const rgba = arr('filament_color_rgba'); + const argb = arr('filament_color'); + const multi = arr('filament_color_multi'); + const weight = arr('filament_weight'); + const usedMm = arr('filament_used_mm'); + const nozzles = arr('nozzle_diameters'); + + /* + * `nozzle_diameters` is NOT one per filament. The C++ builds it from + * `full_config.nozzle_diameter`, which is one per EXTRUDER, while `filament_type` + * comes from the filament presets loaded and is routinely longer - a real plate off + * this Orca reports seven filament types and four nozzles. + * + * The match test needs a nozzle per FILAMENT, so the two have to be paired, and the + * reply does not say how. Two rules, and only the first is derivable: + * + * one distinct diameter every filament was sliced for it. Nothing to infer. + * several pair by the filament's own index, and leave the ones past + * the end null + * + * A null nozzle FAILS the match on its own (`fil.nozzleDiameter != null` is half of + * `A.aJQ`'s test), so the second rule refuses rather than guesses: a filament this + * page cannot place is one the operator is told about, not one silently assigned. + * + * INFERRED, and the only inferred thing on this surface. The bundle pairs them + * somewhere in its view-model and that code has not been read. + */ + const distinct = [...new Set(nozzles.filter(Boolean).map(String))]; + const nozzleFor = (i) => (distinct.length === 1 ? distinct[0] : nozzleStr(nozzles[i])); + + return types.map((type, i) => ({ + index: i, + key: String(i), + type: type || '---', + // `filament_color_multi[i]` carries {mode, colors} for a gradient or segmented + // spool; a plain one falls back to the single colour. Both spellings are read + // because the reply carries the colour twice, as a string and as an integer. + colors: multiColors(multi[i]) || single(rgba[i] != null ? rgba[i] : argb[i]), + colorMode: multi[i] && multi[i].mode != null ? Number(multi[i].mode) : 0, + used: Number(weight[i]) || 0, + usedMm: Number(usedMm[i]) || 0, + nozzle: nozzleFor(i), + })).filter(usesMaterial); +} + +/** `A.bEE`: keep a filament the plate actually consumes. -1 mm means "unknown, keep". */ +function usesMaterial(f) { + return f.used > 0 || f.usedMm > 0 || f.usedMm === -1; +} + +function multiColors(m) { + if (!m || typeof m !== 'object' || !Array.isArray(m.colors)) return null; + const out = m.colors.map(cssColor).filter(Boolean); + return out.length ? out : null; +} +const single = (v) => { const c = cssColor(v); return c ? [c] : []; }; + +/** + * One toolhead, as the dropdown item needs it: `index`, `filamentType`, + * `nozzleDiameter`, and the colour. + * + * The nozzle comes off the EXTRUDER object rather than print_task_config, because that + * is where the machine reports it - `SUBSCRIBE_OBJECTS` asks each extruder for + * `nozzle_diameter` and has since the Device page's first subscription. + */ +export function machineToolheads(state) { + const tc = (state && state.objects && state.objects['print_task_config']) || {}; + const arr = (k) => (Array.isArray(tc[k]) ? tc[k] : []); + const types = arr('filament_type'); + const rgba = arr('filament_color_rgba'); + const argb = arr('filament_color'); + const multi = arr('filament_color_multi'); + const exists = arr('filament_exist'); + + return TOOLHEADS.map((key, i) => { + const ext = (state && state.objects && state.objects[key]) || {}; + const type = types[i]; + return { + index: i, + // The bundle compares this string directly, and treats "NONE" as its own case - + // it gets a different tooltip from a plain mismatch. + filamentType: type == null || type === '' ? 'NONE' : type, + nozzleDiameter: nozzleStr(ext.nozzle_diameter), + colors: multiColors(multi[i]) || single(rgba[i] != null ? rgba[i] : argb[i]), + colorMode: multi[i] && multi[i].mode != null ? Number(multi[i].mode) : 0, + loaded: exists[i] !== false, + }; + }); +} + +/** + * May this file filament print from this toolhead? + * + * `A.aJQ.$1`, verbatim: + * + * r = file.filamentType == head.filamentType + * && file.nozzleDiameter != null + * && String(file.nozzleDiameter) === String(head.nozzleDiameter) + * + * and `r` is passed as the menu item's ENABLED flag. A head that does not match is not + * merely discouraged - it cannot be chosen. Both halves matter and the file having no + * declared nozzle fails the test on its own. + * + * The two refusals are told apart because the bundle tells them apart: a head reading + * NONE gets `dialog_filament_type_none_tips`, anything else + * `dialog_filament_type_not_match_tips`. + */ +export function matchOf(fil, head) { + if (!fil || !head) return { ok: false, reason: 'none' }; + const ok = head.filamentType === fil.type + && fil.nozzle != null + && String(fil.nozzle) === String(head.nozzleDiameter); + if (ok) return { ok: true, reason: null }; + return { ok: false, reason: head.filamentType === 'NONE' ? 'none' : 'mismatch' }; +} + +/** + * `A.R5`, the nozzle-mismatch banner, and `A.c8l`, the comparison behind it. + * + * Set containment, not equality, and deliberately silent at both edges: an empty machine + * list means the banner never fires, so a page that has not heard from the printer yet + * does not accuse it of anything. + */ +export function nozzleMismatch(fileNozzles, machineNozzles) { + const a = (fileNozzles || []).filter(Boolean); + const b = (machineNozzles || []).filter(Boolean); + if (!a.length && !b.length) return false; + if (!b.length) return false; // c8l: `!n && b.length === 0` -> true + const have = new Set(b.map(String)); + return !a.every((x) => have.has(String(x))); +} + +/** + * Read everything Orca can tell us about the job, in the shipped popup's own order. + * + * Each is independent and each is allowed to fail: a dialog that cannot read the + * legality check should still draw the file, and one that cannot reach the printer + * should still say which file it was about to send. What a failure must never do is + * pass silently - every one lands in `errors`, which the status line reads. + */ +export async function readJob(bridge, { log = () => {} } = {}) { + const out = { file: null, mapping: null, legal: null, device: null, devices: [], + errors: [] }; + + const step = async (what, fn) => { + try { return await fn(); } + catch (e) { out.errors.push(`${what}: ${e.message}`); log(what, e); return null; } + }; + + out.file = await step('active file', () => bridge.request(CMD.GET_ACTIVE_FILE, {})); + out.device = await step('connected machine', + () => bridge.request(CMD.GET_CONNECTED_MACHINE, {})); + // The saved list, so the picker has something to offer even when nothing is connected. + const list = await step('device list', () => bridge.request(CMD.GET_LOCAL_DEVICES, {})); + out.devices = Array.isArray(list) ? list : (list && list.devices) || []; + + if (out.device && (out.device.model_name || out.device.machineType)) { + out.legal = await step('legality', () => bridge.request(CMD.GET_PRINT_LEGAL, { + connected_model: out.device.model_name || out.device.machineType, + })); + } + + out.mapping = await step('filament mapping', () => bridge.request( + CMD.GET_FILE_FILAMENT_MAPPING, + { filename: (out.file && out.file.filename) || '' })); + + return out; +} + +/** + * Bring the machine half up: a transport if nothing has one, then the subscription. + * + * `sw_GetMachineState` answers out of a Moonraker host, and something has to ATTACH one + * first - `sw_mqtt_set_engine` is what does it. Inside Orca the Device tab usually has, + * and this popup inherits it. Opened without the Device tab having connected, or run + * outside Orca against u1_bridge.py, nothing has: every state command comes back + * + * no engine attached yet (sw_mqtt_set_engine has not run) + * + * and the dialog draws four toolheads reading NONE with no nozzles - which looks exactly + * like a printer with nothing loaded. That is the worst kind of wrong: it is a plausible + * screen, and the operator would map filaments against it. + * + * So: try, and if the host says there is no engine, connect and try again. The connect + * path is the Device page's, unchanged - `shared/js/connection.js`. + * + * A device that has never been paired cannot be brought up from here. Pairing needs the + * PIN the printer is showing, and asking for it is the Device tab's job; this returns the + * failure rather than growing a second pairing flow. + */ +export async function subscribeState(bridge, state, device, + { log = () => {}, onStep = () => {} } = {}) { + const filterAndSubscribe = async () => { + try { + await bridge.request(CMD.SET_SUBSCRIBE_FILTER, { objects: SUBSCRIBE_OBJECTS }); + } catch (e) { + // Best-effort, as on the Device page: the filter narrows what arrives, and a host + // that refuses it still pushes everything. + log('subscribe filter', e); + } + const snap = await bridge.request(CMD.GET_MACHINE_STATE, { objects: SUBSCRIBE_OBJECTS }); + state.applyPayload(snap); + await bridge.subscribe(CMD.SUBSCRIBE_MACHINE_STATE, {}, (d) => state.applyPayload(d)); + }; + + try { + await filterAndSubscribe(); + return { connected: false }; // something already had a host attached + } catch (e) { + if (!needsEngine(e) || !device) throw e; + log('no engine attached; connecting', e); + } + + onStep('Connecting to the printer…'); + const engine = await connect(bridge, device, { onStep, trace: (m) => log('connect', m) }); + await filterAndSubscribe(); + return { connected: true, engine }; +} + +/** + * Did that fail because no transport is attached, rather than because the machine said + * no? The host answers with that sentence and nothing else does. + */ +function needsEngine(e) { + return /no engine attached/i.test((e && e.message) || ''); +} + +/** + * The assignment: which toolhead each file filament prints from. + * + * It starts from `filament_extruder_map` - Orca's OWN map, made in the Prepare sidebar + * and handed over in the mapping reply - rather than from nothing. Falls back to + * identity, which is what the popup shows for a plate Orca has no opinion about. + */ +export function initialAssignment(mapping, filaments) { + const map = (mapping && mapping.filament_extruder_map) || {}; + const out = {}; + filaments.forEach((f) => { + const v = map[f.key] != null ? map[f.key] : map[f.index]; + const n = Number(v); + // NOT identity. A filament Orca has no opinion about is UNASSIGNED, which the card + // draws as `!` in the warning colour, and which is the bundle's own answer. + // + // Falling back to `f.index` looked harmless and is not. Against the real machine on + // 2026-09-02 the plate wanted PLA in all four while the printer held + // ["PLA","PLA","NONE","PETG"], so identity put filament 3 on an empty head and + // filament 4 on PETG - two toolheads the picker itself REFUSES - and nothing on + // screen said so, because the warning mark means "nothing chosen" and something had + // been. An assignment the operator never made should not be one they have to notice + // is wrong. + out[f.key] = Number.isFinite(n) ? n : null; + }); + return out; +} diff --git a/resources/web/print_processing/js/core/shell.js b/resources/web/print_processing/js/core/shell.js new file mode 100644 index 00000000000..c11fb7d9f6d --- /dev/null +++ b/resources/web/print_processing/js/core/shell.js @@ -0,0 +1,64 @@ +/* + * shell.js - the dialog's chrome, built from the registry. + * + * One
    per panel, in the registry's order, with the spacing the bundle puts + * between them. A panel is a title column of 120..180 (B.m4), a 12px gap (B.bA) and its + * body - except a `bare` one, which is its body and nothing else. + * + * `mount` runs once. After that only `update` runs, so nothing here rebuilds a panel + * that has an open menu under the pointer. + */ +'use strict'; + +import { el } from '../../../shared/js/dom.js'; +import { panelsFor } from '../registry.js'; + +/** Build the whole body. Returns the panels that were mounted, with their roots. */ +export function buildShell(container, route, ctx) { + container.innerHTML = ''; + const panels = panelsFor(route); + const mounted = []; + + container.appendChild(el('div', 'sp-16')); // B.aE + + panels.forEach((panel, i) => { + if (i > 0) container.appendChild(el('div', 'sp-8')); // B.aB + + let body; + if (panel.bare) { + body = el('div', 'bare'); + body.id = `panel-${panel.id}`; + container.appendChild(body); + } else { + const section = el('section', 'card'); + section.id = `panel-${panel.id}`; + + const title = el('div', 'card-title'); + title.appendChild(el('span', null, panel.title)); + if (panel.header && panel.header.kind === 'refresh') { + const btn = el('button', 'card-refresh'); + btn.type = 'button'; + btn.title = panel.header.label; + btn.setAttribute('aria-label', panel.header.label); + btn.onclick = () => ctx.refresh(panel.id); + title.appendChild(btn); + } + section.appendChild(title); + section.appendChild(el('div', 'card-gap')); + + body = el('div', 'card-body'); + section.appendChild(body); + container.appendChild(section); + } + + panel.mount(body, ctx); + mounted.push({ panel, root: body }); + }); + + // The bundle pushes a 20 after the print half and another at the end; on ?path=5 only + // the second exists. + if (route === 'print') container.appendChild(el('div', 'sp-20')); + container.appendChild(el('div', 'sp-20')); + + return mounted; +} diff --git a/resources/web/print_processing/js/mock.js b/resources/web/print_processing/js/mock.js deleted file mode 100644 index d8b826c536a..00000000000 --- a/resources/web/print_processing/js/mock.js +++ /dev/null @@ -1,88 +0,0 @@ -/* - * mock.js - the print-processing popup's simulated host. - * - * Reuses the shared U1 simulation and adds only the commands this surface - * issues. The filament mapping it returns is derived from the SAME - * print_task_config the Device tab reads, which is the point: one model, two - * surfaces, one reading it and one editing it. - */ -'use strict'; - -import { installMockHost } from '../../shared/js/mockhost.js'; -import { MAPPING_STATUS } from '../../shared/js/protocol.js'; - -const FILE = { - filename: 'mock_multicolor_benchy.gcode', - prediction_time: 3182, - weight: 31.4, -}; - -export function installMock({ log = () => {}, onClose = () => {} } = {}) { - let mapping = null; - - const handlers = { - sw_GetActiveFile: () => ({ filename: FILE.filename }), - - // The preset the plate was sliced for vs the machine actually connected. - sw_GetPrintLegal: (params) => ({ - preset_model: 'Snapmaker U1', - legal: !params.connected_model || params.connected_model === 'Snapmaker U1', - }), - - // Built from print_task_config so both surfaces agree on the filament set. - sw_GetFileFilamentMapping: (_params, ctx) => { - const tc = ctx.printer.snapshot().print_task_config; - mapping = { - filename: FILE.filename, - prediction_time: FILE.prediction_time, - weight: FILE.weight, - filaments: (tc.filament_type || []).map((type, i) => ({ - id: i, - type, - color: (tc.filament_color || [])[i] || '#888888', - vendor: (tc.filament_vendor || [])[i] || '', - used_g: [12.4, 9.1, 6.7, 3.2][i] ?? 0, - extruder: (tc.extruders_used || [])[i] ?? i, - })), - }; - return mapping; - }, - - sw_UpdateMachineFilamentInfo: (params, ctx) => { - // Mirror the edit into print_task_config, exactly as the real flow does. - const tc = ctx.printer.taskConfigOverride || (ctx.printer.taskConfigOverride = {}); - if (Array.isArray(params.extruder_map_table)) { - tc.extruder_map_table = params.extruder_map_table; - } - Object.assign(tc, params); - return {}; - }, - - sw_GetPrintZip: () => ({ name: FILE.filename.replace(/\.gcode$/, '.zip'), content: '' }), - sw_StartLocalPrint: () => ({ task_id: 'mock-task-1' }), - sw_StartCloudPrint: () => ({ task_id: 'mock-task-1' }), - - // The two-step close: record the outcome, then end the dialog. - sw_SetFilamentMappingComplete: (params) => { - mockState.finish = params.status === MAPPING_STATUS.SUCCESS; - return {}; - }, - sw_FinishPreprint: (params) => { - mockState.preprintStatus = params.status || null; - return {}; - }, - sw_FinishFilamentMapping: () => { - mockState.closed = true; - onClose(mockState.finish); - return {}; - }, - }; - - const host = installMockHost({ log, handlers }); - if (!host) return null; - - const mockState = { finish: false, closed: false, preprintStatus: null, - get mapping() { return mapping; } }; - host.state = mockState; - return host; -} diff --git a/resources/web/print_processing/js/registry.js b/resources/web/print_processing/js/registry.js new file mode 100644 index 00000000000..c36dc3fbb3a --- /dev/null +++ b/resources/web/print_processing/js/registry.js @@ -0,0 +1,52 @@ +/* + * registry.js - what this dialog is made of. + * + * Same contract as the Device page's registry, because it is the same idea: a panel + * declares itself in one place, and everything one panel is made of lives in one folder. + * The differences are that this surface has no destinations and no columns - it is a + * single scrolling column of sections - and that `route` replaces them. + * + * Order is the order A.bi3 builds them in, and the spacing between them is the bundle's: + * + * SizedBox(16) B.aE + * Model Information A.J9 + * SizedBox(8) B.aB + * Select Printer A.Vn + * [ SizedBox(8) + * Edit Filament A.J8 / A.aiy + * SizedBox(8) + * Print Preferences A.Ja + * SizedBox(8) + * nozzle mismatch banner A.R5 + * SizedBox(20) ] only on ?path=4 + * SizedBox(20) B.eS + * + * Every panel declares: + * + * id unique; also the
    id + * title the text in the 120..180 title column (B.m4) + * route 'both' or 'print' - `print` panels are absent on ?path=5, which is the + * whole difference between the two routes + * bare no
    /title column; the body is the whole thing (the banner) + * reads what this panel renders from, declared because nothing derives it + * mount(root) build the body's fixed structure, once + * update(root, ctx) patch it, on every frame + * + * What a panel can SEND is not declared here: each is handed its own commands module and + * nothing else, so referencing a command is the only way to claim one and + * check_coverage.py can read it there. + */ +'use strict'; + +import modelInfo from './views/model-info/model-info-panel.js'; +import printer from './views/printer/printer-panel.js'; +import filament from './views/filament/filament-panel.js'; +import preferences from './views/preferences/preferences-panel.js'; +import nozzle from './views/nozzle/nozzle-panel.js'; + +export const PANELS = [modelInfo, printer, filament, preferences, nozzle]; + +/** The panels this route draws, in order. */ +export function panelsFor(route) { + return PANELS.filter((p) => p.route === 'both' || p.route === route); +} diff --git a/resources/web/print_processing/js/ui.js b/resources/web/print_processing/js/ui.js deleted file mode 100644 index c883b5fed7d..00000000000 --- a/resources/web/print_processing/js/ui.js +++ /dev/null @@ -1,188 +0,0 @@ -/* - * ui.js - rendering for the print-processing popup. - * - * Section order and labels follow the shipped Flutter popup exactly: - * Model Information / Select Printer / Edit Filament / Print Preferences - * then a send bar carrying upload progress and the Send button. - * See docs/u1-webui/03-print-processing/01-overview.md - */ -'use strict'; - -import { PRINT_PREFERENCES } from '../../shared/js/protocol.js'; - -export const $ = (sel, root = document) => root.querySelector(sel); - -function el(tag, cls, text) { - const n = document.createElement(tag); - if (cls) n.className = cls; - if (text != null) n.textContent = text; - return n; -} - -function section(root, title, extraHead) { - root.innerHTML = ''; - const head = el('div', 'card-head'); - head.appendChild(el('h2', null, title)); - if (extraHead) head.appendChild(extraHead); - root.appendChild(head); - const body = el('div', 'card-body'); - root.appendChild(body); - return body; -} - -/** Seconds -> "1 h 12 min", the popup's own granularity. */ -export function humanDuration(sec) { - const s = Number(sec); - if (!Number.isFinite(s) || s <= 0) return 'N/A'; - const h = Math.floor(s / 3600); - const m = Math.round((s % 3600) / 60); - return h ? `${h} h ${m} min` : `${m} min`; -} - -export function humanGrams(g) { - const n = Number(g); - return Number.isFinite(n) && n > 0 ? `${n.toFixed(1)} g` : 'N/A'; -} - -/* ---- Model Information -------------------------------------------- */ -export function renderModel(root, file) { - const b = section(root, 'Model Information'); - const row = el('div', 'model-row'); - - const thumb = el('div', 'thumb'); - thumb.appendChild(el('span', 'thumb-tag', 'G-code')); - row.appendChild(thumb); - - const facts = el('dl', 'facts'); - const add = (k, v) => { - facts.appendChild(el('dt', null, k)); - facts.appendChild(el('dd', null, v)); - }; - add('Filename', file.filename || 'N/A'); - add('Estimated Time', humanDuration(file.predictionTime)); - add('Estimated Materials', humanGrams(file.weight)); - row.appendChild(facts); - - b.appendChild(row); -} - -/* ---- Select Printer ------------------------------------------------ */ -export function renderPrinter(root, device, legal, handlers) { - const b = section(root, 'Select Printer'); - const row = el('div', 'model-row'); - row.appendChild(el('div', 'thumb printer-thumb')); - - const pick = el('div', 'picker'); - const sel = el('button', 'select'); - sel.type = 'button'; - sel.textContent = device - ? (device.name || device.deviceName || device.model_name || device.sn) - : 'Click to select printer'; - sel.onclick = () => handlers.pickPrinter && handlers.pickPrinter(); - pick.appendChild(sel); - - if (device && legal && legal.legal === false) { - // sw_GetPrintLegal compares the edited printer preset against the machine - // actually connected; a mismatch is what blocks the send. - const w = el('p', 'warn-line', - `This plate was sliced for ${legal.preset_model || 'another model'}, ` - + 'which does not match the connected printer.'); - pick.appendChild(w); - } - row.appendChild(pick); - b.appendChild(row); -} - -/* ---- Edit Filament ------------------------------------------------- */ -export function renderFilament(root, mapping, taskConfig, handlers) { - const refresh = el('button', 'icon-btn', '⟳'); - refresh.title = 'Re-read the filament mapping'; - refresh.onclick = () => handlers.refreshFilament && handlers.refreshFilament(); - - const b = section(root, 'Edit Filament', refresh); - - const rows = (mapping && mapping.filaments) || []; - if (!rows.length) { - b.appendChild(el('p', 'empty', 'No filament requirements read from this file yet.')); - return; - } - - const types = taskConfig.filament_type || []; - const colors = taskConfig.filament_color || []; - - const grid = el('div', 'fil-grid'); - rows.forEach((f, i) => { - const card = el('div', 'fil-row'); - - const chip = el('span', 'chip'); - chip.style.background = f.color || colors[i] || '#888'; - card.appendChild(chip); - - const meta = el('div', 'fil-meta'); - meta.appendChild(el('span', 'fil-name', `Filament ${i + 1} · ${f.type || types[i] || '—'}`)); - meta.appendChild(el('span', 'fil-sub', `${Number(f.used_g || 0).toFixed(1)} g`)); - card.appendChild(meta); - - card.appendChild(el('span', 'arrow', '→')); - - // Which physical toolhead this filament is assigned to. The popup writes - // this back as print_task_config.extruder_map_table. - const sel = el('select', 'slot'); - for (let t = 0; t < 4; t++) { - const o = el('option', null, `Toolhead ${t + 1}`); - o.value = String(t); - if (Number(f.extruder) === t) o.selected = true; - sel.appendChild(o); - } - sel.onchange = () => handlers.assignSlot && handlers.assignSlot(i, Number(sel.value)); - card.appendChild(sel); - - grid.appendChild(card); - }); - b.appendChild(grid); -} - -/* ---- Print Preferences --------------------------------------------- */ -export function renderPreferences(root, taskConfig, handlers) { - const b = section(root, 'Print Preferences'); - const list = el('div', 'prefs'); - - PRINT_PREFERENCES.forEach(({ key, label }) => { - const row = el('label', 'pref-row'); - row.appendChild(el('span', 'pref-label', label)); - const box = el('input'); - box.type = 'checkbox'; - box.checked = !!taskConfig[key]; - box.onchange = () => handlers.setPreference && handlers.setPreference(key, box.checked); - row.appendChild(box); - list.appendChild(row); - }); - - b.appendChild(list); -} - -/* ---- send bar ------------------------------------------------------- */ -export function renderSend(pct, enabled, label) { - const fill = $('#progress-fill'); - const p = Math.max(0, Math.min(100, Math.round(pct))); - fill.style.width = `${p}%`; - $('#progress-pct').textContent = `${p}%`; - const btn = $('#send'); - btn.disabled = !enabled; - if (label) btn.textContent = label; -} - -/* ---- trace ---------------------------------------------------------- */ -export function makeTrace(pane) { - return (kind, packet) => { - if (!pane || pane.dataset.paused === '1') return; - const line = el('div', `t t-${kind}`); - const cmd = (packet && packet.payload && packet.payload.cmd) - || (packet && packet.header && packet.header.event_id ? 'push' : ''); - line.textContent = `${kind.padEnd(9)} ${cmd}`; - line.title = JSON.stringify(packet); - pane.appendChild(line); - while (pane.childElementCount > 200) pane.removeChild(pane.firstChild); - pane.scrollTop = pane.scrollHeight; - }; -} diff --git a/resources/web/print_processing/js/views/filament/filament-commands.js b/resources/web/print_processing/js/views/filament/filament-commands.js new file mode 100644 index 00000000000..ca7bccf03ca --- /dev/null +++ b/resources/web/print_processing/js/views/filament/filament-commands.js @@ -0,0 +1,40 @@ +/* + * What Edit Filament can ask for. + * + * The mapping is written with G-CODE MACROS, not with `sw_UpdateMachineFilamentInfo`. + * That command's name reads like the write and is not one: it writes ORCA's own + * per-device filament record and never reaches the printer, which is why two panels on + * the Device page sent it and did nothing at all on a real machine, silently, because + * neither awaited its own request. See device_page/js/core/orcasync.js. + * + * The two macros are the shipped bundle's, recovered from `setPrePrintConfiguration`: + * it emits them in one script, so they are joined the same way here. + * + * SET_PRINT_EXTRUDER_MAP CONFIG_EXTRUDER= MAP_EXTRUDER= + * SET_PRINT_USED_EXTRUDERS EXTRUDERS= + * + * Bare `KEY=value`, not quoted: only SET_PRINT_FILAMENT_CONFIG goes through the quoting + * map, and passing these through it would send `CONFIG_EXTRUDER='0'`. + * + * `EXTRUDERS` is the set of TOOLHEADS in use, de-duplicated - two filaments can share + * one head - and not the file's filament indices. On the machine `extruders_used` is one + * flag per toolhead. + */ +'use strict'; + +import { CMD, PRINT_TASK, plainLine } from '../../../../shared/js/protocol.js'; + +export function writeAssignment(bridge, { fileIndex, toolhead, allToolheads }) { + const heads = [...new Set(allToolheads.map((n) => Number(n) || 0))].sort((a, b) => a - b); + const script = [ + plainLine(PRINT_TASK.EXTRUDER_MAP, + { CONFIG_EXTRUDER: fileIndex, MAP_EXTRUDER: Number(toolhead) || 0 }), + plainLine(PRINT_TASK.USED_EXTRUDERS, { EXTRUDERS: heads.join(',') }), + ].join('\n'); + return bridge.request(CMD.SEND_GCODES, { script }); +} + +/** `A.b9R` - the refresh button re-reads the file's filaments, debounced by the bundle. */ +export function refreshMapping(bridge, filename) { + return bridge.request(CMD.GET_FILE_FILAMENT_MAPPING, { filename: filename || '' }); +} diff --git a/resources/web/print_processing/js/views/filament/filament-panel.js b/resources/web/print_processing/js/views/filament/filament-panel.js new file mode 100644 index 00000000000..ad1d875174f --- /dev/null +++ b/resources/web/print_processing/js/views/filament/filament-panel.js @@ -0,0 +1,16 @@ +'use strict'; + +import * as view from './filament-view.js'; + +export default { + id: 'filament', + title: 'Edit Filament', + // ?path=5 drops this section entirely - that is the whole difference between routes. + route: 'print', + // The refresh control lives in the title column, beside the heading, which is where + // A.aiy puts it: `Row[title, IconButton(B.nw, 20)]` inside the 120..180 box. + header: { kind: 'refresh', label: 'Re-read the file’s filaments' }, + reads: ['filaments', 'toolheads', 'assignment'], + mount: view.mount, + update: view.update, +}; diff --git a/resources/web/print_processing/js/views/filament/filament-view.js b/resources/web/print_processing/js/views/filament/filament-view.js new file mode 100644 index 00000000000..12629d4eb83 --- /dev/null +++ b/resources/web/print_processing/js/views/filament/filament-view.js @@ -0,0 +1,130 @@ +/* + * filament-view.js - Edit Filament. + * + * A Wrap of 80 x 100 cards, spacing 12 both ways, one per file filament (`A.aiy` -> + * `A.A0`). Each card is a colour block over a toolhead picker, split flex 3 / a 1px rule + * / flex 4. The block carries the filament's TYPE and its weight in the ink that stays + * legible on it; the picker carries a 28px disc with the chosen toolhead's 1-based + * number, or `!` when nothing is chosen yet. + * + * That last one is the section's only affordance for "you still have to decide", and it + * comes with a warning-coloured card border. Both are the bundle's. + * + * Cards are reconciled by key rather than rebuilt: a card whose menu is open must not be + * replaced under the pointer, and the state stream pushes about once a second. + */ +'use strict'; + +import { el } from '../../../../shared/js/dom.js'; +import { keyedList, text, data } from '../../../../shared/js/render.js'; +import { paintFilament, makeDisc, updateDisc } from '../../widgets/art.js'; +import { openPicker, closePicker, isPickerOpen } from '../../widgets/picker.js'; +import { grams0, inkOn } from '../../widgets/format.js'; +import { matchOf } from '../../core/session.js'; + +/* The bundle's own two strings for the two ways a toolhead can be refused. They are + localisation keys with no English entry in the bundle, so they render as themselves - + which is what the shipped dialog shows too. */ +const TIP_NONE = 'dialog_filament_type_none_tips'; +const TIP_MISMATCH = 'dialog_filament_type_not_match_tips'; + +export function mount(root) { + root.appendChild(el('div', 'fil-grid')); +} + +export function update(root, model, ctx) { + const { filaments, toolheads, assignment } = model; + const grid = root.querySelector('.fil-grid'); + + keyedList(grid, filaments, { + key: (f) => f.key, + create: (f) => buildCard(f), + update: (node, f) => paintCard(node, f, toolheads, assignment, ctx), + }); + + // `B.aya` - an invisible 80x100 box holds the row open when there is nothing to draw, + // so the section does not collapse to its title while the mapping is still arriving. + data(grid, 'empty', filaments.length ? null : '1'); +} + +function buildCard(f) { + const card = el('div', 'fil-card'); + + const block = el('div', 'fil-block'); + block.appendChild(el('div', 'fil-type')); + block.appendChild(el('div', 'fil-used')); + card.appendChild(block); + + card.appendChild(el('div', 'fil-rule')); + + const pick = el('button', 'fil-pick'); + pick.type = 'button'; + pick.setAttribute('aria-haspopup', 'listbox'); + pick.setAttribute('aria-expanded', 'false'); + pick.setAttribute('aria-label', `Toolhead for filament ${f.index + 1}`); + pick.appendChild(makeDisc()); + pick.appendChild(el('span', 'fil-caret')); + card.appendChild(pick); + + return card; +} + +function paintCard(card, f, toolheads, assignment, ctx) { + const block = card.querySelector('.fil-block'); + paintFilament(block, f.colors, f.colorMode); + const ink = inkOn(f.colors[0]); + const type = card.querySelector('.fil-type'); + const used = card.querySelector('.fil-used'); + text(type, f.type); + text(used, grams0(f.used)); + type.style.color = ink; + used.style.color = ink; + + const at = assignment[f.key]; + const head = at == null ? null : toolheads[at]; + const unset = !head; + card.classList.toggle('unset', unset); + + updateDisc(card.querySelector('.disc'), { + colors: head ? head.colors : [], + mode: head ? head.colorMode : 0, + // `s ? ""+(a2+1) : "!"` - the 1-based toolhead number, or the mark for undecided. + label: head ? String(head.index + 1) : '!', + ink: head ? inkOn(head.colors[0]) : '', + unset, + }); + + const pick = card.querySelector('.fil-pick'); + pick.onclick = () => { + if (isPickerOpen(pick)) { closePicker(); return; } + openPicker({ + trigger: pick, + kind: 'head', + within: ctx.dialog(), + value: at, + items: toolheads.map((h) => { + const m = matchOf(f, h); + return { + value: h.index, + // The bundle passes this as the DropdownMenuItem's `enabled` flag. A toolhead + // whose type or nozzle does not match the file filament is not merely + // discouraged - it cannot be chosen at all. + enabled: m.ok, + title: m.ok ? '' : (m.reason === 'none' ? TIP_NONE : TIP_MISMATCH), + build: (node) => { + const d = makeDisc('disc-menu'); + updateDisc(d, { colors: h.colors, mode: h.colorMode, + label: String(h.index + 1), ink: inkOn(h.colors[0]) }); + node.appendChild(d); + const label = el('span', 'menu-type', h.filamentType); + if (!m.ok) label.classList.add('bad'); + node.appendChild(label); + if (h.index === at) node.appendChild(el('span', 'menu-tick')); + if (!m.ok) node.appendChild(el('span', 'menu-warn')); + }, + }; + }), + onPick: (v) => ctx.assign(f, Number(v)), + }); + }; +} diff --git a/resources/web/print_processing/js/views/model-info/model-info-panel.js b/resources/web/print_processing/js/views/model-info/model-info-panel.js new file mode 100644 index 00000000000..f98f78f61f2 --- /dev/null +++ b/resources/web/print_processing/js/views/model-info/model-info-panel.js @@ -0,0 +1,17 @@ +/* + * Model Information - the first section, on both routes. + * + * Sends nothing: it is the one panel that only reads. + */ +'use strict'; + +import * as view from './model-info-view.js'; + +export default { + id: 'model-info', + title: 'Model Information', + route: 'both', + reads: ['mapping', 'file'], + mount: view.mount, + update: view.update, +}; diff --git a/resources/web/print_processing/js/views/model-info/model-info-view.js b/resources/web/print_processing/js/views/model-info/model-info-view.js new file mode 100644 index 00000000000..d7849f1cd2c --- /dev/null +++ b/resources/web/print_processing/js/views/model-info/model-info-view.js @@ -0,0 +1,68 @@ +/* + * model-info-view.js - the plate, and what is known about it. + * + * `A.J9` has two layouts and picks between them on whether the file has `partitions`: + * + * aFq no partitions: a 100x100 thumbnail and three text facts + * aFv partitions: the same thumbnail, then a 90-tall plate row - or a dropdown of + * them - carrying name, time, weight, nozzle and filament chips + * + * Only the first is drawn here, and deliberately: `sw_GetFileFilamentMapping` as Orca + * implements it has no `partitions` key at all, so the second layout is unreachable + * through this host. It is recovered and specified in the mockup; building a branch that + * cannot be entered would be building it blind. + */ +'use strict'; + +import { el } from '../../../../shared/js/dom.js'; +import { text } from '../../../../shared/js/render.js'; +import { duration, grams2 } from '../../widgets/format.js'; + +export function mount(root) { + const row = el('div', 'mi'); + + // `thumbnails[0].url` - a data: PNG of the sliced plate, in the reply all along. The + // shipped popup draws a grey `gcodeCover.png` here whether or not one arrived. + const img = el('img', 'mi-thumb'); + img.alt = ''; + img.width = 100; + img.height = 100; + row.appendChild(img); + row.appendChild(el('div', 'gap-20')); + + const facts = el('div', 'mi-facts'); + facts.appendChild(el('div', 'sp-13')); + facts.appendChild(el('div', 'mi-fact', '')); // Filename + facts.appendChild(el('div', 'sp-10')); + facts.appendChild(el('div', 'mi-fact', '')); // Estimated Time + facts.appendChild(el('div', 'sp-10')); + facts.appendChild(el('div', 'mi-fact', '')); // Estimated Materials + row.appendChild(facts); + + root.appendChild(row); +} + +export function update(root, { mapping, file }) { + const img = root.querySelector('.mi-thumb'); + const thumb = mapping && Array.isArray(mapping.thumbnails) && mapping.thumbnails[0]; + const url = thumb && thumb.url; + if (url) { + if (img.getAttribute('src') !== url) img.src = url; + img.classList.remove('empty'); + } else { + // No thumbnail is a state, not an error: a plate sliced without them, or a host that + // has not answered yet. The placeholder is CSS, so there is no broken-image icon. + if (img.hasAttribute('src')) img.removeAttribute('src'); + img.classList.add('empty'); + } + + const facts = root.querySelectorAll('.mi-fact'); + const name = (mapping && mapping.filename) + || (file && file.filename) + || 'N/A'; + text(facts[0], `Filename: ${name}`); + text(facts[1], `Estimated Time: ${mapping ? duration(mapping.estimated_time) : 'N/A'}`); + // toStringAsFixed(2) - "31.40 g", not "31.4 g". + text(facts[2], `Estimated Materials: ${ + mapping ? grams2(mapping.filament_weight_total) : 'N/A'}`); +} diff --git a/resources/web/print_processing/js/views/nozzle/nozzle-panel.js b/resources/web/print_processing/js/views/nozzle/nozzle-panel.js new file mode 100644 index 00000000000..c5e42777ca7 --- /dev/null +++ b/resources/web/print_processing/js/views/nozzle/nozzle-panel.js @@ -0,0 +1,14 @@ +'use strict'; + +import * as view from './nozzle-view.js'; + +export default { + id: 'nozzle', + title: null, + route: 'print', + // No
    and no title column: A.R5 is a bare banner in the same column. + bare: true, + reads: ['nozzleMismatch'], + mount: view.mount, + update: view.update, +}; diff --git a/resources/web/print_processing/js/views/nozzle/nozzle-view.js b/resources/web/print_processing/js/views/nozzle/nozzle-view.js new file mode 100644 index 00000000000..a40b5233fa6 --- /dev/null +++ b/resources/web/print_processing/js/views/nozzle/nozzle-view.js @@ -0,0 +1,26 @@ +/* + * nozzle-view.js - the fifth section, and the one no screenshot ever showed. + * + * `A.R5`: a warning banner under Print Preferences, drawn only when the plate's set of + * nozzle diameters is not contained in the machine's. It is `bare` - no title column, no + * card - and it carries a localisation key the bundle has no English entry for, so it + * renders as `nozzle_mismatch_tip` in the shipped dialog too. + * + * The comparison (`A.c8l`) is deliberately silent at both edges: an empty machine list + * means no banner, so a dialog that has not heard from the printer yet does not accuse + * it of anything. + */ +'use strict'; + +import { el } from '../../../../shared/js/dom.js'; + +export function mount(root) { + const b = el('div', 'nozzle-warn'); + b.appendChild(el('span', 'nozzle-icon')); + b.appendChild(el('span', 'nozzle-text', 'nozzle_mismatch_tip')); + root.appendChild(b); +} + +export function update(root, { nozzleMismatch }) { + root.hidden = !nozzleMismatch; +} diff --git a/resources/web/print_processing/js/views/preferences/preferences-commands.js b/resources/web/print_processing/js/views/preferences/preferences-commands.js new file mode 100644 index 00000000000..57eaf3b1d33 --- /dev/null +++ b/resources/web/print_processing/js/views/preferences/preferences-commands.js @@ -0,0 +1,22 @@ +/* + * What Print Preferences can ask for. + * + * `SET_PRINT_PREFERENCES`, one bare `KEY=value` per change, upper-cased from the + * `print_task_config` field name - and Auto Leveling is the exception that makes the + * translation worth having a table for: the machine REPORTS it as `auto_bed_leveling` + * and ACCEPTS it as `BED_LEVEL`. Sending `AUTO_BED_LEVELING=` is an argument the macro + * does not have, which a G-code macro answers `ok` to and then ignores. That is what + * `arg` in PRINT_PREFERENCES is for; `key` reads, `arg` writes. + * + * This is also not `sw_UpdateMachineFilamentInfo`, for the same reason as the filament + * mapping: that command writes Orca's record and never reaches the printer. + */ +'use strict'; + +import { CMD, PRINT_PREFERENCES, prefsLine } from '../../../../shared/js/protocol.js'; + +export function writePreference(bridge, key, value) { + const pref = PRINT_PREFERENCES.find((p) => p.key === key); + if (!pref) return Promise.reject(new Error(`unknown preference ${key}`)); + return bridge.request(CMD.SEND_GCODES, { script: prefsLine({ [pref.arg]: value }) }); +} diff --git a/resources/web/print_processing/js/views/preferences/preferences-panel.js b/resources/web/print_processing/js/views/preferences/preferences-panel.js new file mode 100644 index 00000000000..7929c6ef72c --- /dev/null +++ b/resources/web/print_processing/js/views/preferences/preferences-panel.js @@ -0,0 +1,12 @@ +'use strict'; + +import * as view from './preferences-view.js'; + +export default { + id: 'preferences', + title: 'Print Preferences', + route: 'print', + reads: ['prefs'], + mount: view.mount, + update: view.update, +}; diff --git a/resources/web/print_processing/js/views/preferences/preferences-view.js b/resources/web/print_processing/js/views/preferences/preferences-view.js new file mode 100644 index 00000000000..28b1666b55f --- /dev/null +++ b/resources/web/print_processing/js/views/preferences/preferences-view.js @@ -0,0 +1,57 @@ +/* + * preferences-view.js - Print Preferences. + * + * Three rows in a Wrap with runSpacing 12 and spacing 170 (`A.Ja`), each row a + * ConstrainedBox of 152..220 by 20 (`B.VS`). At this dialog's width the 170 gutter is + * what puts them one per line; the numbers are kept rather than replaced with a column, + * because the wrap is what decides the layout at any other width. + * + * The toggle is an 18px ROUND CHECKBOX, not a switch: `A.a3(..., 18, ..., 18)` with + * radius 9, a 2px border, and `B.fx` green with a 14px white check when on. + * + * Only the first carries a help control, and it opens a real dialog rather than a + * tooltip - `A.rB(...)` with an OK button. + */ +'use strict'; + +import { el } from '../../../../shared/js/dom.js'; +import { attr } from '../../../../shared/js/render.js'; +import { PRINT_PREFERENCES } from '../../../../shared/js/protocol.js'; + +/* The bundle's own help text, verbatim, for the one preference that has any. */ +export const FLOW_HELP = { + title: 'Extrusion Flow Calibration', + body: 'When enabled, the printer will automatically calibrate flow compensation ' + + 'before printing. Recommended after each filament change.', +}; + +export function mount(root, ctx) { + const wrap = el('div', 'prefs'); + PRINT_PREFERENCES.forEach(({ key, label }) => { + const row = el('button', 'pref'); + row.type = 'button'; + row.dataset.key = key; + row.setAttribute('role', 'switch'); + row.appendChild(el('span', 'pref-label', label)); + if (key === PRINT_PREFERENCES[0].key) { + const help = el('span', 'pref-help'); + help.setAttribute('role', 'button'); + help.tabIndex = 0; + help.title = FLOW_HELP.title; + const ask = (e) => { e.stopPropagation(); ctx.explain(FLOW_HELP); }; + help.onclick = ask; + help.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') ask(e); }; + row.appendChild(help); + } + row.appendChild(el('span', 'pref-box')); + row.onclick = () => ctx.togglePreference(key, !ctx.prefValue(key)); + wrap.appendChild(row); + }); + root.appendChild(wrap); +} + +export function update(root, { prefs }) { + root.querySelectorAll('.pref').forEach((row) => { + attr(row, 'aria-checked', String(!!prefs[row.dataset.key])); + }); +} diff --git a/resources/web/print_processing/js/views/printer/printer-commands.js b/resources/web/print_processing/js/views/printer/printer-commands.js new file mode 100644 index 00000000000..fc17ec3b4ba --- /dev/null +++ b/resources/web/print_processing/js/views/printer/printer-commands.js @@ -0,0 +1,16 @@ +/* + * What Select Printer can ask for. + * + * Choosing a saved device is the PAGE's own bookkeeping - which record the rest of the + * dialog reads - and reaches no host. Adding one is not: `sw_AddDevice` opens Orca's own + * dialog, the same way the Device page's rail asks for the login dialog rather than + * offering a form. There is nothing to add a printer with here, and inventing one would + * be inventing a pairing flow. + */ +'use strict'; + +import { CMD } from '../../../../shared/js/protocol.js'; + +export function addDevice(bridge) { + return bridge.request(CMD.ADD_DEVICE, {}); +} diff --git a/resources/web/print_processing/js/views/printer/printer-panel.js b/resources/web/print_processing/js/views/printer/printer-panel.js new file mode 100644 index 00000000000..9a42b16de94 --- /dev/null +++ b/resources/web/print_processing/js/views/printer/printer-panel.js @@ -0,0 +1,12 @@ +'use strict'; + +import * as view from './printer-view.js'; + +export default { + id: 'printer', + title: 'Select Printer', + route: 'both', + reads: ['devices', 'device', 'legal'], + mount: view.mount, + update: view.update, +}; diff --git a/resources/web/print_processing/js/views/printer/printer-view.js b/resources/web/print_processing/js/views/printer/printer-view.js new file mode 100644 index 00000000000..3829d6c0a32 --- /dev/null +++ b/resources/web/print_processing/js/views/printer/printer-view.js @@ -0,0 +1,151 @@ +/* + * printer-view.js - Select Printer. + * + * `A.Vn` / `A.bbC`: a 100x100 cover, then a 300x50 dropdown over the saved device list + * with a synthetic "add device" row appended. Each row carries a 24px cover, the name, a + * "Lan Mode" label when the record says so, and a green check on the connected one. + * + * The list itself is `sw_GetLocalDevices` - Orca's saved records, not the printer's + * opinion of itself - and the connected one is whichever record says `connected`. + */ +'use strict'; + +import { el } from '../../../../shared/js/dom.js'; +import { text } from '../../../../shared/js/render.js'; +import { openPicker, closePicker, isPickerOpen } from '../../widgets/picker.js'; +import { DEVICE } from '../../../../shared/js/protocol.js'; + +const ADD_DEVICE = '__add_device__'; + +const deviceId = (d) => String(d[DEVICE.ID] || d[DEVICE.SN] || d[DEVICE.IP] + || d[DEVICE.NAME] || ''); +const deviceName = (d) => d[DEVICE.NAME] || d.name || d[DEVICE.MODEL] || deviceId(d); +const isLan = (d) => d[DEVICE.LINK_MODE] === 'lan' || d.protocol === 1; + +/** + * The line under a device's name. + * + * **This is a deliberate departure from the shipped dialog**, which draws a device as a + * cover, a name, a "Lan Mode" label and a check - and nothing else. Two saved records + * with the same name are then two identical rows, which is not a hypothetical: Orca's + * config routinely holds a stale record beside a live one, and the machine this was + * built against has exactly that. Picking the wrong one connects to nothing and the + * dialog can only say the printer did not answer. + * + * The address is on the record already - `connection.js` refuses to connect without it - + * so showing it costs a line that the row had room for. + * + * The serial only appears when it is the thing that TELLS TWO ROWS APART. On the machine + * above both records carry the same name AND the same address and differ only by SN + * (`811002511261022618B3` against a `moonraker` placeholder), so the address alone would + * have left the ambiguity exactly where it was. Where a name and an address are already + * unique the serial is noise, and it is not drawn. + */ +export function deviceMeta(d, all) { + const bits = []; + if (isLan(d)) bits.push('Lan Mode'); + const ip = d[DEVICE.IP]; + if (ip) bits.push(ip); + /* + * Compared by IDENTITY KEY, not by object identity. The selected device is usually a + * DIFFERENT OBJECT from its entry in the list - `sw_GetConnectedMachine` answers with + * its own copy - so `o !== d` counted a device as its own twin and printed the serial + * on a machine that had nothing to be told apart from. + */ + const twin = (all || []).some( + (o) => deviceId(o) !== deviceId(d) + && deviceName(o) === deviceName(d) && o[DEVICE.IP] === ip); + if (twin && d[DEVICE.SN]) bits.push(d[DEVICE.SN]); + return bits.join(' · '); +} + +export function mount(root, ctx) { + const row = el('div', 'sp'); + row.appendChild(el('div', 'sp-cover')); + row.appendChild(el('div', 'gap-20')); + + const pick = el('button', 'picker'); + pick.type = 'button'; + pick.setAttribute('aria-haspopup', 'listbox'); + pick.setAttribute('aria-expanded', 'false'); + pick.appendChild(el('span', 'picker-body')); + pick.appendChild(el('span', 'picker-caret')); + row.appendChild(pick); + root.appendChild(row); + + // The legality line. `sw_GetPrintLegal` compares the EDITED printer preset against the + // machine that is actually connected; a mismatch is the one thing on this surface that + // says the plate cannot go where it is being sent. + root.appendChild(el('p', 'legal-line', '')); + + pick.onclick = () => { + if (isPickerOpen(pick)) { closePicker(); return; } + const rows = ctx.get().devices.slice(); + const items = rows.map((d) => ({ + value: deviceId(d), + build: (node) => { + node.appendChild(el('span', 'menu-cover')); + const t = el('span', 'dev-text'); + t.appendChild(el('span', 'dev-name', deviceName(d))); + const meta = deviceMeta(d, rows); + if (meta) { + const m = el('span', 'dev-meta', meta); + m.title = meta; + t.appendChild(m); + } + node.appendChild(t); + // No spacer: `.dev-text` is the flexible child and pushes the tick right on its + // own. Keeping the old `menu-grow` beside it left TWO `flex: 1` children sharing + // the row, so the address was ellipsised to "Lan Mode · 19..." with half the + // menu standing empty next to it. + if (d[DEVICE.CONNECTED]) node.appendChild(el('span', 'menu-tick')); + }, + })); + // The bundle appends this row itself, with its own cover art, and treats picking it + // as "open Orca's add-device dialog" rather than as choosing a machine. + items.push({ + value: ADD_DEVICE, + build: (node) => { + node.appendChild(el('span', 'menu-cover')); + node.appendChild(el('span', 'menu-name', 'add device')); + }, + }); + openPicker({ + trigger: pick, kind: 'printer', items, + value: ctx.get().device ? deviceId(ctx.get().device) : null, + within: ctx.dialog(), + onPick: (v) => (v === ADD_DEVICE ? ctx.addDevice() : ctx.chooseDevice(v)), + }); + }; +} + +export function update(root, { device, legal, devices }) { + const body = root.querySelector('.picker-body'); + body.innerHTML = ''; + if (device) { + body.appendChild(el('span', 'menu-cover')); + const t = el('span', 'dev-text'); + t.appendChild(el('span', 'dev-name', deviceName(device))); + // The closed picker says the same thing the open one does. Choosing between two + // rows only to be shown a name that matches both of them is half an answer. + const meta = deviceMeta(device, devices); + if (meta) { + const m = el('span', 'dev-meta', meta); + m.title = meta; + t.appendChild(m); + } + body.appendChild(t); + body.classList.remove('placeholder'); + } else { + body.appendChild(el('span', 'picker-hint', 'Click to select printer')); + body.classList.add('placeholder'); + } + + const line = root.querySelector('.legal-line'); + const bad = legal && legal.legal === false; + line.hidden = !bad; + text(line, bad + ? `This plate was sliced for ${legal.preset_model || 'another model'}, ` + + 'which is not the connected printer.' + : ''); +} diff --git a/resources/web/print_processing/js/widgets/art.js b/resources/web/print_processing/js/widgets/art.js new file mode 100644 index 00000000000..8d6bf5f616c --- /dev/null +++ b/resources/web/print_processing/js/widgets/art.js @@ -0,0 +1,73 @@ +/* + * art.js - the two things this dialog paints a filament colour into. + * + * A filament's colour is not one colour. `A.abX` (the card's block) and `A.a6K` (the + * round target disc) both switch on `filamentColorMulti.mode`: + * + * 0 colours nothing + * 1 colour solid - unless its alpha is ZERO, which is an absence and draws a hatch + * many, mode 0 equal vertical bands, one per colour + * many, mode 1 a left-to-right linear gradient + * many, mode 2 the first colour, solid (disc only) + * + * The alpha-zero case is not a curiosity: multiACE wipes a stock-feeder head's identity + * with `FILAMENT_COLOR_RGBA=00000000` when the machine enters head mode, and the spool is + * still physically in the head while it happens. Drawing that black would be a lie about + * the machine; the hatch says "no colour recorded". + */ +'use strict'; + +import { el } from '../../../shared/js/dom.js'; + +/** + * Paint `node` with a filament's colours. + * + * Bands are real child elements rather than a `repeating-linear-gradient` because the + * bundle builds them as flex children, and because a band has to be able to carry a + * border when the colour is near-white - see the disc below. + */ +export function paintFilament(node, colors, mode = 0) { + node.classList.remove('sw-hatch', 'sw-banded'); + node.style.background = ''; + node.style.backgroundImage = ''; + Array.from(node.querySelectorAll('.sw-band')).forEach((n) => n.remove()); + + const list = (colors || []).filter(Boolean); + if (!list.length) { node.classList.add('sw-hatch'); return; } + if (list.length === 1) { node.style.background = list[0]; return; } + + if (Number(mode) === 1) { + node.style.backgroundImage = `linear-gradient(90deg, ${list.join(', ')})`; + return; + } + if (Number(mode) === 2) { node.style.background = list[0]; return; } + + node.classList.add('sw-banded'); + list.forEach((c) => { + const b = el('span', 'sw-band'); + b.style.background = c; + node.appendChild(b); + }); +} + +/** + * The 28px target disc, with the toolhead's 1-based number on it. + * + * `!` rather than a number is the bundle's own mark for an unassigned filament + * (`s ? ""+(a2+1) : "!"`), and it is drawn in the warning colour along with the card's + * border. Nothing else on this surface says "you have not chosen yet". + */ +export function makeDisc(cls = '') { + const d = el('div', `disc ${cls}`.trim()); + d.appendChild(el('span', 'disc-n')); + return d; +} + +export function updateDisc(disc, { colors, mode, label, ink, unset }) { + paintFilament(disc, colors, mode); + disc.classList.toggle('unset', !!unset); + const n = disc.querySelector('.disc-n'); + const want = label == null ? '' : String(label); + if (n.textContent !== want) n.textContent = want; + n.style.color = ink || ''; +} diff --git a/resources/web/print_processing/js/widgets/format.js b/resources/web/print_processing/js/widgets/format.js new file mode 100644 index 00000000000..fa3da7160a1 --- /dev/null +++ b/resources/web/print_processing/js/widgets/format.js @@ -0,0 +1,72 @@ +/* + * format.js - the dialog's own number and colour formatting. + * + * Each of these is the shipped popup's, not a preference. Where they differ from the + * obvious choice the bundle is quoted, because "31.4 g" and "31.40 g" are the kind of + * difference nobody notices until the two surfaces are side by side. + */ +'use strict'; + +import { isDarkColor } from '../../../shared/js/protocol.js'; + +// Re-exported so a view imports its formatting from one place; the implementations are +// shared, not this surface's. +export { cssColor, isDarkColor } from '../../../shared/js/protocol.js'; + +/** + * `A.aZU(seconds, B.oW)` - the popup's own duration format. + * + * Under an hour it drops the hour part entirely; the bundle's plate rows show `33m`, + * not `0h 33m`. + */ +export function duration(sec) { + const s = Number(sec); + if (!Number.isFinite(s) || s <= 0) return 'N/A'; + const h = Math.floor(s / 3600); + const m = Math.round((s % 3600) / 60); + return h ? `${h}h ${m}m` : `${m}m`; +} + +/** Estimated Materials is `toStringAsFixed(2) + " g"`. Two decimals, not one. */ +export function grams2(g) { + const n = Number(g); + return Number.isFinite(n) && n > 0 ? `${n.toFixed(2)} g` : 'N/A'; +} + +/** A filament card's own weight is `toStringAsFixed(0) + "g"`. No space, no decimals. */ +export function grams0(g) { + const n = Number(g); + return `${Number.isFinite(n) ? n.toFixed(0) : 0}g`; +} + +/** The upload percentage is `toStringAsFixed(0) + "%"`. */ +export const percent0 = (f) => `${Math.round((Number(f) || 0) * 100)}%`; + +export const megabytes = (b) => `${(Number(b) / 1048576).toFixed(1)} MB`; + +/* + * Colour is NOT implemented here. `cssColor` and `isDarkColor` are in + * shared/js/protocol.js, where they already were: the alpha-zero rule - multiACE wipes a + * head's identity with `00000000` while the spool is still physically in it, so alpha + * zero is an ABSENCE and not black - is the sort of thing that must have exactly one + * implementation. A second copy in this file was one spelling away from drawing four + * black spools again. + * + * `inkOn` below is the only colour function this surface adds, because the filament card + * writes text ON the colour block and has to choose an ink for it. It is `isDarkColor`'s + * question asked the other way round, and it delegates. + */ +export function inkOn(color) { + return isDarkColor(color) ? '#ffffff' : '#1b1b1b'; +} + +/** + * Nozzle diameters compare as STRINGS in the bundle - `J.p(a) === J.p(b)` - and the two + * sides spell them differently: the file says "0.4", the extruder object says 0.4. One + * decimal place, as a string, is the spelling both can be put in. + */ +export function nozzleStr(v) { + if (v == null || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n.toFixed(1) : String(v); +} diff --git a/resources/web/print_processing/js/widgets/picker.js b/resources/web/print_processing/js/widgets/picker.js new file mode 100644 index 00000000000..2fb0ca5be2c --- /dev/null +++ b/resources/web/print_processing/js/widgets/picker.js @@ -0,0 +1,121 @@ +/* + * picker.js - the dropdown all three of this dialog's value pickers use. + * + * Named a picker rather than a menu on purpose. The Device page has an `openMenu` in + * core/overlay.js and it is a different widget: a list of ACTIONS positioned against the + * window, every row clickable. This one is a list of VALUES with a per-item enabled flag, + * three geometry presets and an anchor relative to the dialog. Two rectangles that do + * different jobs; giving them one name is how the wrong one gets reached for. + * + * DropdownButton2, as the bundle configures it: an item height and padding + * (MenuItemStyleData) and a surface with its own width, maximum height, elevation, + * radius and offset (DropdownStyleData). The three call sites differ only in those + * numbers, so there is one implementation and three sets of them: + * + * menu item h item pad width maxH elev offset radius + * printer 50 LTRB(16,0,16,0) 300 - 0 (0,0) 0 + * toolhead 48 LTRB(16,0,16,0) 200 300 8 (-50,0) 8 + * plate 80 LTRB(8,6,8,6) button 480 4 (0,-2) 0/0/4/4 + * + * from B.aqV / B.aqU / B.aqW and each `bEo(...)` call. The geometry lives in CSS, keyed + * by the `kind` below; this module owns opening, placing, keyboard and the one rule that + * matters - **a disabled item cannot be chosen**, which is the toolhead menu's whole + * behaviour and not a decoration. + */ +'use strict'; + +import { el } from '../../../shared/js/dom.js'; + +const KINDS = { + printer: { dx: 0, dy: 0, matchWidth: false }, + head: { dx: -50, dy: 0, matchWidth: false }, + plate: { dx: 0, dy: -2, matchWidth: true }, +}; + +let open = null; // { menu, trigger, onClose } + +/** Close whatever is open. Safe to call when nothing is. */ +export function closePicker() { + if (!open) return; + const { menu, trigger, onClose } = open; + open = null; + menu.remove(); + if (trigger && trigger.isConnected) trigger.setAttribute('aria-expanded', 'false'); + if (onClose) onClose(); +} + +export const isPickerOpen = (trigger) => !!open && open.trigger === trigger; + +/** + * Open a menu under `trigger`. + * + * `items` are `{ value, enabled, title, build(node) }`. `enabled` defaults to true; a + * false one is rendered, kept in the tab order as `aria-disabled`, and refuses to fire + * `onPick` - the bundle passes exactly this flag to the DropdownMenuItem and the item + * is unselectable, which is what stops a filament being assigned to a toolhead that + * cannot print it. + */ +export function openPicker({ trigger, kind, items, value, onPick, onClose, within }) { + closePicker(); + const spec = KINDS[kind] || KINDS.printer; + const menu = el('div', `menu menu-${kind}`); + menu.setAttribute('role', 'listbox'); + + items.forEach((it) => { + const row = el('button', 'menu-item'); + row.type = 'button'; + row.setAttribute('role', 'option'); + const enabled = it.enabled !== false; + row.setAttribute('aria-selected', String(it.value === value)); + if (!enabled) row.setAttribute('aria-disabled', 'true'); + if (it.title) row.title = it.title; + it.build(row); + row.onclick = () => { + if (!enabled) return; // the flag, enforced + closePicker(); + if (onPick) onPick(it.value); + }; + menu.appendChild(row); + }); + + const host = within || document.body; + host.appendChild(menu); + + const hb = host.getBoundingClientRect(); + const tb = trigger.getBoundingClientRect(); + if (spec.matchWidth) menu.style.width = `${tb.width}px`; + const mb = menu.getBoundingClientRect(); + + let left = tb.left - hb.left + spec.dx; + let top = tb.bottom - hb.top + spec.dy; + // A real menu is constrained to the screen; this one to the dialog, which is the same + // rule and the reason a menu anchored to the last filament card does not vanish. + left = Math.max(4, Math.min(left, hb.width - mb.width - 4)); + if (top + mb.height > hb.height - 4) { + top = Math.max(4, tb.top - hb.top - mb.height - spec.dy); + } + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + trigger.setAttribute('aria-expanded', 'true'); + open = { menu, trigger, onClose }; + + const sel = menu.querySelector('[aria-selected="true"]:not([aria-disabled])') + || menu.querySelector('.menu-item:not([aria-disabled])'); + if (sel) sel.focus(); + return menu; +} + +/* One listener for the lot: a click outside closes, Escape closes. Registered once at + module scope rather than per-open, because a listener added on open and removed on + close is a listener leaked whenever something else closes the menu first. */ +document.addEventListener('pointerdown', (e) => { + if (!open) return; + if (open.menu.contains(e.target) || open.trigger.contains(e.target)) return; + closePicker(); +}, true); + +document.addEventListener('keydown', (e) => { + if (!open) return; + if (e.key === 'Escape') { const t = open.trigger; closePicker(); t.focus(); } +}); diff --git a/resources/web/print_processing/mockups/README.md b/resources/web/print_processing/mockups/README.md new file mode 100644 index 00000000000..6a63ac0bf52 --- /dev/null +++ b/resources/web/print_processing/mockups/README.md @@ -0,0 +1,80 @@ +# Print dialog — three designs + +Three interactive answers to the same question, at the size the host actually opens +(714 × 750), running on the **real** `sw_GetFileFilamentMapping` reply shape and the +**corrected** send sequence. Open the chooser: + +```bash +python3 resources/web/shared/tests/run_webkit.py \ + --size 1500x980 --watch \ + --page web/print_processing/mockups/index.html +``` + +Or one at a time, in the dialog's own size: + +```bash +python3 resources/web/shared/tests/run_webkit.py --size 714x750 --watch \ + --page 'web/print_processing/mockups/option-b.html?scenario=mismatch' +``` + +`build/resources` is a symlink to `resources/`, so these need no build — edit and reload. + +## The three + +| | | The bet | +|---|---|---| +| **A** | [Faithful](option-a.html) | The shipped four cards, in the shipped order, carrying the fields the host was already returning. Familiarity is worth more than a better layout for a step most people click through. | +| **B** | [Match sheet](option-b.html) | The mapping *is* the page: file filaments on the left, what is loaded on the right, a wire between them. Showing the destination beats the four-card layout. | +| **C** | [Preflight](option-c.html) | A run of checks that resolve themselves — one line each when fine, open where a decision is needed. Most sends want one glance; the ones that do not want the problem in front of them. | + +## Switches + +Both are query parameters, on any of the four pages; the chooser drives all three frames +at once so they can be compared in the same state. + +| | | +|---|---| +| `?scenario=` | `ready` · `mismatch` · `wrongmodel` · `noprinter` | +| `?mode=` | `print` (Orca's `?path=4`) · `upload` (`?path=5`) | + +`mismatch` is the one worth looking at: toolhead 3 is empty, no PETG is loaded, and +toolhead 4 has a 0.2 mm nozzle where the file was sliced for 0.4. The three designs +differ most in what they do about it. + +## What they have in common + +Everything except the design, deliberately — so a comparison is between designs and not +between fixtures. + +- **[`fixture.js`](fixture.js)** is the `sw_GetFileFilamentMapping` reply as + `SSWCP.cpp:3039` builds it: parallel arrays, the plate thumbnail, per-filament grams + and length, and the nozzle diameters the file was sliced for. The shipped + reconstruction reads `mapping.filaments[i]`, which the host has never returned — see + [04-requirements](../../../../docs/u1-webui/03-print-processing/04-requirements.md). +- **[`shell.js`](shell.js)** holds the send: `sw_GetFileStream` → `fetch` → a multipart + POST with a real byte count → `sw_StartLocalPrint { type, path }` → the three-command + close protocol. The progress bar moves on bytes, not on a timer. +- **[`mockup.css`](mockup.css)** is the dialog frame and the primitives more than one + design draws. + +These are **mockups**, not the page: no bridge, no printer, no `window.wx`. Each marks +itself in the corner and hands a `--drive` script `window.__mockup`. + +## Checking one + +```bash +python3 resources/web/shared/tests/run_webkit.py --size 714x750 \ + --page 'web/print_processing/mockups/option-a.html?scenario=mismatch' \ + --drive resources/web/shared/tests/drive/print-mockups.js +``` + +Dumps the tree, then asserts the handful of things that would be quietly wrong: the +thumbnail is visible without a click and decoded, there is a way to choose a destination +per filament, Send agrees with the scenario, and an edit reaches the model *and* +repaints. It found two real faults that a screenshot did not — a filament name column +squeezed to 22 px, and a lane that had fallen into the 54 px wire gutter. + +## What is deliberately not here + +No printer picker, no ACE bays, no live state. The mockups answer *what shape should this +dialog be*; the plumbing is settled and written down. diff --git a/resources/web/print_processing/mockups/fixture.js b/resources/web/print_processing/mockups/fixture.js new file mode 100644 index 00000000000..0f15ab02169 --- /dev/null +++ b/resources/web/print_processing/mockups/fixture.js @@ -0,0 +1,196 @@ +/* + * fixture.js - the data all three mockups run on. + * + * The mapping object is the REAL `sw_GetFileFilamentMapping` reply shape, read off + * SSWCP.cpp:3039 - parallel arrays, not a `filaments[]` array of objects. The + * reconstruction's own mock invented the latter, which is why the popup renders four + * rows against the simulator and none against Orca. Building the mockups on the + * handler's shape is half the point of having them. + * + * The heads are `state.filaments()`' shape, from the shared model the Device page reads. + */ +'use strict'; + +/* A plate render, standing in for `thumbnails[0].url`. The real one is a + `data:image/png;base64,` PNG that Orca embeds in the same reply. */ +function plateThumb(colors) { + const [a, b, c, d] = colors; + const svg = ` + + + + + + ${Array.from({ length: 9 }, (_, i) => ``).join('')} + ${Array.from({ length: 5 }, (_, i) => ``).join('')} + + + + + + + + + + + `; + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); +} + +const COLORS = ['#D8452F', '#2F6FE0', '#2E9E5B', '#E9A21B']; + +/** The `sw_GetFileFilamentMapping` reply, verbatim in shape. */ +export const MAPPING = { + filename: 'benchy_4colour.gcode', + filepath: '/tmp/SnapmakerOrca/plate_1.gcode', + machine_model: 'Snapmaker U1', + estimated_time: 4412, + filament_type: ['PLA', 'PLA', 'PETG', 'PLA'], + filament_color_rgba: COLORS, + filament_color: [14173487, 3108320, 3055707, 15311387], + filament_color_multi: [null, null, null, + { mode: 'gradient', colors: ['#E9A21B', '#E9601B'] }], + filament_weight: [12.4, 9.1, 6.7, 3.2], + filament_weight_total: 31.4, + filament_used_mm: [4123, 3027, 2229, 1064], + filament_used_m: [4.1, 3.0, 2.2, 1.1], + nozzle_diameters: ['0.4', '0.4', '0.4', '0.4'], + nozzle_info: ['0.4'], + filament_extruder_map: { 0: '0', 1: '1', 2: '2', 3: '3' }, + thumbnails: [{ url: plateThumb(COLORS), width: 512, height: 512 }], +}; + +/** `sw_GetActiveFile` / the preceding native dialog's answers. */ +export const JOB = { + displayName: 'benchy_4colour.gcode', + sizeBytes: 12_684_221, + postAction: 'StartPrint', // None -> the popup opens in upload-only mode + switchToDeviceTab: true, +}; + +/* + * What is actually in the printer - `state.filaments()`' shape. Three scenarios, + * because a print dialog is only interesting when the plate and the machine disagree. + */ +const HEAD = (i, o) => Object.assign({ + index: i, type: null, subType: null, vendor: null, color: null, + loaded: false, tag: null, nozzle: '0.4', feed: null, +}, o); + +export const SCENARIOS = { + ready: { + label: 'Everything matches', + note: 'Four heads loaded, every file filament has a home, nozzles agree.', + device: { name: 'U1 (workshop)', model: 'Snapmaker U1', sn: '811002511261022618B3', + connected: true }, + legal: { legal: true, preset_model: 'Snapmaker U1' }, + heads: [ + HEAD(0, { type: 'PLA', subType: 'Marble', vendor: 'Jayo', color: '#D8452F', + loaded: true, tag: { vendor: 'Jayo', type: 'PLA', subType: 'Marble' } }), + HEAD(1, { type: 'PLA', vendor: 'Forshape', color: '#2F6FE0', loaded: true }), + HEAD(2, { type: 'PETG', vendor: 'Polymaker', color: '#2E9E5B', loaded: true }), + HEAD(3, { type: 'PLA', subType: 'Basic', vendor: 'Kingroon', color: '#E9A21B', + loaded: true }), + ], + }, + + mismatch: { + label: 'The plate and the machine disagree', + note: 'Head 3 is empty, no PETG is loaded, and head 4 has a 0.2 nozzle where the ' + + 'file wants 0.4.', + device: { name: 'U1 (workshop)', model: 'Snapmaker U1', sn: '811002511261022618B3', + connected: true }, + legal: { legal: true, preset_model: 'Snapmaker U1' }, + heads: [ + HEAD(0, { type: 'PLA', subType: 'Marble', vendor: 'Jayo', color: '#D8452F', + loaded: true, tag: { vendor: 'Jayo', type: 'PLA', subType: 'Marble' } }), + HEAD(1, { type: 'PLA', vendor: 'Forshape', color: '#2F6FE0', loaded: true }), + HEAD(2, {}), + HEAD(3, { type: 'PLA', subType: 'Basic', vendor: 'Kingroon', color: '#E9A21B', + loaded: true, nozzle: '0.2' }), + ], + }, + + wrongmodel: { + label: 'Sliced for another printer', + note: 'sw_GetPrintLegal says the edited preset is not the connected machine. ' + + 'Nothing currently decides what that should do.', + device: { name: 'U1 (workshop)', model: 'Snapmaker U1', sn: '811002511261022618B3', + connected: true }, + legal: { legal: false, preset_model: 'Snapmaker A350' }, + heads: [ + HEAD(0, { type: 'PLA', vendor: 'Jayo', color: '#D8452F', loaded: true }), + HEAD(1, { type: 'PLA', vendor: 'Forshape', color: '#2F6FE0', loaded: true }), + HEAD(2, { type: 'PETG', vendor: 'Polymaker', color: '#2E9E5B', loaded: true }), + HEAD(3, { type: 'PLA', vendor: 'Kingroon', color: '#E9A21B', loaded: true }), + ], + }, + + noprinter: { + label: 'No printer connected', + note: 'sw_GetConnectedMachine returns nothing. Send cannot proceed and the ' + + 'machine half of every design goes blank.', + device: null, + legal: null, + heads: [HEAD(0), HEAD(1), HEAD(2), HEAD(3)], + }, +}; + +/* ---- formatting, shared by all three mockups ----------------------------- */ + +export function humanDuration(sec) { + const s = Number(sec); + if (!Number.isFinite(s) || s <= 0) return 'N/A'; + const h = Math.floor(s / 3600); + const m = Math.round((s % 3600) / 60); + return h ? `${h} h ${m} min` : `${m} min`; +} + +export const grams = (g) => (Number.isFinite(+g) && +g > 0 ? `${(+g).toFixed(1)} g` : '—'); +export const metres = (mm) => `${(Number(mm) / 1000).toFixed(1)} m`; +export const megabytes = (b) => `${(Number(b) / 1048576).toFixed(1)} MB`; + +/** How a head describes itself in one line. */ +export function headWords(h) { + if (!h.loaded) return 'Empty'; + const bits = [h.vendor, h.type, h.subType].filter(Boolean); + return bits.length ? bits.join(' ') : 'Loaded, not named'; +} + +/** + * Is this head an acceptable home for this file filament? + * + * Type equality only - the popup has no business guessing that PETG-CF will do where + * PETG was sliced. `ok` blocks; `warn` is a judgement the operator makes. + */ +export function matchOf(mapping, heads, filIdx, headIdx) { + const head = heads[headIdx]; + const wantType = mapping.filament_type[filIdx]; + const wantNozzle = mapping.nozzle_diameters[filIdx]; + if (!head || !head.loaded) return { level: 'bad', why: `Toolhead ${headIdx + 1} is empty` }; + if (head.nozzle && wantNozzle && head.nozzle !== wantNozzle) { + return { level: 'bad', + why: `Sliced for a ${wantNozzle} mm nozzle; toolhead ${headIdx + 1} has ${head.nozzle} mm` }; + } + if (head.type && wantType && head.type !== wantType) { + return { level: 'warn', why: `${wantType} in the file, ${head.type} in toolhead ${headIdx + 1}` }; + } + return { level: 'ok', why: `${headWords(head)} in toolhead ${headIdx + 1}` }; +} + +/** The mapping Orca already made, as an array. `filament_extruder_map` is Orca's own. */ +export function initialAssignment(mapping) { + return mapping.filament_type.map((_, i) => { + const v = mapping.filament_extruder_map[i]; + return v == null ? i : Number(v); + }); +} + +export const PREFERENCES = [ + { key: 'flow_calibrate', label: 'Extrusion Flow Calibration', + hint: 'Runs a short flow test before the print.' }, + { key: 'time_lapse_camera', label: 'Time-lapse Camera', + hint: 'Records a frame per layer.' }, + { key: 'auto_bed_leveling', label: 'Auto Leveling', + hint: 'Probes the bed before starting.' }, +]; diff --git a/resources/web/print_processing/mockups/index.html b/resources/web/print_processing/mockups/index.html new file mode 100644 index 00000000000..03e12602b47 --- /dev/null +++ b/resources/web/print_processing/mockups/index.html @@ -0,0 +1,183 @@ + + + + + +Print dialog — three designs + + + + + +
    +

    Print dialog — three designs

    +
    + + +
    +
    + + +
    +
    +
    + Show + + + + +
    +
    + +

    + All three run on the real sw_GetFileFilamentMapping reply shape — + parallel arrays, the plate thumbnail, per-filament grams and the nozzle the file was + sliced for — and all three send through the corrected sequence + (sw_GetFileStream → upload → sw_StartLocalPrint {type, path} → + the close protocol). They are the same data and the same send. What differs is what the + dialog thinks it is for. Frames are the true 714 × 750 the host opens. +

    + +
    + +
    +
    +

    AFaithful

    +

    The shipped four cards, in the shipped order, with the fields the + host was already returning and the popup was not reading.

    +
    +
    +
      +
    • Nobody relearns the dialog; it is recognisably the same screen.
    • +
    • Every correction lands without a layout argument.
    • +
    • The mapping — the only decision here — gets a quarter of the screen.
    • +
    • A problem is a line of prose next to the control that caused it.
    • +
    +
    + +
    +
    +

    BMatch sheet

    +

    The mapping is the page: file filaments on the left, what is actually + loaded on the right, a wire between them. Everything else is trim.

    +
    +
    +
      +
    • The destination is drawn. Nobody has to remember what is in toolhead 3.
    • +
    • Reassigning is two clicks and the result is visible without reading.
    • +
    • A dialog nobody recognises, for a step most people click through.
    • +
    • The wires need real layout measurement; they are the fragile part.
    • +
    +
    + +
    +
    +

    CPreflight

    +

    A run of checks that resolve themselves. One line each when fine, + open where a decision is needed. Send is gated on the checks, not on a guess.

    +
    +
    +
      +
    • Short when nothing is wrong, long exactly where something is.
    • +
    • The only design that uses sw_GetPrintLegal and the nozzle + diameters for anything.
    • +
    • “Send anyway” separates a substitution from an empty toolhead.
    • +
    • Furthest from what shipped, and the most to get wrong.
    • +
    • A checklist can read as an obstacle course on a good day.
    • +
    +
    + +
    + + + + diff --git a/resources/web/print_processing/mockups/mockup.css b/resources/web/print_processing/mockups/mockup.css new file mode 100644 index 00000000000..40738b03a6d --- /dev/null +++ b/resources/web/print_processing/mockups/mockup.css @@ -0,0 +1,252 @@ +/* + * mockup.css - what the three print-dialog mockups share. + * + * Layered on ../../shared/css/base.css, so the tokens are the reconstruction's own. + * Everything here is either the dialog frame (714 x 750, which is what + * WebPreprintDialog actually opens) or a primitive more than one option draws. + */ + +/* ---- the dialog itself ------------------------------------------------ */ + +/* `hidden` is a UA rule, and any author `display:` beats it. Option B's match grid is + `display: grid`, so `#match.hidden = true` left the two lane headings on an + upload-only dialog with nothing under them. Author-level, so it wins. */ +[hidden] { display: none !important; } + +html, body { height: 100%; } +body.mock-dialog { + margin: 0; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; /* the dialog does not scroll; its body does */ + background: var(--panel); +} + +.dlg-title { + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border-bottom: 1px solid var(--line); + background: var(--panel-2); + font-size: 13px; + font-weight: 600; + color: var(--ink); +} +.dlg-title .sub { font-weight: 400; color: var(--muted); font-size: 12px; } +.dlg-title .spacer { flex: 1; } + +.dlg-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; } + +.dlg-foot { + flex: none; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-top: 1px solid var(--line); + background: var(--panel); +} + +/* ---- send bar --------------------------------------------------------- */ +.bar { flex: 1; min-width: 0; } +.bar-track { + height: 6px; + border-radius: 999px; + background: var(--panel-2); + overflow: hidden; +} +.bar-fill { + height: 100%; + width: 0; + border-radius: 999px; + background: var(--accent); + transition: width .18s linear; +} +/* One line, always: a blocking reason can be a whole sentence, and letting it wrap + moved the Send button down by a line the moment something went wrong. The full text + is on the element's title, and the check that raised it says it in full anyway. */ +.bar-label { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 4px; + font-size: 11px; + color: var(--muted); + white-space: nowrap; +} +.bar-label > :first-child { overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.bar-label b { font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums; } + +button.primary { + flex: none; + min-width: 130px; + padding: 9px 18px; + border: 1px solid transparent; + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-in); + font: inherit; + font-weight: 600; + cursor: pointer; +} +button.primary:disabled { background: var(--panel-2); color: var(--muted); cursor: not-allowed; } +button.ghost { + padding: 7px 13px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + color: var(--ink-2); + font: inherit; + cursor: pointer; +} +button.ghost:hover { border-color: var(--accent); color: var(--ink); } + +/* ---- cards ------------------------------------------------------------ */ +.card { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + margin-bottom: 10px; +} +.card > h2 { + margin: 0; + padding: 9px 12px; + border-bottom: 1px solid var(--line); + font-size: 12px; + font-weight: 600; + letter-spacing: .02em; + color: var(--ink-2); + display: flex; + align-items: center; + gap: 8px; +} +.card > h2 .spacer { flex: 1; } +.card-body { padding: 11px 12px; } + +/* ---- filament swatch -------------------------------------------------- */ +.sw { + flex: none; + width: 22px; + height: 22px; + border-radius: 5px; + border: 1px solid rgba(0, 0, 0, .22); + box-shadow: inset 0 -5px 8px rgba(0, 0, 0, .16); +} +.sw.lg { width: 30px; height: 30px; border-radius: 7px; } +.sw.sm { width: 15px; height: 15px; border-radius: 4px; } +/* An empty slot is a checkerboard, the same absence the Device page draws. */ +.sw.empty { + background: + repeating-conic-gradient(var(--line) 0 25%, transparent 0 50%) 0 0 / 10px 10px, + var(--panel-2); + box-shadow: none; +} + +/* ---- verdicts --------------------------------------------------------- */ +.dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); } +.dot.ok { background: var(--ok); } +.dot.warn { background: var(--warn); } +.dot.bad { background: var(--err); } + +.note { font-size: 12px; line-height: 1.45; } +.note.ok { color: var(--ok); } +.note.warn { color: var(--warn); } +.note.bad { color: var(--err); } +.muted { color: var(--muted); } + +.pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border: 1px solid var(--line); + border-radius: 999px; + font-size: 11px; + color: var(--ink-2); + white-space: nowrap; +} +.pill.warn { border-color: var(--warn); color: var(--warn); } +.pill.bad { border-color: var(--err); color: var(--err); } +.pill.ok { border-color: var(--ok); color: var(--ok); } + +/* ---- toggles ---------------------------------------------------------- */ +.pref { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 0; + cursor: pointer; +} +.pref + .pref { border-top: 1px solid var(--line); } +.pref .pref-text { flex: 1; min-width: 0; } +.pref .pref-label { font-size: 13px; color: var(--ink); } +.pref .pref-hint { font-size: 11px; color: var(--muted); } +.switch { + flex: none; + width: 34px; + height: 19px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--panel-2); + position: relative; + transition: background .14s, border-color .14s; +} +.switch::after { + content: ""; + position: absolute; + top: 2px; left: 2px; + width: 13px; height: 13px; + border-radius: 50%; + background: var(--muted); + transition: transform .14s, background .14s; +} +.pref input { position: absolute; opacity: 0; pointer-events: none; } +.pref input:checked + .switch { background: var(--accent); border-color: var(--accent); } +.pref input:checked + .switch::after { transform: translateX(15px); background: var(--accent-in); } +.pref input:focus-visible + .switch { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ---- thumbnail -------------------------------------------------------- */ +.thumb { + flex: none; + border-radius: 6px; + border: 1px solid var(--line); + background: var(--panel-2); + object-fit: cover; + display: block; +} + +/* ---- select ----------------------------------------------------------- */ +select.head-pick { + font: inherit; + font-size: 12px; + padding: 5px 7px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--ink); + max-width: 100%; +} +select.head-pick:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +/* ---- the mockup's own marker ------------------------------------------ */ + /* Top-right, in the title bar's band: at the bottom it sat over the Send button in + all three options, and a marker that hides the control being judged is worse than + no marker. */ +.mock-tag { + position: fixed; + right: 10px; + top: 8px; + z-index: 50; + padding: 3px 9px; + border: 1px dashed var(--accent); + border-radius: 999px; + background: var(--panel); + color: var(--accent); + font-family: var(--mono); + font-size: 10px; + letter-spacing: .04em; + pointer-events: none; +} diff --git a/resources/web/print_processing/mockups/option-a.css b/resources/web/print_processing/mockups/option-a.css new file mode 100644 index 00000000000..eaff0de32ba --- /dev/null +++ b/resources/web/print_processing/mockups/option-a.css @@ -0,0 +1,57 @@ +/* Option A - the shipped four cards, corrected. */ + +.model-row { display: flex; gap: 12px; align-items: flex-start; } +.model-row .thumb { width: 104px; height: 104px; } + +.facts { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; } +.facts dt { font-size: 12px; color: var(--muted); } +.facts dd { margin: 0; font-size: 13px; color: var(--ink); } +.facts dd.mono { font-variant-numeric: tabular-nums; } + +.printer-row { display: flex; align-items: center; gap: 10px; } +.printer-pick { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 9px; + padding: 9px 11px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + font: inherit; + font-size: 13px; + color: var(--ink); + text-align: left; + cursor: pointer; +} +.printer-pick:hover { border-color: var(--accent); } +.printer-pick .caret { margin-left: auto; color: var(--muted); } +.printer-pick .sub { font-size: 11px; color: var(--muted); } + +/* One row per file filament. + Fixed widths for the two right-hand columns: with `auto auto` the select and the + destination took 586 of 660 px between them and the filament's own name wrapped to + 22px wide - which a screenshot shows as "a bit tight" and a DOM dump shows as a + 22x57 box. Subtract two numbers. */ +.fil { + display: grid; + grid-template-columns: 22px minmax(140px, 1fr) 116px 176px; + align-items: center; + gap: 10px; + padding: 9px 0; +} +.fil + .fil { border-top: 1px solid var(--line); } +.fil-name { font-size: 13px; color: var(--ink); } +.fil-sub { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } +.fil-to { font-size: 11px; color: var(--muted); } + +.dest { display: flex; align-items: center; gap: 7px; min-width: 0; } +.dest .dest-words { font-size: 11px; line-height: 1.25; min-width: 0; } +.dest .dest-words b { display: block; font-weight: 600; font-size: 12px; color: var(--ink); } + +.verdicts { margin-top: 10px; display: flex; flex-direction: column; gap: 5px; } +.verdict { display: flex; align-items: flex-start; gap: 7px; font-size: 12px; } +.verdict .dot { margin-top: 5px; } + +button.ghost.tiny { padding: 3px 9px; font-size: 11px; } diff --git a/resources/web/print_processing/mockups/option-a.html b/resources/web/print_processing/mockups/option-a.html new file mode 100644 index 00000000000..d09db50876c --- /dev/null +++ b/resources/web/print_processing/mockups/option-a.html @@ -0,0 +1,49 @@ + + + + +Print processing — Option A + + + + + + + + +
    + Print Preprocessing + + +
    + +
    +

    Model Information

    +

    Select Printer

    +

    Edit Filament +

    +
    +

    Print Preferences

    +
    + +
    +
    +
    +
    Ready
    +
    + +
    + + + + diff --git a/resources/web/print_processing/mockups/option-a.js b/resources/web/print_processing/mockups/option-a.js new file mode 100644 index 00000000000..b25eeeb4b07 --- /dev/null +++ b/resources/web/print_processing/mockups/option-a.js @@ -0,0 +1,193 @@ +/* + * Option A - the shipped four cards, corrected. + * + * Nothing moves. Model Information, Select Printer, Edit Filament, Print Preferences, + * then the send bar - the order and the labels the Flutter popup uses. The changes are + * all inside the cards, and each one is a field the host was already returning and the + * popup was not reading. + */ +'use strict'; + +import { MAPPING, JOB, humanDuration, grams, metres, headWords, matchOf, PREFERENCES } + from './fixture.js'; +import { $, el, swatch, makeModel, blockers, runSend, prefRows, markMockup, expose, + WITH_PRINT_SETUP, MODE } from './shell.js'; + +const model = makeModel(); +let sending = false; + +/* ---- Model Information ------------------------------------------------- */ +function drawModel() { + const b = $('#c-model .card-body'); + b.innerHTML = ''; + const row = el('div', 'model-row'); + + // thumbnails[0].url - a data: PNG of the sliced plate, in the reply all along. + const img = el('img', 'thumb'); + img.src = MAPPING.thumbnails[0].url; + img.alt = 'Plate preview'; + img.width = 104; img.height = 104; + row.appendChild(img); + + const dl = el('dl', 'facts'); + const add = (k, v, mono) => { + dl.appendChild(el('dt', null, k)); + dl.appendChild(el('dd', mono ? 'mono' : null, v)); + }; + add('Filename', MAPPING.filename); + add('Estimated Time', humanDuration(MAPPING.estimated_time), true); + add('Estimated Materials', grams(MAPPING.filament_weight_total), true); + // nozzle_diameters is what the FILE was sliced for. Nothing checked it before. + add('Nozzle', `${[...new Set(MAPPING.nozzle_diameters)].join(', ')} mm`, true); + row.appendChild(dl); + + b.appendChild(row); +} + +/* ---- Select Printer ---------------------------------------------------- */ +function drawPrinter() { + const b = $('#c-printer .card-body'); + b.innerHTML = ''; + const row = el('div', 'printer-row'); + + const pick = el('button', 'printer-pick'); + pick.type = 'button'; + if (model.device) { + const txt = el('div'); + txt.appendChild(el('div', null, model.device.name)); + txt.appendChild(el('div', 'sub', `${model.device.model} · ${model.device.sn}`)); + pick.appendChild(txt); + } else { + pick.appendChild(el('div', null, 'Click to select printer')); + } + pick.appendChild(el('span', 'caret', '▾')); + // sw_GetLocalDevices returns the saved list; nothing has ever called it. + pick.onclick = () => alert('sw_GetLocalDevices — the saved-device list, unwired.'); + row.appendChild(pick); + b.appendChild(row); + + if (model.legal && model.legal.legal === false) { + const w = el('p', 'note bad', + `Sliced for ${model.legal.preset_model}, which is not the connected printer.`); + w.style.margin = '9px 0 0'; + b.appendChild(w); + } +} + +/* ---- Edit Filament ----------------------------------------------------- */ +function drawFilament() { + const b = $('#c-filament .card-body'); + b.innerHTML = ''; + + // The real reply is PARALLEL ARRAYS. There is no `filaments[]`, and reading for one + // is why this card is empty against a real Orca. + MAPPING.filament_type.forEach((type, i) => { + const row = el('div', 'fil'); + row.appendChild(swatch(MAPPING.filament_color_rgba[i])); + + const meta = el('div'); + meta.appendChild(el('div', 'fil-name', `Filament ${i + 1} · ${type}`)); + meta.appendChild(el('div', 'fil-sub', + `${grams(MAPPING.filament_weight[i])} · ${metres(MAPPING.filament_used_mm[i])}`)); + row.appendChild(meta); + + const sel = el('select', 'head-pick'); + sel.setAttribute('aria-label', `Toolhead for filament ${i + 1}`); + // Just the number: what is IN the head is drawn next to the select, in words, and + // saying it twice is what made this column 242px wide. + model.heads.forEach((_, t) => { + const o = el('option', null, `Toolhead ${t + 1}`); + o.value = String(t); + if (model.assignment[i] === t) o.selected = true; + sel.appendChild(o); + }); + sel.onchange = () => { model.assignment[i] = Number(sel.value); render(); }; + row.appendChild(sel); + + // The destination, drawn. The shipped card names a number and shows nothing. + const head = model.heads[model.assignment[i]]; + const m = matchOf(MAPPING, model.heads, i, model.assignment[i]); + const dest = el('div', 'dest'); + dest.appendChild(el('span', `dot ${m.level}`)); + dest.appendChild(swatch(head && head.loaded ? head.color : null, 'sm')); + const words = el('div', 'dest-words'); + words.appendChild(el('b', null, head && head.loaded ? headWords(head) : 'Empty')); + words.appendChild(el('span', m.level === 'ok' ? 'muted' : `note ${m.level}`, + m.level === 'ok' ? `${head.nozzle} mm nozzle` : m.why)); + dest.appendChild(words); + row.appendChild(dest); + + b.appendChild(row); + }); +} + +/* ---- Print Preferences ------------------------------------------------- */ +function drawPrefs() { + const b = $('#c-prefs .card-body'); + b.innerHTML = ''; + b.appendChild(prefRows(model, () => {})); +} + +/* ---- send bar ---------------------------------------------------------- */ +function drawSend() { + const problems = blockers(model).concat( + WITH_PRINT_SETUP + ? MAPPING.filament_type + .map((_, i) => matchOf(MAPPING, model.heads, i, model.assignment[i])) + .filter((m) => m.level === 'bad') + .map((m) => ({ level: 'bad', text: m.why })) + : []); + + $('#send').disabled = sending || problems.length > 0; + if (!sending) { + $('#stage').textContent = problems.length + ? problems[0].text + : (MODE === 'upload' ? 'Ready to upload' : 'Ready to print'); + $('#stage').title = $('#stage').textContent; + $('#stage').className = problems.length ? 'note bad' : ''; + } +} + +/* ---- render ------------------------------------------------------------ */ +function render() { + drawModel(); + drawPrinter(); + $('#c-filament').hidden = !WITH_PRINT_SETUP; + $('#c-prefs').hidden = !WITH_PRINT_SETUP; + if (WITH_PRINT_SETUP) { drawFilament(); drawPrefs(); } + drawSend(); +} + +$('#title').textContent = WITH_PRINT_SETUP ? 'Print Preprocessing' + : 'Pretreat the uploaded content'; +$('#subtitle').textContent = WITH_PRINT_SETUP ? '?path=4' : '?path=5'; +$('#refresh').onclick = () => render(); + +$('#send').onclick = () => { + sending = true; + drawSend(); + runSend(model, { + onStage: ({ step, index, of, pct, sent }) => { + const overall = Math.round(((index + (pct == null ? 0.5 : pct / 100)) / of) * 100); + $('#fill').style.width = `${overall}%`; + $('#stage').textContent = step.bytes + ? `${step.say} — ${(sent / 1048576).toFixed(1)} of ${(JOB.sizeBytes / 1048576).toFixed(1)} MB` + : step.say; + $('#stage').className = ''; + $('#pct').textContent = `${overall}%`; + }, + onDone: () => { + $('#fill').style.width = '100%'; + $('#pct').textContent = '100%'; + $('#stage').textContent = 'Sent. The dialog closes here.'; + $('#stage').className = 'note ok'; + $('#send').textContent = 'Sent'; + }, + onFail: (e) => { sending = false; $('#stage').textContent = String(e); render(); }, + }); +}; + +render(); +markMockup('A · faithful'); +expose(model, { render, + destinationControls: () => document.querySelectorAll('select.head-pick').length }); diff --git a/resources/web/print_processing/mockups/option-b.css b/resources/web/print_processing/mockups/option-b.css new file mode 100644 index 00000000000..69acfe067c7 --- /dev/null +++ b/resources/web/print_processing/mockups/option-b.css @@ -0,0 +1,127 @@ +/* Option B - the match sheet. */ + +.dlg-body { display: flex; flex-direction: column; gap: 10px; } + +/* the plate and its numbers, compressed to one strip */ +.strip { + flex: none; + display: flex; + align-items: center; + gap: 11px; + padding: 9px 11px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel-2); +} +.strip .thumb { width: 52px; height: 52px; } +.strip .name { font-size: 13px; font-weight: 600; color: var(--ink); } +.strip .nums { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } +.strip .grow { flex: 1; min-width: 0; } +.legal { margin: 0; } + +/* the two lanes, and the wires between them */ +.match { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 54px 1fr; + gap: 0; + position: relative; +} +/* The wires SVG is absolutely positioned, so it occupies no track - which left the + right-hand lane to fall into the 54px gutter. Both lanes name their column. */ +.match .col { min-width: 0; display: flex; flex-direction: column; } +.match .col:first-of-type { grid-column: 1; } +.match .col:last-of-type { grid-column: 3; } +.match h3 { + margin: 0 0 7px; + font-size: 11px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--muted); +} +.match .col:last-child h3 { text-align: right; } +.lane { display: flex; flex-direction: column; gap: 7px; } + +.wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } +.wires path { fill: none; stroke-width: 2.5; stroke-linecap: round; opacity: .75; } +.wires path.bad { stroke-dasharray: 4 4; } +.wires path.dim { opacity: .18; } +.wires path.live { opacity: 1; stroke-width: 3.5; } + +/* a file filament */ +.frow, .hrow { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 9px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + cursor: pointer; + text-align: left; + font: inherit; + color: var(--ink); + width: 100%; +} +.frow:hover, .hrow:hover { border-color: var(--accent); } +.frow.sel, .hrow.sel { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(15, 110, 123, .18); } +.frow .t, .hrow .t { flex: 1; min-width: 0; } +.frow .lbl, .hrow .lbl { + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.frow .sub, .hrow .sub { font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; } +.hrow { flex-direction: row-reverse; text-align: right; } +.hrow .num { + flex: none; + width: 19px; + height: 19px; + border-radius: 5px; + border: 1px solid var(--line); + background: var(--panel-2); + color: var(--muted); + font-size: 10.5px; + line-height: 17px; + text-align: center; + font-variant-numeric: tabular-nums; +} +.hrow.taken .num { background: var(--accent); border-color: var(--accent); color: var(--accent-in); } +.hrow.bad { border-color: var(--err); } +.hrow.bad .sub { color: var(--err); } +.hrow.warn { border-color: var(--warn); } +.hrow.warn .sub { color: var(--warn); } +.hrow.bad .num { background: var(--err); border-color: var(--err); color: #fff; } +.hrow.warn .num { background: var(--warn); border-color: var(--warn); color: #fff; } + +.hint { margin: 0; font-size: 11.5px; color: var(--muted); min-height: 1.4em; } +.hint.bad { color: var(--err); } +.hint.warn { color: var(--warn); } + +/* the three preferences, as one compact row of chips */ +.foot-prefs { flex: none; display: flex; flex-direction: column; gap: 1px; } +.foot-prefs .chip { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--muted); + cursor: pointer; + white-space: nowrap; +} +.foot-prefs .chip input { position: absolute; opacity: 0; pointer-events: none; } +.foot-prefs .chip .box { + width: 12px; height: 12px; + border: 1px solid var(--line); + border-radius: 3px; + background: var(--panel); +} +.foot-prefs .chip input:checked + .box { + background: var(--accent); + border-color: var(--accent); + box-shadow: inset 0 0 0 2px var(--panel); +} +.foot-prefs .chip input:checked ~ span { color: var(--ink-2); } diff --git a/resources/web/print_processing/mockups/option-b.html b/resources/web/print_processing/mockups/option-b.html new file mode 100644 index 00000000000..831b016e8e4 --- /dev/null +++ b/resources/web/print_processing/mockups/option-b.html @@ -0,0 +1,63 @@ + + + + +Print processing — Option B + + + + + + + + +
    + Print Preprocessing + + + +
    + +
    +
    + + +
    +
    +

    In this file

    +
    +
    + +
    +

    In the printer

    +
    +
    +
    + +

    +
    + +
    +
    +
    +
    +
    Ready
    +
    + +
    + + + + diff --git a/resources/web/print_processing/mockups/option-b.js b/resources/web/print_processing/mockups/option-b.js new file mode 100644 index 00000000000..93204f85f47 --- /dev/null +++ b/resources/web/print_processing/mockups/option-b.js @@ -0,0 +1,237 @@ +/* + * Option B - the match sheet. + * + * One question, drawn: file filaments on the left, toolheads on the right, a wire + * between them per assignment. Click a file row and then a head to move a wire. Both + * lanes are +
    + + + + diff --git a/resources/web/print_processing/mockups/option-c.js b/resources/web/print_processing/mockups/option-c.js new file mode 100644 index 00000000000..79a36e537c9 --- /dev/null +++ b/resources/web/print_processing/mockups/option-c.js @@ -0,0 +1,330 @@ +/* + * Option C - the preflight. + * + * The screen is a list of checks. Each one owns a question, answers it in a line, and + * opens only when the answer needs a decision - so a plate with nothing wrong is six + * short rows and a Send, and a plate with a problem opens at the problem. + * + * `level` is the whole grammar: ok, warn, bad. A `bad` disables Send; a `warn` changes + * its label to "Send anyway", because a filament substitution is a judgement the operator + * is entitled to make and an empty toolhead is not. + */ +'use strict'; + +import { MAPPING, JOB, humanDuration, grams, metres, megabytes, headWords, matchOf, + PREFERENCES } from './fixture.js'; +import { $, el, swatch, makeModel, runSend, prefRows, markMockup, expose, + WITH_PRINT_SETUP, MODE } from './shell.js'; + +const model = makeModel(); +const opened = new Set(); +let sending = false; + +/* ---- the checks --------------------------------------------------------- */ + +function checks() { + const list = []; + + list.push({ + id: 'plate', name: 'Plate', level: 'ok', + // The thumbnail rides in the ROW, not only in the body: "is this the right plate" + // is the one question this dialog is always asked, and it should not need a click. + thumb: MAPPING.thumbnails[0].url, + said: `${MAPPING.filename} — ${humanDuration(MAPPING.estimated_time)}, ` + + `${grams(MAPPING.filament_weight_total)}`, + body: plateBody, + }); + + list.push(model.device + ? { id: 'printer', name: 'Printer', level: 'ok', + said: `${model.device.name} — connected`, body: printerBody } + : { id: 'printer', name: 'Printer', level: 'bad', + said: 'Nothing connected', body: printerBody }); + + if (model.legal) { + list.push(model.legal.legal + ? { id: 'preset', name: 'Preset', level: 'ok', + said: `Sliced for ${model.legal.preset_model}, which is what is connected` } + : { id: 'preset', name: 'Preset', level: 'bad', + said: `Sliced for ${model.legal.preset_model}, not for the connected printer` }); + } + + if (WITH_PRINT_SETUP) { + /* + * nozzle_diameters (what the FILE was sliced for) against the nozzle fitted to the + * head each filament is assigned to. Nothing checked this before. + * + * Following the ASSIGNMENT rather than the head list matters: a 0.2 nozzle on a + * toolhead this plate never prints from is not a problem, and reporting it as one + * blocks a print that would have worked. + */ + const wrong = MAPPING.nozzle_diameters + .map((want, i) => ({ want, i, head: model.heads[model.assignment[i]], + at: model.assignment[i] })) + .filter(({ want, head }) => head && want && head.nozzle !== want); + list.push({ + id: 'nozzles', name: 'Nozzles', level: wrong.length ? 'bad' : 'ok', + said: wrong.length + ? `Filament ${wrong[0].i + 1} goes to toolhead ${wrong[0].at + 1}, which has a ` + + `${wrong[0].head.nozzle} mm nozzle; the file was sliced for ${wrong[0].want} mm` + : `All ${[...new Set(MAPPING.nozzle_diameters)].join('/')} mm, as sliced`, + body: nozzleBody, openIf: wrong.length > 0, + }); + + const verdicts = MAPPING.filament_type.map( + (_, i) => matchOf(MAPPING, model.heads, i, model.assignment[i])); + const bad = verdicts.filter((v) => v.level === 'bad'); + const warn = verdicts.filter((v) => v.level === 'warn'); + list.push({ + id: 'filaments', name: 'Filaments', level: bad.length ? 'bad' : warn.length ? 'warn' : 'ok', + said: bad.length ? bad[0].why + : warn.length ? warn[0].why + : `${MAPPING.filament_type.length} needed, each in a toolhead that can print it`, + swatches: MAPPING.filament_color_rgba, + body: filamentBody, openIf: bad.length > 0 || warn.length > 0, + }); + + const on = PREFERENCES.filter((p) => model.prefs[p.key]).map((p) => p.label); + list.push({ + id: 'options', name: 'Options', level: 'ok', + said: on.length ? on.join(', ') : 'None of the three enabled', + body: optionsBody, + }); + } + + list.push({ + id: 'transfer', name: 'Transfer', level: 'ok', + said: MODE === 'upload' + ? `${megabytes(JOB.sizeBytes)} to the printer, not started` + : `${megabytes(JOB.sizeBytes)} to the printer, then start`, + body: transferBody, + }); + + return list; +} + +/* ---- the bodies --------------------------------------------------------- */ + +function plateBody(b) { + const wrap = el('div', 'plate-open'); + const img = el('img', 'thumb'); + img.src = MAPPING.thumbnails[0].url; + img.alt = 'Plate preview'; + wrap.appendChild(img); + const dl = el('dl'); + const add = (k, v) => { dl.appendChild(el('dt', null, k)); dl.appendChild(el('dd', null, v)); }; + add('File', MAPPING.filename); + add('Time', humanDuration(MAPPING.estimated_time)); + add('Material', `${grams(MAPPING.filament_weight_total)} over ` + + `${MAPPING.filament_type.length} filaments`); + add('Length', metres(MAPPING.filament_used_mm.reduce((a, x) => a + x, 0))); + add('Sliced for', MAPPING.machine_model); + wrap.appendChild(dl); + b.appendChild(wrap); +} + +function printerBody(b) { + const row = el('div', 'printer-open'); + if (model.device) { + const t = el('div'); + t.appendChild(el('div', null, `${model.device.model} · ${model.device.sn}`)); + t.appendChild(el('div', 'muted', 'Connected over the LAN.')); + t.style.flex = '1'; + row.appendChild(t); + } else { + row.appendChild(el('div', 'note bad', + 'sw_GetConnectedMachine returned nothing. Nothing can be sent.')); + } + const pick = el('button', 'ghost', 'Choose a printer'); + pick.onclick = () => alert('sw_GetLocalDevices — the saved-device list, unwired.'); + row.appendChild(pick); + b.appendChild(row); +} + +function nozzleBody(b) { + const t = el('table', 'grid'); + t.innerHTML = 'ToolheadFittedPrintsSliced for' + + ''; + model.heads.forEach((h, k) => { + // Which file filaments, if any, come out of this head. + const uses = MAPPING.nozzle_diameters + .map((want, i) => ({ want, i })) + .filter(({ i }) => model.assignment[i] === k); + const want = uses.length ? uses[0].want : null; + const bad = want != null && h.nozzle !== want; + const tr = el('tr'); + tr.appendChild(el('td', null, `${k + 1} · ${headWords(h)}`)); + tr.appendChild(el('td', 'tight', `${h.nozzle} mm`)); + tr.appendChild(el('td', 'tight', + uses.length ? uses.map(({ i }) => `Filament ${i + 1}`).join(', ') : 'nothing')); + tr.appendChild(el('td', 'tight', want != null ? `${want} mm` : '—')); + const v = el('td', 'tight'); + v.appendChild(el('span', `pill ${!uses.length ? '' : bad ? 'bad' : 'ok'}`, + !uses.length ? 'unused' : bad ? 'mismatch' : 'ok')); + tr.appendChild(v); + t.appendChild(tr); + }); + b.appendChild(t); +} + +function filamentBody(b) { + const t = el('table', 'grid'); + t.innerHTML = 'In the filePrints from'; + MAPPING.filament_type.forEach((type, i) => { + const m = matchOf(MAPPING, model.heads, i, model.assignment[i]); + const tr = el('tr'); + + const left = el('td'); + const lr = el('div', 'cellrow'); + lr.appendChild(swatch(MAPPING.filament_color_rgba[i], 'sm')); + const lt = el('div'); + lt.appendChild(el('div', null, `${type} · Filament ${i + 1}`)); + lt.appendChild(el('div', 'muted', `${grams(MAPPING.filament_weight[i])}`)); + lr.appendChild(lt); + left.appendChild(lr); + tr.appendChild(left); + + const mid = el('td'); + const sel = el('select', 'head-pick'); + model.heads.forEach((h, k) => { + const o = el('option', null, + `${k + 1} · ${h.loaded ? headWords(h) : 'empty'}`); + o.value = String(k); + if (model.assignment[i] === k) o.selected = true; + sel.appendChild(o); + }); + sel.onchange = () => { model.assignment[i] = Number(sel.value); render(); }; + const mr = el('div', 'cellrow'); + mr.appendChild(swatch(model.heads[model.assignment[i]].loaded + ? model.heads[model.assignment[i]].color : null, 'sm')); + mr.appendChild(sel); + mid.appendChild(mr); + tr.appendChild(mid); + + const v = el('td', 'tight'); + v.appendChild(el('span', `pill ${m.level}`, + m.level === 'ok' ? 'ok' : m.level === 'warn' ? 'substitution' : 'no')); + v.title = m.why; + tr.appendChild(v); + + t.appendChild(tr); + }); + b.appendChild(t); +} + +function optionsBody(b) { b.appendChild(prefRows(model, () => render())); } + +function transferBody(b) { + const p = el('div'); + p.appendChild(el('div', null, + `${JOB.displayName.replace(/\.gcode$/, '.zip')} — ${megabytes(JOB.sizeBytes)}`)); + p.appendChild(el('div', 'muted', + MODE === 'upload' + ? 'Uploaded to the printer and left there.' + : 'Uploaded, then started with sw_StartLocalPrint.')); + b.appendChild(p); +} + +/* ---- render ------------------------------------------------------------- */ + +function render() { + const list = checks(); + const root = $('#checks'); + root.innerHTML = ''; + + list.forEach((c) => { + // A check that found something opens itself, once - reopening it on every render + // would fight a user who closed it. + if (c.openIf && !opened.has(`${c.id}:auto`)) { opened.add(c.id); opened.add(`${c.id}:auto`); } + const isOpen = opened.has(c.id); + + const box = el('section', `chk ${c.level}`); + box.setAttribute('open-state', isOpen ? '1' : '0'); + + const head = el('button', 'chk-head'); + head.type = 'button'; + head.appendChild(el('span', `dot ${c.level}`)); + head.appendChild(el('span', 'name', c.name)); + if (c.thumb) { + const t = el('img', 'thumb row-thumb'); + t.src = c.thumb; + t.alt = 'Plate preview'; + head.appendChild(t); + } + head.appendChild(el('span', `said ${c.level === 'ok' ? '' : c.level}`, c.said)); + if (c.swatches) { + const sw = el('span', 'swatches'); + c.swatches.forEach((col) => sw.appendChild(swatch(col, 'sm'))); + head.appendChild(sw); + } + if (c.body) head.appendChild(el('span', 'caret', '▸')); + head.onclick = () => { + if (!c.body) return; + if (opened.has(c.id)) opened.delete(c.id); else opened.add(c.id); + render(); + }; + box.appendChild(head); + + if (c.body && isOpen) { + const body = el('div', 'chk-body'); + c.body(body); + box.appendChild(body); + } + root.appendChild(box); + }); + + const worst = list.some((c) => c.level === 'bad') ? 'bad' + : list.some((c) => c.level === 'warn') ? 'warn' : 'ok'; + $('#summary').textContent = + worst === 'ok' ? 'nothing to resolve' + : worst === 'warn' ? '1 to look at' + : `${list.filter((c) => c.level === 'bad').length} blocking`; + + const btn = $('#send'); + btn.disabled = sending || worst === 'bad'; + if (!sending) { + btn.textContent = worst === 'warn' ? 'Send anyway' + : MODE === 'upload' ? 'Upload' : 'Send'; + $('#stage').textContent = worst === 'bad' + ? list.find((c) => c.level === 'bad').said + : 'Ready'; + $('#stage').title = $('#stage').textContent; + $('#stage').className = worst === 'bad' ? 'note bad' : ''; + } +} + +$('#title').textContent = WITH_PRINT_SETUP ? 'Print Preprocessing' + : 'Pretreat the uploaded content'; +$('#subtitle').textContent = WITH_PRINT_SETUP ? '?path=4' : '?path=5'; + +$('#send').onclick = () => { + sending = true; + render(); + runSend(model, { + onStage: ({ step, index, of, pct, sent }) => { + const overall = Math.round(((index + (pct == null ? 0.5 : pct / 100)) / of) * 100); + $('#fill').style.width = `${overall}%`; + $('#pct').textContent = `${overall}%`; + $('#stage').textContent = step.bytes + ? `${step.say} — ${(sent / 1048576).toFixed(1)} of ${(JOB.sizeBytes / 1048576).toFixed(1)} MB` + : step.say; + $('#stage').className = ''; + }, + onDone: () => { + $('#fill').style.width = '100%'; + $('#pct').textContent = '100%'; + $('#stage').textContent = 'Sent. The dialog closes here.'; + $('#stage').className = 'note ok'; + $('#send').textContent = 'Sent'; + }, + onFail: (e) => { sending = false; $('#stage').textContent = String(e); render(); }, + }); +}; + +render(); +markMockup('C · preflight'); +expose(model, { render, opened, + destinationControls: () => document.querySelectorAll('select.head-pick').length, + // The mapping lives behind a disclosure by design; say so rather than + // letting a harness read a collapsed check as a missing control. + disclosed: true }); diff --git a/resources/web/print_processing/mockups/shell.js b/resources/web/print_processing/mockups/shell.js new file mode 100644 index 00000000000..633db9357cd --- /dev/null +++ b/resources/web/print_processing/mockups/shell.js @@ -0,0 +1,156 @@ +/* + * shell.js - what the three mockups share below the design. + * + * Scenario and mode come off the query string so the chooser can drive all three + * frames at once, and so a `--drive` script can put any option into any state: + * + * ?scenario=ready|mismatch|wrongmodel|noprinter default ready + * ?mode=print|upload default print (path=4 | path=5) + * + * The send simulation follows the CORRECTED sequence from 04-requirements.md, not the + * one the reconstruction currently ships: the file crosses as a URL rather than as a + * JSON array of bytes, the progress is a byte count rather than a timer, and + * sw_StartLocalPrint carries the { type, path } it refuses to run without. + */ +'use strict'; + +import { SCENARIOS, JOB, MAPPING, initialAssignment, PREFERENCES } from './fixture.js'; + +export const qs = new URLSearchParams(location.search); +export const SCENARIO_KEY = SCENARIOS[qs.get('scenario')] ? qs.get('scenario') : 'ready'; +export const SCENARIO = SCENARIOS[SCENARIO_KEY]; +export const MODE = qs.get('mode') === 'upload' ? 'upload' : 'print'; +/** ?path=5 drops the filament mapping and the preferences. That is the whole difference. */ +export const WITH_PRINT_SETUP = MODE === 'print'; + +/* ---- DOM ---------------------------------------------------------------- */ +export const $ = (sel, root = document) => root.querySelector(sel); +export const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + +export function el(tag, cls, text) { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; +} + +/** A filament swatch. `null` colour is an absence, and draws as one. */ +export function swatch(color, size = '') { + const n = el('span', 'sw' + (size ? ' ' + size : '')); + if (color) n.style.background = color; + else n.classList.add('empty'); + return n; +} + +/* ---- shared model ------------------------------------------------------- */ + +/** + * One mutable object per mockup: which head each file filament goes to, and the three + * preferences. Options differ in how they DRAW this; none of them differs in what it is. + */ +export function makeModel() { + const prefs = {}; + PREFERENCES.forEach((p) => { prefs[p.key] = p.key === 'auto_bed_leveling'; }); + return { + assignment: initialAssignment(MAPPING), + prefs, + device: SCENARIO.device, + heads: SCENARIO.heads, + legal: SCENARIO.legal, + }; +} + +/** Every file filament assigned to a head that can take it. */ +export function blockers(model) { + const out = []; + if (!model.device) out.push({ level: 'bad', text: 'No printer is connected.' }); + if (model.legal && model.legal.legal === false) { + out.push({ level: 'bad', + text: `This plate was sliced for ${model.legal.preset_model}.` }); + } + return out; +} + +/* ---- the send ----------------------------------------------------------- */ + +/** + * The sequence, staged the way the real one would be. Each stage reports a fraction and + * a line for the trace; `onStage` is how an option draws it, and every option draws it + * differently, which is part of what is being chosen between. + */ +export async function runSend(model, { onStage, onDone, onFail }) { + const path = `gcodes/${JOB.displayName.replace(/\.gcode$/, '.zip')}`; + const steps = [ + { cmd: 'sw_GetFileStream', params: { is_zip: true }, + say: 'Packaging', detail: 'Orca zips the plate and hands back a URL, a size and a SHA-256.' }, + { cmd: 'fetch(file_url)', params: null, + say: 'Reading', detail: `${(JOB.sizeBytes / 1048576).toFixed(1)} MB from Orca's own HTTP server.` }, + { cmd: 'POST /server/files/upload', params: { host: model.device && model.device.name }, + say: 'Uploading', detail: 'Multipart to the printer. This is where the bar is real.', + bytes: true }, + { cmd: 'sw_StartLocalPrint', params: { type: 'zip', path }, + say: 'Starting', detail: 'The two parameters the handler refuses to run without.' }, + { cmd: 'sw_FinishPreprint', params: { status: 'success' }, say: 'Reporting' }, + { cmd: 'sw_SetFilamentMappingComplete', params: { status: 'success' }, + say: 'Recording', detail: 'Records the outcome. Does not close the dialog.' }, + { cmd: 'sw_FinishFilamentMapping', params: {}, + say: 'Closing', detail: 'This one closes it.' }, + ]; + + try { + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + if (s.bytes) { + // The one stage with a real byte count behind it. + for (let p = 0; p <= 100; p += 4) { + onStage({ step: s, index: i, of: steps.length, pct: p, + sent: Math.round(JOB.sizeBytes * p / 100) }); + await sleep(28); + } + } else { + onStage({ step: s, index: i, of: steps.length, pct: null }); + await sleep(190); + } + } + onDone(); + } catch (e) { + onFail(e); + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/* ---- the preference rows, identical in all three ------------------------ */ +export function prefRows(model, onChange) { + const frag = document.createDocumentFragment(); + PREFERENCES.forEach(({ key, label, hint }) => { + const row = el('label', 'pref'); + const box = el('input'); + box.type = 'checkbox'; + box.checked = !!model.prefs[key]; + box.onchange = () => { model.prefs[key] = box.checked; onChange(key, box.checked); }; + const text = el('div', 'pref-text'); + text.appendChild(el('div', 'pref-label', label)); + text.appendChild(el('div', 'pref-hint', hint)); + row.appendChild(text); + row.appendChild(box); + row.appendChild(el('span', 'switch')); + frag.appendChild(row); + }); + return frag; +} + +/** The marker that says this is not the page. */ +export function markMockup(name) { + const tag = el('div', 'mock-tag', `mockup · ${name} · ${SCENARIO_KEY}`); + document.body.appendChild(tag); + document.title = `Print processing — ${name}`; +} + +/* Handed to the chooser and to any --drive script. */ +export function expose(model, extra = {}) { + window.__mockup = Object.assign({ + get model() { return model; }, + scenario: SCENARIO_KEY, mode: MODE, mapping: MAPPING, job: JOB, + }, extra); +} diff --git a/resources/web/device_page/js/core/connection.js b/resources/web/shared/js/connection.js similarity index 96% rename from resources/web/device_page/js/core/connection.js rename to resources/web/shared/js/connection.js index 8967ab73928..cc91e0809bc 100644 --- a/resources/web/device_page/js/core/connection.js +++ b/resources/web/shared/js/connection.js @@ -1,6 +1,12 @@ /* * connection.js - bring an MQTT session to the printer up. * + * SHARED by both surfaces. It grew on the Device page, and the print dialog needs the + * same thing for the same reason: `sw_GetMachineState` answers out of a Moonraker host + * that something has to attach first. Inside Orca the Device tab usually has, and the + * popup inherits it; opened on its own - or run outside Orca against u1_bridge.py - + * nothing has, and every state command comes back "no engine attached yet". + * * The page owns the transport; Orca owns the socket. Every step below is a * bridge command, and the whole sequence is recovered from SSWCP.cpp and * MoonRaker.cpp - see docs/u1-webui/02-device-page/06-connection.md @@ -25,7 +31,7 @@ 'use strict'; import { CMD, DEVICE, PAIR_PORT, LAN_AUTH_CODE, MQTT_KEEPALIVE, hasTlsMaterial } - from '../../../shared/js/protocol.js'; + from './protocol.js'; /* * Cloud hosts. The `.com` / `.cn` split is region selection; Orca appends the diff --git a/resources/web/shared/js/dialog.js b/resources/web/shared/js/dialog.js new file mode 100644 index 00000000000..8936817a631 --- /dev/null +++ b/resources/web/shared/js/dialog.js @@ -0,0 +1,165 @@ +/* + * dialog.js - the modal sheet, shared by both surfaces. + * + * It lived in the Device tab's core/overlay.js, next to that page's anchored menus and + * popovers, which is where it grew: `openDialog` for a sheet with a Cancel and a + * confirm, `openBlockingDialog` for one the machine has to finish before the operator + * may leave, and two field helpers so a sheet does not hand-roll an input. + * + * The print dialog needs exactly one of these - the help sheet behind Extrusion Flow + * Calibration, which the bundle opens with `A.rB(...)` and an Ok button - and writing a + * second one would have been a second scrim, a second Escape handler and a second answer + * to "what does clicking the backdrop do". + * + * The MENUS did not come with it. The Device page's `openMenu` is a list of actions + * positioned against the window; this dialog's pickers are value lists with a per-item + * enabled flag, three geometry presets and a dialog-relative anchor. They are two + * widgets that happen to both be rectangles, and merging them would have made both + * worse - see print_processing/js/widgets/picker.js. + */ +'use strict'; + +import { el } from './dom.js'; + +/* ---- modal dialog --------------------------------------------------- */ + +let openDialogEl = null; +let blocking = false; // a blocking dialog ignores Escape, the scrim and the X + +export function closeDialog() { + if (openDialogEl) { openDialogEl.remove(); openDialogEl = null; } + blocking = false; +} + +/** + * Modal sheet. `build(body)` fills the body; `onConfirm()` returning false + * keeps it open (so a validator can reject). + * + * `cancel: false` drops the Cancel button, for a sheet that only tells you something: + * offering both Cancel and Close for one dismissal is a choice that is not one. + */ +export function openDialog({ title, build, confirmLabel = 'OK', onConfirm, wide = false, + cancel = true }) { + closeDialog(); + const scrim = el('div', 'scrim'); + const box = el('div', 'dialog' + (wide ? ' wide' : '')); + + const head = el('div', 'dialog-head'); + head.appendChild(el('h3', null, title)); + const x = el('button', 'dialog-x', '×'); + x.onclick = closeDialog; + head.appendChild(x); + box.appendChild(head); + + const body = el('div', 'dialog-body'); + build(body); + box.appendChild(body); + + const foot = el('div', 'dialog-foot'); + if (cancel) { + const no = el('button', 'btn', 'Cancel'); + no.onclick = closeDialog; + foot.appendChild(no); + } + const ok = el('button', 'btn primary', confirmLabel); + ok.onclick = () => { if (onConfirm() !== false) closeDialog(); }; + foot.appendChild(ok); + box.appendChild(foot); + + scrim.appendChild(box); + scrim.onclick = (e) => { if (e.target === scrim && !blocking) closeDialog(); }; + document.body.appendChild(scrim); + openDialogEl = scrim; + + const first = box.querySelector('input, select, button.primary'); + if (first) first.focus(); + return box; +} + + +/** A labelled number field, returned with a .value getter. */ +export function numberField(parent, { label, value, min, max, unit, hint }) { + const wrap = el('label', 'field'); + wrap.appendChild(el('span', 'field-label', label)); + const row = el('div', 'field-row'); + const input = el('input'); + input.type = 'number'; + input.value = String(value ?? 0); + if (min != null) input.min = String(min); + if (max != null) input.max = String(max); + row.appendChild(input); + if (unit) row.appendChild(el('span', 'field-unit', unit)); + wrap.appendChild(row); + if (hint) wrap.appendChild(el('span', 'field-hint', hint)); + parent.appendChild(wrap); + return input; +} + +export function toggleField(parent, { label, checked }) { + const row = el('label', 'field-toggle'); + row.appendChild(el('span', null, label)); + const input = el('input'); + input.type = 'checkbox'; + input.checked = !!checked; + row.appendChild(input); + parent.appendChild(row); + return input; +} + +/** + * A modal that cannot be dismissed, for an operation the machine is in the middle of. + * + * The shipped page blocks the surface while a toolchange runs, and it is right to: the + * gantry is moving, and a second command sent into that window is not something the + * user meant. No close button, no scrim click, no Escape - it goes away when the caller + * says the operation is over. + * + * Returns { update(text), fail(text), close() }. + */ +export function openBlockingDialog({ title, message }) { + closeDialog(); + const scrim = el('div', 'scrim'); + const box = el('div', 'dialog blocking'); + + const head = el('div', 'dialog-head'); + head.appendChild(el('h3', null, title)); + box.appendChild(head); + + const body = el('div', 'dialog-body'); + const spinner = el('div', 'spinner'); + body.appendChild(spinner); + const msg = el('p', 'blocking-msg', message || ''); + body.appendChild(msg); + box.appendChild(body); + + scrim.appendChild(box); + document.body.appendChild(scrim); + openDialogEl = scrim; + blocking = true; + + return { + update(text) { msg.textContent = text; }, + fail(text) { + msg.textContent = text; + spinner.remove(); + blocking = false; // let the user out again + const foot = el('div', 'dialog-foot'); + const ok = el('button', 'btn primary', 'Close'); + ok.onclick = closeDialog; + foot.appendChild(ok); + box.appendChild(foot); + }, + close() { blocking = false; closeDialog(); }, + }; +} + + +/* + * Escape closes a dialog - unless it is a blocking one, which is the point of those: + * the machine is mid-procedure and leaving early is not a thing the operator can do. + * Registered here rather than in a surface, so a page that imports this module gets the + * behaviour and does not have to remember to. + */ +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !blocking) closeDialog(); +}); diff --git a/resources/web/device_page/js/core/dom.js b/resources/web/shared/js/dom.js similarity index 73% rename from resources/web/device_page/js/core/dom.js rename to resources/web/shared/js/dom.js index 84c014d50d2..8f9e6f04e40 100644 --- a/resources/web/device_page/js/core/dom.js +++ b/resources/web/shared/js/dom.js @@ -4,6 +4,11 @@ * These lived inside the one big ui.js, where only that file could reach them. Every * panel's view needs the same three, and a second copy is how two spellings of `icon()` * start. + * + * SHARED by both surfaces. They grew on the Device page; the print dialog is the + * second page to build with them, which is the only way to find out whether they + * generalise. `icon()` resolves against the PAGE, not against this file, so each + * surface brings its own icons/ directory. */ 'use strict'; diff --git a/resources/web/shared/js/mockhost.js b/resources/web/shared/js/mockhost.js index ab6dd7825e9..2001d76b277 100644 --- a/resources/web/shared/js/mockhost.js +++ b/resources/web/shared/js/mockhost.js @@ -20,6 +20,9 @@ import { OK_CODE } from './sswcp.js'; const TICK_MS = 1000; +/** An empty but structurally valid zip, so the send path has something to fetch. */ +const ZIP_STUB = 'UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=='; + /** A 1x1 PNG, so thumbnail plumbing can be exercised without real artwork. */ const PNG_1PX = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; @@ -30,9 +33,13 @@ const PNG_1PX = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B * @param {Function} opts.log trace sink (kind, packet) * @param {object} opts.handlers extra commands: cmd -> (params, ctx) => data * @param {object} opts.printer override the simulated printer + * @param {Function} opts.onDialogClose called by sw_FinishFilamentMapping with the + * recorded outcome. There is no modal here to end, + * so this is how a surface learns it was closed. * @returns {{printer, stop}|null} null if a real host is already present */ -export function installMockHost({ log = () => {}, handlers = {}, printer: given = null } = {}) { +export function installMockHost({ log = () => {}, handlers = {}, printer: given = null, + onDialogClose = null } = {}) { if (window.wx && window.wx.__isMock !== true && typeof window.wx.postMessage === 'function') { return null; // a real host is present; never shadow it } @@ -482,6 +489,137 @@ export function installMockHost({ log = () => {}, handlers = {}, printer: given case 'sw_FileLog': case 'sw_Log': case 'sw_UploadEvent': case 'sw_SetLogLevel': return ok({}); + /* ---- Orca's side of the print-processing popup --------------------- + * + * These are answered by ORCA, not by the printer, so none of them is in + * PRINTER_BACKED and none comes back inside a JSON-RPC envelope. They live here + * rather than in the popup's own mock because the U1 they talk about is the one + * simulated above: the machine half of every reply below is read off `printer`, + * so the dialog cannot be shown a machine the rest of the simulation disagrees + * with. See `printer.job`. + */ + case 'sw_GetActiveFile': { + const j = printer.job; + if (!j.filepath || !j.filename) return fail('mock: no active file'); + if (params.is_zip) { + return ok({ file_name: j.filename.replace(/\.gcode$/, '.zip'), + file_path: j.filepath.replace(/\.gcode$/, '.zip'), + origin_size: j.sizeBytes, metadata: {} }); + } + return ok({ filename: j.filename, metadata: {} }); + } + + /* + * The reply shape is SSWCP.cpp:3039's, verbatim: PARALLEL ARRAYS, plus the + * rendered plate. Not `{filaments: [...]}` - see printer.job. + */ + case 'sw_GetFileFilamentMapping': { + const j = printer.job; + const argb = (hex) => { + const h = String(hex).replace('#', ''); + return parseInt(h.slice(0, 6), 16); + }; + return ok({ + filename: j.filename, + filepath: j.filepath, + machine_model: j.presetModel, + estimated_time: j.estimatedTime, + filament_type: j.fileFilamentType.slice(), + filament_color: j.fileFilamentColor.map(argb), + filament_color_rgba: j.fileFilamentColor.slice(), + filament_color_multi: j.fileFilamentColorMulti.slice(), + filament_weight: j.fileFilamentWeight.slice(), + filament_weight_total: j.fileFilamentWeight.reduce((a, b) => a + b, 0), + filament_used_mm: j.fileFilamentUsedMm.slice(), + nozzle_diameters: j.fileNozzle.slice(), + nozzle_info: [...new Set(j.fileNozzle)], + filament_extruder_map: Object.assign({}, j.extruderMap), + // `thumbnails[0].url` is a data: PNG. One pixel is enough to prove the + // plumbing; what matters is that the popup reads it instead of a grey box. + thumbnails: [{ url: `data:image/png;base64,${PNG_1PX}`, width: 512, height: 512 }], + }); + } + + /* + * The right door for the file. Returns a URL on Orca's own page server, the size + * and a SHA-256 - NOT the zip's bytes. `sw_GetPrintZip` below is the other one, + * and it is implemented honestly so the cost of using it is visible. + */ + case 'sw_GetFileStream': { + const j = printer.job; + const zip = j.filename.replace(/\.gcode$/, '.zip'); + /* + * A FETCHABLE url. It used to be `http://127.0.0.1:0/…` - port zero, which + * nothing serves - so `fetch` failed with "Load failed" and the send path + * could not be exercised in the simulator at all. That went unnoticed because + * no check had ever pressed Send here; the first thing to try it was a script + * being smoke-tested before it ran against a real machine. + * + * A `data:` URL is the honest stand-in: the page fetches it and gets a blob, + * which is all it does with Orca's own localhost URL. + */ + return ok({ file_name: params.is_zip ? zip : j.filename, + file_url: `data:application/zip;base64,${ZIP_STUB}`, + origin_size: j.sizeBytes, + checksum: j.checksum }); + } + + /* + * `m_res_data["content"] = res["zip_data"]` where zip_data is a + * std::vector, which nlohmann serialises as one integer per byte. A 12 MB + * zip crosses as ~40 MB of JSON. The mock returns a token array of the right + * KIND rather than the right length - the point is that it is an array of + * numbers, not a base64 string, which is what a client guesses. + */ + case 'sw_GetPrintZip': { + const j = printer.job; + return ok({ name: `${j.filename}.zip`, content: [80, 75, 3, 4, 20, 0, 0, 0] }); + } + + case 'sw_GetPrintLegal': { + const j = printer.job; + const connected = params.connected_model || ''; + return ok({ preset_model: j.presetModel, legal: j.presetModel === connected }); + } + + /* + * The precondition is the handler's own (SSWCP.cpp:2595). A mock that accepted + * `{}` is exactly how the reconstruction came to send `{}` and never find out. + */ + case 'sw_StartLocalPrint': { + if (params.type == null || params.path == null) { + return fail('param [type] or [path] required!'); + } + printer.job.startedWith = { type: params.type, path: params.path }; + return ok({ task_id: 'mock-task-1' }); + } + case 'sw_StartCloudPrint': + printer.job.startedWith = Object.assign({ cloud: true }, params); + return ok({ task_id: 'mock-task-1' }); + + /* + * The close protocol, in the host's own three parts. Recording the outcome and + * ending the dialog are SEPARATE commands, and only the second closes anything - + * SafeEndModal exists because the page, the window manager and OnClose can all + * race to end the same dialog. See 03-print-processing/02-lifecycle.md + */ + case 'sw_FinishPreprint': + printer.job.preprintStatus = params.status || null; + return ok({}); + case 'sw_SetFilamentMappingComplete': { + const st = params.status; + if (st !== 'success' && st !== 'canceled') { + // Orca raises a native "setting failed" dialog and does NOT record anything. + return fail(`mock: setting failed (status ${JSON.stringify(st)})`); + } + printer.job.mappingOutcome = st; + return ok({}); + } + case 'sw_FinishFilamentMapping': + printer.job.closed = true; + if (onDialogClose) onDialogClose(printer.job.mappingOutcome === 'success'); + return ok({}); + default: return fail(`mock: unhandled command ${cmd}`); } @@ -715,6 +853,47 @@ export function makePrinter() { filamentVendor: ['Snapmaker', 'Snapmaker', 'Snapmaker', 'Generic'], filamentType: ['PLA', 'PLA', 'PETG', 'ABS'], filamentColorRgba: ['E03131FF', '1971C2FF', '2F9E44FF', 'F08C00FF'], + + /* + * ---- the JOB: what Orca is about to send ------------------------------- + * + * The print dialog's whole purpose is to match the FILE's filaments against the + * MACHINE's, so those two lists must not be invented separately. They are not: the + * machine's are `filamentType` / `filamentColorRgba` / `toolheads[].nozzle_diameter` + * above, and the file's default to the same values here - so the default job is + * satisfiable, and a check that wants a mismatch has to CREATE one by moving one of + * them. That is the difference between a match test and a self-fulfilling one. + * + * The previous mock for this surface invented `{filaments: [{type, color, used_g}]}`, + * a shape `sw_GetFileFilamentMapping` has never returned. It agreed with the client + * that was written from the same head, so the popup drew four rows in the simulator + * and none in Orca. The arrays below are the handler's own - SSWCP.cpp:3039. + */ + job: { + filepath: '/tmp/SnapmakerOrca/plate_1.gcode', + filename: 'benchy_4colour.gcode', + // sw_GetPrintLegal compares this against the CONNECTED machine's model_name. + presetModel: 'Snapmaker U1', + estimatedTime: 4412, + // one entry per FILE filament; the defaults mirror the four heads + fileFilamentType: ['PLA', 'PLA', 'PETG', 'ABS'], + fileFilamentColor: ['#E03131', '#1971C2', '#2F9E44', '#F08C00'], + // Read but never drawn by the shipped popup: a gradient or segmented spool. + // `null` for a plain one; `{mode, colors}` otherwise, mode 0 bands / 1 gradient. + fileFilamentColorMulti: [null, null, null, null], + fileFilamentWeight: [12.4, 9.1, 6.7, 3.2], + fileFilamentUsedMm: [4123, 3027, 2229, 1064], + // what the FILE was sliced for, per filament - checked against the head's own + fileNozzle: ['0.4', '0.4', '0.4', '0.4'], + // Orca's own filament -> extruder map, from AppConfig. The popup starts here. + extruderMap: { 0: '0', 1: '1', 2: '2', 3: '3' }, + sizeBytes: 12684221, + checksum: 'kBqDdG0mZ0nH0mockCHECKSUMbase64PADDINGxxxxxxx=', + /** Set by the close protocol so a check can read what the page reported. */ + preprintStatus: null, mappingOutcome: null, closed: false, + /** What sw_StartLocalPrint was actually given. */ + startedWith: null, + }, /* * The three job toggles, mutable because SET_PRINT_PREFERENCES writes them. Keyed by * the field name the machine REPORTS them under; the macro's own argument for the @@ -924,7 +1103,7 @@ export function makePrinter() { * success, and ACE_BG_UNLOAD's own help says ~3 min - so it confirms what it asked for * against machine state instead. That only means anything if the state actually moves, * which is what this is for: the simulator answers `ok` AND changes, so - * core/pending.js has something to resolve against. + * shared/pending.js has something to resolve against. * * Deliberately instant. Modelling the three minutes belongs in a test that wants to * watch a wait time out, not in the default path. diff --git a/resources/web/device_page/js/core/pending.js b/resources/web/shared/js/pending.js similarity index 97% rename from resources/web/device_page/js/core/pending.js rename to resources/web/shared/js/pending.js index 8734110f94d..fbaa6678995 100644 --- a/resources/web/device_page/js/core/pending.js +++ b/resources/web/shared/js/pending.js @@ -28,6 +28,9 @@ * * Nothing here touches the DOM, so it runs in JavaScriptCore under unit_jsc.py against * whatever clock the test hands it. + * + * SHARED by both surfaces. The print dialog holds a filament assignment with it for + * exactly the reason above: the machine echoes `print_task_config` a second later. */ 'use strict'; diff --git a/resources/web/shared/js/protocol.js b/resources/web/shared/js/protocol.js index 8651f498b09..2cfd7848c2d 100644 --- a/resources/web/shared/js/protocol.js +++ b/resources/web/shared/js/protocol.js @@ -123,7 +123,15 @@ export const CMD = { // ---- print-processing popup only (?path=4 / ?path=5) ------------------ GET_ACTIVE_FILE: 'sw_GetActiveFile', GET_PRINT_LEGAL: 'sw_GetPrintLegal', // { connected_model } -> { legal, preset_model } - GET_PRINT_ZIP: 'sw_GetPrintZip', // -> { name, content } + // The RIGHT door for the file: { is_zip } -> { file_name, file_url, origin_size, + // checksum }. `file_url` is a localhost URL on Orca's own page HTTP server - the one + // already serving this document - so the page fetches the zip rather than being + // handed it. NOT printer-backed: both branches build m_res_data and send_to_js(). + GET_FILE_STREAM: 'sw_GetFileStream', + // The other one, kept named because it is what the reconstruction used to call: + // `content` comes from a std::vector, which serialises as one JSON integer per + // byte. A 12 MB zip crosses as ~40 MB of JSON. + GET_PRINT_ZIP: 'sw_GetPrintZip', // -> { name, content: number[] } GET_FILE_FILAMENT_MAPPING: 'sw_GetFileFilamentMapping', // { filename } UPDATE_MACHINE_FILAMENT_INFO: 'sw_UpdateMachineFilamentInfo', SET_FILAMENT_MAPPING_COMPLETE: 'sw_SetFilamentMappingComplete', // { status } @@ -609,6 +617,15 @@ export const PURIFIER_MODES = { 0: 'Off', 1: 'Recirculation Mode', 2: 'Exhaust M * * Derived from SSWCP.cpp by finding every handler whose body reaches * `on_mqtt_msg_arrived`, and re-derived by the conformance suite so it cannot drift. + * + * Two were wrong until the derivation learned to end a handler at its own closing brace + * rather than at the next `sw_*` signature: `sw_GetFileStream` and + * `sw_UnsubscribeCacheKeys` both build `m_res_data` and call `send_to_js()`, and were + * credited with an `on_mqtt_msg_arrived` belonging to a function declared between them + * and the next handler the regex could see. The list and the check were written from the + * same derivation, so they agreed with each other and not with the C++. `sw_GetFileStream` + * is the one that would have bitten: it is how the print dialog fetches the file, and a + * client unwrapping it looks one level too deep for `file_url`. */ export const PRINTER_BACKED = new Set([ 'sw_BedMesh_AbortProbeMesh', 'sw_CameraStartMonitor', 'sw_CameraStopMonitor', @@ -617,14 +634,14 @@ export const PRINTER_BACKED = new Set([ 'sw_ControlPurifier', 'sw_DefectDetactionConfig', 'sw_DeleteCameraTimelapse', 'sw_DeleteMachineFile', 'sw_FileGetStatus', 'sw_FilesThumbnailsBase64', 'sw_GetCameraTimelapseInstance', 'sw_GetDeviceDataStorageSpace', 'sw_GetFileListPage', - 'sw_GetFileStream', 'sw_GetMachineObjects', 'sw_GetMachineState', 'sw_GetPrintHistory', 'sw_GetPrintInfo', + 'sw_GetMachineObjects', 'sw_GetMachineState', 'sw_GetPrintHistory', 'sw_GetPrintInfo', 'sw_GetSystemInfo', 'sw_MachineFilesGetDirectory', 'sw_MachineFilesMetadata', 'sw_MachineFilesRoots', 'sw_MachineFilesThumbnails', 'sw_MachineHeartbeat', 'sw_MachinePrintCancel', 'sw_MachinePrintPause', 'sw_MachinePrintResume', 'sw_MachinePrintStart', 'sw_PrinterDefectDetection', 'sw_PullCloudFile', 'sw_SendGCodes', 'sw_ServerClientManagerSetUserinfo', 'sw_SetDeviceName', 'sw_SetMachineSubscribeFilter', 'sw_StartCloudPrint', 'sw_StartLocalPrint', 'sw_SystemGetDeviceInfo', - 'sw_UnSubscribeMachineState', 'sw_UnsubscribeCacheKeys', + 'sw_UnSubscribeMachineState', 'sw_UploadAsyncTimelapseInstance', 'sw_UploadCameraTimelapse', 'sw_exception_query' ]); diff --git a/resources/web/device_page/js/core/render.js b/resources/web/shared/js/render.js similarity index 98% rename from resources/web/device_page/js/core/render.js rename to resources/web/shared/js/render.js index 6ea12b3372b..e468ce01b36 100644 --- a/resources/web/device_page/js/core/render.js +++ b/resources/web/shared/js/render.js @@ -13,6 +13,9 @@ * those was noticed separately and fixed with a guard invented on the spot - which is * how there came to be four disciplines rather than one. * + * SHARED by both surfaces - see shared/dom.js on why these moved out of the Device + * page's core/. + * * The rule these primitives encode: **build structure once, then write only what * differs.** Structure changes are keyed by a signature, so they happen when the shape * genuinely changes rather than on every tick; lists are reconciled by key, so a list diff --git a/resources/web/shared/js/sswcp.js b/resources/web/shared/js/sswcp.js index c700c925b4c..0ec376cb899 100644 --- a/resources/web/shared/js/sswcp.js +++ b/resources/web/shared/js/sswcp.js @@ -18,7 +18,39 @@ */ 'use strict'; -const TIMEOUT_MS = 15000; +import { PRINTER_BACKED } from './protocol.js'; + +/** + * How long to wait for a reply. + * + * TWO clocks, because there are two very different things being waited for, and using + * one number for both is a mistake this project has now made twice at two layers. + * + * A command Orca answers out of its own state comes back in milliseconds or not at all; + * 15 s is generous. A command that reaches the PRINTER is queued, and Klipper runs + * G-code sequentially - it answers when the queue drains, not when it is asked. + * + * Measured on 811002511261022618B3, with `G4` blocking the queue by a known amount and + * nothing else running: + * + * queue empty sw_MachinePrintCancel 197 ms + * behind G4 P3000 3323 ms + * behind G4 P6000 6213 ms + * + * The round trip is the queue plus about a quarter of a second, every time. So a cancel + * sent to a print that has just started waits for the homing move - which is exactly + * what happened: the machine reached `cancelled` in ~10 s while the client gave up at + * 15 and reported a failure on a cancel that had worked. + * + * `u1_bridge.py` learned this once already and set its own RPC_TIMEOUT to 80 s, after a + * 31 s toolchange came back as "the printer refused the command". This is the same + * lesson one layer up, and PRINTER_BACKED is exactly the set it applies to: those are + * the commands whose replies come back through the printer's own envelope. + */ +const TIMEOUT_MS = 15000; // Orca answers it, or nothing does +const PRINTER_TIMEOUT_MS = 80000; // the printer answers it, when it gets to it + +const timeoutFor = (cmd) => (PRINTER_BACKED.has(cmd) ? PRINTER_TIMEOUT_MS : TIMEOUT_MS); /** Orca's success code. 200, not 0 - see the note above. */ export const OK_CODE = 200; @@ -138,11 +170,12 @@ export class Sswcp { header: { seqid }, payload: { cmd, event_id: null, params, metadata: null }, }; + const wait = timeoutFor(cmd); return new Promise((resolve, reject) => { const timer = setTimeout(() => { this._pending.delete(seqid); - reject(new Error(`${cmd} timed out after ${TIMEOUT_MS}ms`)); - }, TIMEOUT_MS); + reject(new Error(`${cmd} timed out after ${wait}ms`)); + }, wait); this._pending.set(seqid, { resolve, reject, timer, cmd }); try { this._post(packet); @@ -171,7 +204,8 @@ export class Sswcp { const timer = setTimeout(() => { this._pending.delete(seqid); // The ack is best-effort: some builds push before acking. Keep the - // subscription alive rather than tearing it down on a slow ack. + // subscription alive rather than tearing it down on a slow ack. A subscription + // ack is not queued behind machine work, so it keeps the short clock. resolve({ eventId, ack: null, cancel: () => this.cancel(eventId) }); }, TIMEOUT_MS); this._pending.set(seqid, { diff --git a/resources/web/device_page/js/widgets/trace.js b/resources/web/shared/js/trace.js similarity index 59% rename from resources/web/device_page/js/widgets/trace.js rename to resources/web/shared/js/trace.js index 3f48d169feb..1fedb342e61 100644 --- a/resources/web/device_page/js/widgets/trace.js +++ b/resources/web/shared/js/trace.js @@ -1,9 +1,16 @@ /* - * trace.js - the live WCP trace pane. A development aid, not part of the shipped page. + * trace.js - the live WCP trace pane. + * + * A development aid, not part of either shipped surface, and shared because both of them + * want the same one: `--watch` turns the terminal into a running list of what each click + * sent, and the pane is where that is read on screen. + * + * The Device page's own filament artwork used to live in this file and moved out with + * it - it is that page's drawing, not a trace concern. */ 'use strict'; -import { $, el } from '../core/dom.js'; +import { el } from './dom.js'; /* ---- trace ---------------------------------------------------------- */ export function makeTrace(pane) { @@ -20,11 +27,3 @@ export function makeTrace(pane) { }; } -/* ---- filament ------------------------------------------------------- */ - -/** - * Four slots, drawn on the bundle's own extruder artwork. - * - * Values come from `print_task_config` - the same object the print-processing - * popup edits. See docs/u1-webui/00-shared/01-shared-models.md - */ diff --git a/resources/web/shared/tests/conformance_test.py b/resources/web/shared/tests/conformance_test.py index d0108c1c885..b34d7fccafa 100644 --- a/resources/web/shared/tests/conformance_test.py +++ b/resources/web/shared/tests/conformance_test.py @@ -253,7 +253,7 @@ def all_views(): return "\n".join(out) -cfile = os.path.join(JS, "core", "connection.js") +cfile = os.path.join(SHARED, "js", "connection.js") if os.path.exists(cfile): conn_src = open(cfile, encoding='utf-8').read() check("connection.js exists", bool(conn_src)) @@ -389,13 +389,35 @@ def js_const(name, source=src): SRC_CPP = os.path.join(ROOT, "src", "slic3r", "GUI", "SSWCP.cpp") cpp = open(SRC_CPP, encoding="utf-8", errors="replace").read() -starts = [(m.group(1), m.end()) for m in - re.finditer(r"void\s+SSWCP_\w+::(sw_\w+)\s*\([^)]*\)\s*\{", cpp)] +def _body(text, open_brace): + """The handler's own body, by brace matching. + + This used to run from one `sw_*` signature to the next, which is not the same thing: + the regex only sees handlers NAMED sw_*, so anything else declared between two of + them landed inside the earlier one's span. `sw_GetFileStream` was classified + printer-backed on the strength of an `on_mqtt_msg_arrived` inside `test_mqtt_request` + six functions further down - and because PRINTER_BACKED was written from this same + derivation, the check agreed with itself. Both branches of that handler build + `m_res_data` and call `send_to_js()`; nothing about it is a printer reply, and a + client that unwrapped it would have looked one level too deep for `file_url`. + """ + depth, i = 0, open_brace + while i < len(text): + c = text[i] + if c == '{': + depth += 1 + elif c == '}': + depth -= 1 + if depth == 0: + return text[open_brace:i + 1] + i += 1 + return text[open_brace:] + + derived = set() -for i, (name, pos) in enumerate(starts): - end = starts[i + 1][1] if i + 1 < len(starts) else len(cpp) - if "on_mqtt_msg_arrived" in cpp[pos:end]: - derived.add(name) +for m in re.finditer(r"void\s+SSWCP_\w+::(sw_\w+)\s*\([^)]*\)\s*\{", cpp): + if "on_mqtt_msg_arrived" in _body(cpp, m.end() - 1): + derived.add(m.group(1)) listed = set(re.findall(r"'(sw_\w+)'", re.search(r"export const PRINTER_BACKED = new Set\(\[(.*?)\]\);", @@ -472,8 +494,8 @@ def js_const(name, source=src): # Every panel's declaration, concatenated: several checks below ask "does any control # on the page do X", and a panel module is where that is now written down. panels_all = "\n".join(panel_src.values()) -pending_src = open(os.path.join(JS, "core", "pending.js"), encoding="utf-8").read() -render_src = open(os.path.join(JS, "core", "render.js"), encoding="utf-8").read() +pending_src = open(os.path.join(SHARED, "js", "pending.js"), encoding="utf-8").read() +render_src = open(os.path.join(SHARED, "js", "render.js"), encoding="utf-8").read() # Having a printer on the other end of the bridge - the connect path, staleness, retry, # the heartbeat and the state stream - left app.js for its own module. session_src = open(os.path.join(JS, "core", "session.js"), encoding="utf-8").read() diff --git a/resources/web/shared/tests/drive/README.md b/resources/web/shared/tests/drive/README.md index 78c0d17b1b6..74d57424612 100644 --- a/resources/web/shared/tests/drive/README.md +++ b/resources/web/shared/tests/drive/README.md @@ -25,6 +25,12 @@ nobody runs. | `homing-real.js` | The same dialog against a **real G28**, and the only witness that counts - this one MOVES THE MACHINE. Two attempts at this wait passed every offline check and closed the dialog over a moving bed; what settled it was pressing the button with the printer in sight. Needs `--real`, and Orca closed. Takes a minute. | | `orca-sync.js` | What the page sends to **Orca** rather than to the printer, and the three writes that were going to the wrong one of the two. Checks the filament inventory reaches Orca unprompted and in the exact shape `update_filament_info()` parses, that an unchanged one is not re-sent, that naming a filament and applying the print preferences send G-code macros, and that signing in opens Orca's own dialog rather than a form on this page. 22 checks. | | `task-card.js` | That the Printing Task panel is as tall as its card. It was pinned at 150 px against a 304 px card, and `.panel-body` hides its overflow — so the progress bar and both job buttons were cut off with nothing to say they had been. | +| `print-mockups.js` | One of the three **print-dialog mockups**, in the dialog's own 714 × 750. Dumps the tree, then asserts what would be quietly wrong: the plate thumbnail visible without a click *and* decoded, a way to choose a destination per filament, Send agreeing with the scenario, and an edit reaching the model and repainting. Needs `--page`, not the Device page. It found a name column squeezed to 22 px and a lane that had fallen into the wire gutter — neither of which the screenshots showed. | +| `print-dialog.js` | The rebuilt **print dialog** against the simulator, in the 714 × 750 the host opens. Every geometry assertion is a row of the specification mockup, checked after a real layout; then the behaviour the bundle carries - a toolhead whose type or nozzle does not match is passed `enabled: false` and **cannot be picked**, which this checks by clicking one and asserting nothing moved. 43 checks. | +| `print-dialog-real.js` | The same dialog against `u1_bridge.py` with a **real sliced plate** (`--gcode`) and no printer (`--device-ip 192.0.2.1`). The filament list, the weights and the thumbnail all come out of the file Orca's own slicer wrote, so this is the Orca half on real data rather than a fixture. **Read-only on purpose**: the send path ends in `sw_StartLocalPrint`. 15 checks. | +| `print-dialog-machine.js` | The dialog against a **connected U1**, and the first thing here to see one. Prints `print_task_config` and the extruder objects raw — this surface had never seen either from hardware — then checks the refusal rule against whatever the machine actually holds, recomputed from the raw objects so a bug in the page cannot make it agree with itself. **Read-only**: it opens the picker and never picks. | +| `print-cancel-real.js` | Whether `sw_MachinePrintCancel` reaches this firmware, asked **before** anything is sent to it. Refuses outright if a print is in progress. Proves the route and the round trip; it cannot prove a running print stops, and says so. | +| `cancel-latency-real.js` | Why a machine command answered slowly. Blocks Klipper's queue with `G4` — a dwell, so **nothing moves** — and times a cancel behind it. Measured `dwell + ~250 ms` every time, which is what turned "this firmware does not answer a cancel" into "the client's clock was too short". Run it before concluding a command was ignored. | ```bash R=resources/web/shared/tests @@ -38,6 +44,19 @@ python3 $R/run_webkit.py --real --size 1920x1080 --drive $R/drive/camera-real.js python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/ace-panel.js python3 $R/run_webkit.py --size 1920x1080 --drive $R/drive/orca-sync.js python3 $R/run_webkit.py --real --size 1920x1080 --drive $R/drive/ace-real.js +python3 $R/run_webkit.py --size 714x750 --drive $R/drive/print-mockups.js \ + --page 'web/print_processing/mockups/option-b.html?scenario=mismatch' +python3 $R/run_webkit.py --size 714x750 --drive $R/drive/print-dialog.js \ + --page 'web/print_processing/index.html?mock=1' +python3 $R/run_webkit.py --real --device-ip 192.0.2.1 --size 714x750 \ + --gcode ~/models/plate_1.gcode --page web/print_processing/index.html \ + --drive $R/drive/print-dialog-real.js +python3 $R/run_webkit.py --real --sn --size 714x750 --settle 25 \ + --gcode ~/models/plate_1.gcode --page web/print_processing/index.html \ + --drive $R/drive/print-dialog-machine.js +python3 $R/run_webkit.py --real --sn --size 714x750 --settle 25 \ + --gcode ~/models/plate_1.gcode --page web/print_processing/index.html \ + --drive $R/drive/print-cancel-real.js python3 $R/run_webkit.py --real --device-ip 192.0.2.1 --drive $R/drive/no-printer.js ``` diff --git a/resources/web/shared/tests/drive/cancel-latency-real.js b/resources/web/shared/tests/drive/cancel-latency-real.js new file mode 100644 index 00000000000..288551e22fc --- /dev/null +++ b/resources/web/shared/tests/drive/cancel-latency-real.js @@ -0,0 +1,106 @@ +/* + * Why did a cancel take longer than the client waits? + * + * python3 resources/web/shared/tests/run_webkit.py --real --sn --size 714x750 \ + * --settle 25 --gcode ~/models/plate.gcode \ + * --page web/print_processing/index.html \ + * --drive resources/web/shared/tests/drive/cancel-latency-real.js + * + * Measured once, on a print that had just started: `sw_MachinePrintCancel` did not answer + * inside the client's 15 s, while `print_stats` reached `cancelled` in about 10. The + * conclusion drawn from that - "this firmware does not answer a cancel" - was a guess + * with one data point behind it. + * + * The better explanation is that Klipper runs G-code SEQUENTIALLY: a command issued + * behind a blocking one waits for it, and a print that has just started is homing. If + * that is what happened then the reply is not lost, it is queued, and how long a caller + * should wait is a different question from whether it should wait at all. + * + * This separates the two WITHOUT printing anything. `G4` is a dwell: it blocks the queue + * for a known time and moves nothing, heats nothing and extrudes nothing. If a cancel + * behind a 6 s dwell takes ~6 s, the queue is the explanation and the firmware is fine. + * + * Nothing here moves the machine. It refuses outright if anything is printing. + */ +(function () { + const L = []; + const say = (s) => L.push(s); + const check = (n, got, want) => + L.push(`${got === want ? 'PASS' : 'FAIL'} ${n}` + + (got === want ? '' : ` got ${JSON.stringify(got)} want ${JSON.stringify(want)}`)); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + async function timed(label, fn) { + const t0 = Date.now(); + let out = null; + let err = null; + try { out = await fn(); } catch (e) { err = e.message; } + const ms = Date.now() - t0; + say(` ${String(ms).padStart(6)} ms ${label}` + + (err ? ` FAILED ${err}` : ` -> ${JSON.stringify(out)}`)); + return { ms, out, err }; + } + + const run = async () => { + const pp = window.__preprint; + if (!pp) { window.__report = 'FAIL no window.__preprint'; return; } + const st = () => ((pp.state.objects || {})['print_stats'] || {}).state; + + check('the machine half came up', pp.model.connected, true); + say(`print_stats.state: ${JSON.stringify(st())}`); + if (st() === 'printing' || st() === 'paused') { + say('REFUSED something is printing. Not queueing dwells behind a live job.'); + window.__report = L.join('\n'); + return; + } + + /* ---- 1. the baseline: nothing in the queue ---- */ + say(''); + say('--- cancel with an empty queue ---'); + const base = await timed('sw_MachinePrintCancel', () => + pp.bridge.request(pp.CMD.PRINT_CANCEL, {})); + check('an idle cancel answers quickly', base.ms < 2000, true); + + /* ---- 2. the same cancel, behind a dwell ---- + G4 is a pure delay: no motion, no heater, no extruder. If the round trip tracks + the dwell then the queue is what a cancel waits for. */ + for (const ms of [3000, 6000]) { + say(''); + say(`--- cancel behind G4 P${ms} (a dwell: nothing moves) ---`); + // The dwell itself is fire-and-forget; what is being timed is what follows it. + const dwell = pp.bridge.request(pp.CMD.SEND_GCODES, { script: `G4 P${ms}` }); + await sleep(150); + const behind = await timed(`sw_MachinePrintCancel behind ${ms} ms`, () => + pp.bridge.request(pp.CMD.PRINT_CANCEL, {})); + await dwell.catch(() => {}); + const tracked = behind.ms > ms * 0.6; + check(`a cancel behind a ${ms} ms dwell waits for it`, tracked, true); + say(` ${behind.ms} ms against a ${ms} ms dwell` + + ` (baseline was ${base.ms} ms)`); + await sleep(500); + } + + say(''); + say('--- what this means ---'); + say(' If the numbers above track the dwell, the cancel was never lost on that'); + say(' print - it was queued behind the homing move a starting job makes, and the'); + say(' client gave up at 15 s while the firmware was still working through it.'); + say(' The reply is late, not absent, and a caller has to either wait longer or'); + say(' confirm against print_stats rather than against the ack.'); + + say(''); + say(`print_stats.state at the end: ${JSON.stringify(st())}`); + check('nothing was left running', st() === 'printing' || st() === 'paused', false); + + window.__report = L.join('\n'); + }; + + let n = 0; + const tick = () => { + if ((window.__preprint && window.__preprint.ready && window.__preprint.model.connected) + || n++ > 200) setTimeout(() => { run().catch((e) => { + window.__report = `${L.join('\n')}\nFAIL threw: ${e && e.message}`; }); }, 1200); + else setTimeout(tick, 200); + }; + tick(); +})(); diff --git a/resources/web/shared/tests/drive/print-cancel-real.js b/resources/web/shared/tests/drive/print-cancel-real.js new file mode 100644 index 00000000000..a3e15b74d92 --- /dev/null +++ b/resources/web/shared/tests/drive/print-cancel-real.js @@ -0,0 +1,103 @@ +/* + * Can this machine be told to stop? Answered BEFORE anything is sent to it. + * + * python3 resources/web/shared/tests/run_webkit.py --real --sn --size 714x750 \ + * --settle 25 --gcode ~/models/plate_1.gcode \ + * --page web/print_processing/index.html \ + * --drive resources/web/shared/tests/drive/print-cancel-real.js + * + * The order matters and is the whole point: a send path that has never been exercised + * should not be exercised first. `sw_MachinePrintCancel` is the only way back from a + * print this dialog starts, and it has never been sent to this firmware by anything + * here. Proving the route while the machine is IDLE costs nothing and settles the + * precondition; discovering it is wrong with a job running costs a plate and a bed. + * + * What this can and cannot show: + * + * it CAN that the command routes - page to Orca's bridge to MQTT to Klipper - and + * what an idle machine answers, which is the reply a caller has to handle + * it CANNOT that a RUNNING print stops. Nothing short of running one shows that, and + * that is a decision for a person standing at the machine. + * + * It refuses to send anything at all if a print is in progress, which would be the one + * situation where this script could do harm. + */ +(function () { + const L = []; + const say = (s) => L.push(s); + const check = (n, got, want) => + L.push(`${got === want ? 'PASS' : 'FAIL'} ${n}` + + (got === want ? '' : ` got ${JSON.stringify(got)} want ${JSON.stringify(want)}`)); + + const run = async () => { + const pp = window.__preprint; + if (!pp) { window.__report = 'FAIL no window.__preprint'; return; } + const objs = pp.state.objects || {}; + const stats = objs['print_stats'] || {}; + const sd = objs['virtual_sdcard'] || {}; + const idleT = objs['idle_timeout'] || {}; + + check('the machine half came up', pp.model.connected, true); + + say(''); + say('--- is anything printing? ---'); + say(` print_stats.state ${JSON.stringify(stats.state)}`); + say(` print_stats.filename ${JSON.stringify(stats.filename)}`); + say(` print_duration ${JSON.stringify(stats.print_duration)}`); + say(` virtual_sdcard is_active=${JSON.stringify(sd.is_active)} ` + + `progress=${JSON.stringify(sd.progress)}`); + say(` idle_timeout.state ${JSON.stringify(idleT.state)}`); + + // Three independent answers, because one of them being absent is not evidence of + // an idle machine - it is evidence of a field that did not arrive. + const running = stats.state === 'printing' || stats.state === 'paused' + || sd.is_active === true; + check('the machine is idle, so a cancel can be sent harmlessly', running, false); + + if (running) { + say(''); + say('REFUSED a print is in progress. This script will not send a cancel to a'); + say(' running job - that is a decision for a person at the machine.'); + window.__report = L.join('\n'); + return; + } + + /* ---- the one command this script sends ---- */ + say(''); + say('--- sw_MachinePrintCancel, to an idle machine ---'); + let reply = null; + let failed = null; + const t0 = Date.now(); + try { + reply = await pp.bridge.request(pp.CMD.PRINT_CANCEL, {}); + } catch (e) { + failed = e.message; + } + const ms = Date.now() - t0; + + say(` round trip ${ms} ms`); + say(` reply ${JSON.stringify(reply)}`); + say(` error ${JSON.stringify(failed)}`); + + // The route is what is under test. A refusal from Klipper is a perfectly good + // answer - it means the command reached something that had an opinion about it. + // What would be bad is silence, or a bridge-level "not dispatched". + const routed = !(failed && /is answered inside Orca|not dispatched|REFUSED/i.test(failed)); + check('the command reached the machine rather than dying in the host', routed, true); + check('and it answered within the request window', ms < 15000, true); + + say(''); + say('NOTE the machine was idle, so nothing was stopped. That a RUNNING print stops'); + say(' is not shown here and cannot be without running one.'); + + window.__report = L.join('\n'); + }; + + let n = 0; + const tick = () => { + if ((window.__preprint && window.__preprint.ready && window.__preprint.model.connected) + || n++ > 200) setTimeout(() => { run(); }, 1200); + else setTimeout(tick, 200); + }; + tick(); +})(); diff --git a/resources/web/shared/tests/drive/print-dialog-machine.js b/resources/web/shared/tests/drive/print-dialog-machine.js new file mode 100644 index 00000000000..4ac011aafd7 --- /dev/null +++ b/resources/web/shared/tests/drive/print-dialog-machine.js @@ -0,0 +1,159 @@ +/* + * The print dialog against a CONNECTED U1. Read-only. + * + * python3 resources/web/shared/tests/run_webkit.py --real --sn --size 714x750 \ + * --settle 25 --gcode ~/models/plate_1.gcode \ + * --page web/print_processing/index.html \ + * --drive resources/web/shared/tests/drive/print-dialog-machine.js + * + * This is the half nothing else can reach: a machine actually answering. Everything + * before it - the reply shapes, the geometry, the refusal rule - has been checked + * against a simulator built from the same documentation, which proves internal + * consistency and not that a U1 agrees. + * + * IT SENDS NOTHING. Assigning a filament writes `SET_PRINT_EXTRUDER_MAP` to the printer + * and a preference writes `SET_PRINT_PREFERENCES`; neither moves the machine, but both + * change its `print_task_config`, and a suite should not edit the state it is measuring. + * The picker is opened and read, never picked from. Same rule as drive/ace-real.js. + */ +(function () { + const L = []; + const say = (s) => L.push(s); + const check = (n, got, want) => + L.push(`${got === want ? 'PASS' : 'FAIL'} ${n}` + + (got === want ? '' : ` got ${JSON.stringify(got)} want ${JSON.stringify(want)}`)); + const q = (s) => document.querySelector(s); + const qq = (s) => [...document.querySelectorAll(s)]; + + const run = () => { + const pp = window.__preprint; + if (!pp) { window.__report = 'FAIL no window.__preprint'; return; } + const m = pp.model; + const tc = (pp.state.objects || {})['print_task_config'] || {}; + + /* ---- did the transport come up at all ---- */ + say('--- the connection ---'); + (pp.said || []).forEach((s) => say(` ${s}`)); + check('the machine half came up', m.connected, true); + check('and a device is selected', !!m.device, true); + if (m.device) say(` device: ${m.device.dev_name} @ ${m.device.ip} sn=${m.device.sn}`); + + /* ---- what the machine actually reports ---- + The raw shapes, printed rather than only asserted: this is the first time this + surface has seen them from hardware, and a future run wants something to diff. */ + say(''); + say('--- print_task_config, as the machine sent it ---'); + ['filament_type', 'filament_vendor', 'filament_sub_type', 'filament_color_rgba', + 'filament_exist', 'filament_official', 'filament_edit', + 'extruders_used', 'flow_calibrate', 'time_lapse_camera', 'auto_bed_leveling'] + .forEach((k) => say(` ${k.padEnd(20)} ${JSON.stringify(tc[k])}`)); + const table = tc.extruder_map_table; + say(` extruder_map_table ${Array.isArray(table) + ? `${table.length} entries: ${JSON.stringify(table.slice(0, 8))}…` : JSON.stringify(table)}`); + say(''); + say('--- extruders ---'); + ['extruder', 'extruder1', 'extruder2', 'extruder3'].forEach((k) => { + const e = (pp.state.objects || {})[k] || {}; + say(` ${k.padEnd(9)} nozzle=${JSON.stringify(e.nozzle_diameter)} ` + + `temp=${e.temperature} state=${JSON.stringify(e.state)}`); + }); + + check('print_task_config arrived', Object.keys(tc).length > 0, true); + check('it carries a filament type per slot', + Array.isArray(tc.filament_type) && tc.filament_type.length === 4, true); + // The nozzle is read off the EXTRUDER objects, not print_task_config. If the + // subscription's field filter dropped it, every match would fail and the dialog + // would refuse every toolhead - silently, and looking like a mismatch. + check('every toolhead reports a nozzle diameter', + m.toolheads.every((h) => h.nozzleDiameter != null), true); + + /* ---- the two sides, side by side ---- */ + say(''); + say('--- the file against the machine ---'); + m.filaments.forEach((f) => { + const at = m.assignment[f.key]; + const h = at == null ? null : m.toolheads[at]; + const dest = h + ? `head ${at + 1}: ${String(h.filamentType).padEnd(6)} ` + + `${String(h.nozzleDiameter).padEnd(4)} ` + + (h.filamentType === f.type && String(f.nozzle) === String(h.nozzleDiameter) + ? 'ok' : 'REFUSED') + : '(unassigned)'; + say(` file ${f.index + 1}: ${String(f.type).padEnd(6)} ` + + `${String(f.nozzle).padEnd(4)} -> ${dest}`); + }); + say(` nozzle banner: ${m.nozzleMismatch ? 'SHOWN' : 'silent'}`); + + /* ---- the refusal rule, on real data. Read, never clicked. ---- */ + const card = q('.fil-card .fil-pick'); + check('there is a filament card to open', !!card, true); + if (card) { + card.click(); // opening a menu writes nothing + const menu = q('.menu-head'); + check('the toolhead picker opens against a live machine', !!menu, true); + if (menu) { + const rows = [...menu.children].map((c) => ({ + type: (c.querySelector('.menu-type') || {}).textContent, + off: c.getAttribute('aria-disabled') === 'true', + tip: c.title, + })); + say(''); + say('--- what the picker offers for file filament 1 ---'); + rows.forEach((r, i) => say(` head ${i + 1}: ${String(r.type).padEnd(8)} ` + + `${r.off ? 'REFUSED' : 'offered'}${r.tip ? ' ' + r.tip : ''}`)); + check('one row per toolhead', rows.length, 4); + // Whatever the machine holds, a refusal must carry its reason and an offer + // must not - that pairing is the bundle's and does not depend on the data. + check('every refusal says which refusal it is', + rows.filter((r) => r.off).every((r) => /_tips$/.test(r.tip || '')), true); + check('and nothing offered carries one', + rows.filter((r) => !r.off).every((r) => !r.tip), true); + // The rule itself, recomputed here from the raw objects rather than from the + // page's own model - so a bug in the page cannot make this agree with it. + const f0 = m.filaments[0]; + const want = m.toolheads.map((h) => h.filamentType === f0.type + && f0.nozzle != null && String(f0.nozzle) === String(h.nozzleDiameter)); + check('the picker refuses exactly what the match rule says it should', + rows.map((r) => !r.off).join(','), want.join(',')); + } + // Close it without choosing anything. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + } + + /* ---- what the dialog does with a mapping nobody made ---- + This host has no `filament_extruder_map` - that is Orca's own config, and the + bridge only reads devices - so every filament arrives unassigned. Identity used + to fill in, which on this machine put filament 3 on an empty head and filament 4 + on PETG: two toolheads the picker itself refuses, with nothing on screen saying + so, because the warning mark means "nothing chosen" and something had been. */ + say(''); + say('--- an assignment nobody made ---'); + const unplaced = m.filaments.filter((f) => m.assignment[f.key] == null); + say(` ${unplaced.length} of ${m.filaments.length} filaments are unassigned`); + const marks = qq('.fil-card .disc-n').map((n) => n.textContent); + say(` card marks: ${JSON.stringify(marks)}`); + check('an unassigned filament is marked, not silently placed', + unplaced.length === 0 || marks.includes('!'), true); + check('and its card wears the warning border', + qq('.fil-card.unset').length, unplaced.length); + check('Send is refused while any filament has no home', + unplaced.length === 0 || q('#send').disabled, true); + + say(''); + check('nothing threw', window.__ppError || '-', '-'); + say('NOTE read-only: no mapping, no preference and no send was written.'); + window.__report = L.join('\n'); + }; + + window.addEventListener('error', (e) => { + window.__ppError = `${e.message} @${(e.filename || '').split('/').pop()}:${e.lineno}`; + }); + let n = 0; + const tick = () => { + // The connect is a pairing exchange and two MQTT sessions; give it room. + if ((window.__preprint && window.__preprint.ready && window.__preprint.model.connected) + || n++ > 200) setTimeout(run, 1200); + else setTimeout(tick, 200); + }; + tick(); +})(); diff --git a/resources/web/shared/tests/drive/print-dialog-real.js b/resources/web/shared/tests/drive/print-dialog-real.js new file mode 100644 index 00000000000..294e5aef42c --- /dev/null +++ b/resources/web/shared/tests/drive/print-dialog-real.js @@ -0,0 +1,122 @@ +/* + * The print dialog against the REAL bridge, and read-only. + * + * python3 resources/web/shared/tests/run_webkit.py --real --device-ip 192.0.2.1 \ + * --size 714x750 --gcode ~/models/plate_1.gcode \ + * --page web/print_processing/index.html \ + * --drive resources/web/shared/tests/drive/print-dialog-real.js + * + * What this covers that the simulator cannot: the ORCA half answered out of a real + * sliced plate rather than a fixture - the filament list Orca's own slicer wrote, its + * weights, its embedded thumbnail - and the not-connected branch, forced with an + * unroutable address so no printer is involved. + * + * It SENDS NOTHING. `drive/print-dialog.js` clicks through the toolhead picker against + * the simulator; against hardware this one only reads, because the send path ends in + * `sw_StartLocalPrint` and a suite that can start a print is a suite that will. + */ +(function () { + const L = []; + const say = (s) => L.push(s); + const check = (n, got, want) => + L.push(`${got === want ? 'PASS' : 'FAIL'} ${n}` + + (got === want ? '' : ` got ${JSON.stringify(got)} want ${JSON.stringify(want)}`)); + const q = (s) => document.querySelector(s); + const qq = (s) => [...document.querySelectorAll(s)]; + + const run = () => { + const pp = window.__preprint; + if (!pp) { window.__report = 'FAIL no window.__preprint'; return; } + const m = pp.model; + + /* ---- what the plate said about itself ---- */ + say('--- the plate, as Orca sliced it ---'); + say(`file ${(m.mapping && m.mapping.filename) || '(none)'}`); + say(`sliced for ${(m.mapping && m.mapping.machine_model) || '(not stated)'}`); + say(`estimate ${(m.mapping && m.mapping.estimated_time) || 0}s`); + say(`filaments ${m.filaments.map((f) => `${f.type}/${f.nozzle}/${f.used}g`).join(' ')}`); + say(`dropped ${((m.mapping && m.mapping.filament_type) || []).length + - m.filaments.length} with no material (A.bEE)`); + say(''); + + check('the mapping arrived', !!m.mapping, true); + // The shape is the handler's: parallel arrays, and no `filaments[]` anywhere. + check('the reply is parallel arrays, not filaments[]', + !!(m.mapping && Array.isArray(m.mapping.filament_type) + && m.mapping.filaments === undefined), true); + check('at least one filament survived the used-material filter', + m.filaments.length > 0, true); + check('every filament carries a weight', + m.filaments.every((f) => f.used > 0 || f.usedMm !== 0), true); + check('one card per surviving filament', qq('.fil-card').length, m.filaments.length); + + /* ---- the plate's own thumbnail, out of the gcode ---- */ + const src = (q('.mi-thumb') && q('.mi-thumb').getAttribute('src')) || ''; + check('the plate thumbnail came from the file', + src.startsWith('data:image/png;base64,') && src.length > 2000, true); + say(`thumbnail ${src.length} chars of base64`); + + /* ---- the not-connected branch ---- */ + say(''); + say('--- with no printer answering ---'); + check('no machine is connected', m.device, null); + check('every toolhead reads NONE', + m.toolheads.every((h) => h.filamentType === 'NONE'), true); + check('and none reports a nozzle', + m.toolheads.every((h) => h.nozzleDiameter == null), true); + // A.c8l is silent when the machine list is empty: a dialog that has not heard from + // the printer does not accuse it of a mismatch. + check('the nozzle banner stays silent', m.nozzleMismatch, false); + check('and is not drawn', q('#panel-nozzle').hidden, true); + check('Send is refused with nothing to send to', q('#send').disabled, true); + + /* ---- the dialog is still the right shape ---- */ + say(''); + check('five sections', qq('#body .card, #body .bare').length, 5); + const c = q('.fil-card').getBoundingClientRect(); + check('filament card is still 80x100', + `${Math.round(c.width)}x${Math.round(c.height)}`, '80x100'); + check('nothing threw', window.__ppError || '-', '-'); + + /* ---- picking a printer brings the transport up ---- + `sw_GetMachineState` answers out of a Moonraker host and something has to attach + one. Nothing has here, so choosing a printer must try - and against an unroutable + address it must FAIL AND SAY SO rather than leave four toolheads reading NONE, + which is indistinguishable from a printer with nothing loaded. */ + say(''); + say('--- picking a printer ---'); + q('.picker').click(); + setTimeout(() => { + const items = qq('.menu-printer .menu-item'); + check('the saved devices are offered', items.length > 1, true); + if (items.length > 1) { + items[0].click(); + setTimeout(() => { + const said = pp.said || []; + say(`reported: ${JSON.stringify(said.slice(-2))}`); + check('it tried to connect', + said.some((x) => /Connecting to the printer/.test(x)), true); + check('it reached the transport', + said.some((x) => /Opening mqtt/.test(x)), true); + check('and reported the failure instead of pretending', + said.some((x) => /^warn: could not read the printer/.test(x)), true); + check('the machine half is still down', pp.model.connected, false); + check('so Send stays refused', q('#send').disabled, true); + window.__report = L.join('\n'); + }, 4000); + } else { + window.__report = L.join('\n'); + } + }, 600); + }; + + window.addEventListener('error', (e) => { + window.__ppError = `${e.message} @${(e.filename || '').split('/').pop()}:${e.lineno}`; + }); + let n = 0; + const tick = () => { + if ((window.__preprint && window.__preprint.ready) || n++ > 80) setTimeout(run, 800); + else setTimeout(tick, 150); + }; + tick(); +})(); diff --git a/resources/web/shared/tests/drive/print-dialog.js b/resources/web/shared/tests/drive/print-dialog.js new file mode 100644 index 00000000000..fa40a2d4775 --- /dev/null +++ b/resources/web/shared/tests/drive/print-dialog.js @@ -0,0 +1,195 @@ +/* + * The rebuilt print dialog, against the numbers the shipped one is built from. + * + * python3 resources/web/shared/tests/run_webkit.py --size 714x750 \ + * --page 'web/print_processing/index.html?mock=1' \ + * --drive resources/web/shared/tests/drive/print-dialog.js + * + * The specification is docs/u1-webui/03-print-processing/original-dialog-mockup.html and + * every assertion here is one of its rows. Two things this checks that reading cannot: + * that the geometry SURVIVES a real layout in the engine Orca renders with, and that the + * behaviour the bundle carries - a toolhead that cannot be picked - is actually enforced + * rather than merely drawn. + */ +(function () { + const L = []; + const say = (s) => L.push(s); + const check = (n, got, want) => + L.push(`${got === want ? 'PASS' : 'FAIL'} ${n}` + + (got === want ? '' : ` got ${JSON.stringify(got)} want ${JSON.stringify(want)}`)); + const q = (s) => document.querySelector(s); + const qq = (s) => [...document.querySelectorAll(s)]; + const R = (e) => e.getBoundingClientRect(); + const W = (s) => Math.round(R(q(s)).width); + const H = (s) => Math.round(R(q(s)).height); + const css = (s, p) => getComputedStyle(q(s))[p]; + + const run = () => { + const pp = window.__preprint; + if (!pp) { window.__report = 'FAIL no window.__preprint'; return; } + + say(`route ${pp.route} filaments ${pp.model.filaments.length} ` + + `toolheads ${pp.model.toolheads.length}`); + say(''); + + /* ---- the sections, in the bundle's order ---- + ?path=5 drops the print half entirely, which is the whole difference between the + two routes. Run this script against both URLs; it checks whichever it is on. */ + const ids = qq('#body .card, #body .bare').map((n) => n.id).join(','); + if (pp.route === 'upload') { + check('upload-only keeps two sections', ids, 'panel-model-info,panel-printer'); + check('and no filament cards', qq('.fil-card').length, 0); + check('and no preferences', qq('.pref').length, 0); + check('and no nozzle banner', qq('.nozzle-warn').length, 0); + check('Send is still offered', q('.send').disabled, false); + window.__report = L.join('\n'); + return; + } + check('five sections, in A.bi3 order', ids, + 'panel-model-info,panel-printer,panel-filament,panel-preferences,panel-nozzle'); + + /* ---- the card ---- */ + check('card border is 2px (A.de(ax.ry,-1,2))', css('#panel-model-info', 'borderTopWidth'), '2px'); + check('card radius 8', css('#panel-model-info', 'borderTopLeftRadius'), '8px'); + check('card padding 12 (B.ch)', css('#panel-model-info', 'paddingTop'), '12px'); + const tw = Math.round(R(q('#panel-model-info .card-title')).width); + check('title column stays inside B.m4 (120..180)', tw >= 120 && tw <= 180, true); + say(` (title column resolved to ${tw}px - the bundle ships Roboto and this page ` + + 'uses the webview\'s own face, so the width inside the constraint differs)'); + const cards = qq('#body .card'); + check('sections are 8 apart (B.aB)', + Math.round(R(cards[1]).top - R(cards[0]).bottom), 8); + check('16 above the first (B.aE)', + Math.round(R(cards[0]).top - R(q('#body')).top), 16); + + /* ---- Model Information ---- */ + check('thumbnail is 100x100', `${W('.mi-thumb')}x${H('.mi-thumb')}`, '100x100'); + check('thumbnail radius 16', css('.mi-thumb', 'borderTopLeftRadius'), '16px'); + check('the plate thumbnail is DRAWN, not a grey box', + (q('.mi-thumb').getAttribute('src') || '').startsWith('data:image/'), true); + const facts = qq('.mi-fact').map((n) => n.textContent.split(':')[0]); + check('three facts, in the shipped order', facts.join('/'), + 'Filename/Estimated Time/Estimated Materials'); + check('Estimated Materials is toStringAsFixed(2)', + /\d+\.\d\d g$/.test(qq('.mi-fact')[2].textContent), true); + + /* ---- Select Printer ---- */ + check('printer picker is 300x50 (A.a2l(50,300,...))', + `${W('.picker')}x${H('.picker')}`, '300x50'); + // The address, which the shipped dialog does not draw - see deviceMeta(). + const meta0 = q('.picker .dev-meta'); + check('the chosen printer shows its address', + !!meta0 && /\d+\.\d+\.\d+\.\d+/.test(meta0.textContent), true); + // The two simulated devices differ by name AND address, so nothing needs the serial. + check('and not its serial, which nothing here needs to be told apart', + !!meta0 && /U1MOCK/.test(meta0.textContent), false); + + q('.picker').click(); + const pmenu = q('.menu-printer'); + check('the printer menu opens', !!pmenu, true); + if (pmenu) { + check('printer menu is 300 wide', Math.round(R(pmenu).width), 300); + check('items are 50 tall (B.aqV)', Math.round(R(pmenu.children[0]).height), 50); + check('the saved devices, plus the synthetic add-device row', + pmenu.children.length, pp.model.devices.length + 1); + const metas = qq('.menu-printer .dev-meta').map((n) => n.textContent); + check('every device row carries an address', + metas.length > 0 && metas.every((t) => /\d+\.\d+\.\d+\.\d+/.test(t)), true); + say(` device rows: ${JSON.stringify(metas)}`); + // Ellipsised is not shown. The row is 300 wide and the address is ~24 characters; + // if it does not fit, the line that was added to disambiguate two rows cannot. + const clipped = qq('.menu-printer .dev-meta') + .filter((n) => n.scrollWidth > n.clientWidth + 1); + check('and none of them is truncated', + clipped.map((n) => n.textContent).join(' | '), ''); + check('the connected one carries a check', qq('.menu-printer .menu-tick').length, 1); + } + q('.picker').click(); + + /* ---- Edit Filament: the part no screenshot ever showed ---- */ + check('one card per file filament', + qq('.fil-card').length, pp.model.filaments.length); + check('filament card is 80x100 (A.A0(...,80,100,8,...))', + `${W('.fil-card')}x${H('.fil-card')}`, '80x100'); + check('card radius 8', css('.fil-card', 'borderTopLeftRadius'), '8px'); + check('grid gap is 12 both ways (Wrap 12/12)', css('.fil-grid', 'gap'), '12px'); + const sh = H('.fil-block'); + const ph = H('.fil-pick'); + check('colour block takes 3 of the 7 parts', Math.abs(sh - 42) <= 4, true); + check('picker takes 4 of the 7 parts', Math.abs(ph - 56) <= 4, true); + check('a 1px rule between them', H('.fil-rule'), 1); + check('target disc is 28 (A.aJO(...,28,...,28))', W('.fil-card .disc'), 28); + check('the disc carries the 1-based toolhead number', + q('.fil-card .disc-n').textContent, '1'); + + /* ---- Print Preferences ---- */ + check('three preferences', qq('.pref').length, 3); + check('preference row is 220x20 (B.VS max)', `${W('.pref')}x${H('.pref')}`, '220x20'); + check('12 between rows (runSpacing)', + Math.round(R(qq('.pref')[1]).top - R(qq('.pref')[0]).bottom), 12); + check('one per line at this width', + Math.round(R(qq('.pref')[0]).top) !== Math.round(R(qq('.pref')[1]).top), true); + check('only the first carries a help control', qq('.pref-help').length, 1); + check('the toggle is an 18px round box, not a switch', + `${W('.pref-box')}x${H('.pref-box')}/${css('.pref-box', 'borderTopLeftRadius')}`, + '18x18/9px'); + + /* ---- the send bar ---- */ + check('progress track is 8 tall', H('.bar-track'), 8); + check('Send is 120x40', `${W('.send')}x${H('.send')}`, '120x40'); + check('bar padding is 16 (B.bV)', css('.dlg-foot', 'padding'), '16px'); + check('the percentage has no decimals', /^\d+%$/.test(q('#pct').textContent), true); + // Nothing this page adds for its own benefit may sit on the control being pressed. + const send = R(q('.send')); + const overlaps = ['#build-badge', '#status', '.trace-wrap'] + .map((s) => q(s)).filter((n) => n && !n.hidden) + .filter((n) => { const r = R(n); + return r.right > send.left && r.left < send.right + && r.bottom > send.top && r.top < send.bottom; }); + check('no reconstruction chrome covers the Send button', + overlaps.map((n) => n.className || n.id).join(','), ''); + + /* ---- the behaviour the bundle carries ---- */ + q('.fil-card .fil-pick').click(); + const menu = q('.menu-head'); + check('the toolhead picker opens', !!menu, true); + if (menu) { + check('toolhead menu is 200 wide', Math.round(R(menu).width), 200); + check('item height 48 (B.aqU)', Math.round(R(menu.children[0]).height), 48); + check('menu caps at 300 (maxHeight)', parseInt(getComputedStyle(menu).maxHeight, 10), 300); + check('offset -50 from the trigger (B.asz)', + Math.abs((R(menu).left - R(q('.fil-card .fil-pick')).left) + 50) <= 1, true); + check('one item per toolhead', menu.children.length, pp.model.toolheads.length); + + // Filament 1 is PLA/0.4; heads 1 and 2 are PLA/0.4, 3 is PETG, 4 is ABS. + const dis = [...menu.children].filter((c) => c.getAttribute('aria-disabled') === 'true'); + check('a head whose type does not match is unpickable', dis.length, 2); + check('and says which refusal it is', + dis.map((c) => c.title).every((t) => /_tips$/.test(t)), true); + + // The flag is ENFORCED, not decoration: clicking a refused head changes nothing. + const before = JSON.stringify(pp.model.assignment); + dis[0].click(); + check('clicking a refused toolhead assigns nothing', + JSON.stringify(pp.model.assignment), before); + + // And a permitted one does. + const okItem = [...menu.children].find((c) => c.getAttribute('aria-disabled') !== 'true' + && c.getAttribute('aria-selected') !== 'true'); + if (okItem) { + okItem.click(); + check('a permitted toolhead is assigned', + JSON.stringify(pp.model.assignment) !== before, true); + } + } + + window.__report = L.join('\n'); + }; + + let n = 0; + const tick = () => { + if ((window.__preprint && window.__preprint.ready) || n++ > 60) setTimeout(run, 500); + else setTimeout(tick, 150); + }; + tick(); +})(); diff --git a/resources/web/shared/tests/drive/print-mockups.js b/resources/web/shared/tests/drive/print-mockups.js new file mode 100644 index 00000000000..e9603ea531b --- /dev/null +++ b/resources/web/shared/tests/drive/print-mockups.js @@ -0,0 +1,116 @@ +/* + * Walk one print-dialog mockup and say what is actually on it. + * + * The mockups are not the page, so they get no built-in checks - but they are the thing + * a decision is being made from, and "it rendered on my screen" is exactly the evidence + * CLAUDE.md says not to accept. This dumps the tree, then asserts the handful of facts + * that would be silently wrong if the fixture or a renderer broke: the plate thumbnail + * has a source, there is one row per file filament, the send button's state matches the + * scenario, and an assignment change actually repaints. + * + * python3 resources/web/shared/tests/run_webkit.py \ + * --page 'web/print_processing/mockups/option-b.html?scenario=mismatch' \ + * --drive resources/web/shared/tests/drive/print-mockups.js + */ +(function () { + const L = []; + const say = (s) => L.push(s); + const check = (name, ok, detail) => + L.push(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); + + const m = window.__mockup; + if (!m) { window.__report = 'FAIL no window.__mockup — the module never ran'; return; } + + say(`title: ${document.title}`); + say(`scenario: ${m.scenario} mode: ${m.mode}`); + say(`assign: [${m.model.assignment.join(', ')}]`); + say(`heads: ${m.model.heads.map((h, i) => + `${i + 1}:${h.loaded ? (h.type || '?') + '@' + h.nozzle : 'empty'}`).join(' ')}`); + say(''); + + /* ---- the tree ---- */ + const walk = (el, d) => { + const cls = el.className && typeof el.className === 'string' + ? '.' + el.className.trim().split(/\s+/).filter(Boolean).join('.') : ''; + const id = el.id ? '#' + el.id : ''; + const data = Object.keys(el.dataset || {}).sort() + .map((k) => `[${k}=${el.dataset[k]}]`).join(''); + const r = el.getBoundingClientRect(); + const geo = (r.width || r.height) + ? ` {${Math.round(r.width)}x${Math.round(r.height)}@${Math.round(r.left)},${Math.round(r.top)}}` : ''; + const hidden = el.hidden ? ' HIDDEN' : ''; + L.push(' '.repeat(d) + el.tagName.toLowerCase() + id + cls + data + geo + hidden); + [...el.children].forEach((c) => walk(c, d + 1)); + }; + walk(document.body, 0); + say(''); + say('--- checks ---'); + + /* ---- the dialog is the size the host opens ---- */ + const h = document.documentElement.clientHeight; + check('dialog does not scroll', document.body.scrollHeight <= h + 1, + `body ${document.body.scrollHeight} vs viewport ${h}`); + + /* ---- the plate thumbnail is drawn, and drawn WITHOUT a click ---- + Every design has to answer "is this the right plate" on arrival. Whether it does + is the check; how big the picture is, is the design's business. */ + const img = document.querySelector('img.thumb'); + check('plate thumbnail visible on arrival', !!img && !!img.getAttribute('src'), + img ? `${Math.round(img.getBoundingClientRect().width)}px wide` : 'no img.thumb'); + if (img) { + check('thumbnail actually decoded', img.naturalWidth > 0, + `naturalWidth ${img.naturalWidth}`); + } + + /* ---- a way to choose a destination, per filament ---- + Each design counts its own, because they are not the same control: A and C use a + select per filament, B uses a lane of toolhead buttons. Asking every design for a + `select` is how a working design reports FAIL. */ + const wanted = m.mapping.filament_type.length; + const controls = m.destinationControls ? m.destinationControls() : 0; + if (m.mode === 'print') { + check('a destination control per filament', + controls >= wanted || m.disclosed, + `${controls} controls for ${wanted} filaments` + + (m.disclosed ? ' (this design discloses the mapping on demand)' : '')); + } else { + check('upload-only hides the mapping', controls === 0, + `${controls} destination controls`); + } + + /* ---- every filament colour reached the DOM ---- + Only in print mode: ?path=5 drops the mapping entirely, so no swatches is the + correct answer there and asserting otherwise fails three working designs. */ + const swatches = [...document.querySelectorAll('.sw')] + .map((s) => getComputedStyle(s).backgroundColor); + if (m.mode === 'print') { + check('swatches painted', swatches.length > 0, `${swatches.length} drawn`); + } else { + check('upload-only draws no filament swatches', swatches.length === 0, + `${swatches.length} drawn`); + } + + /* ---- Send agrees with the scenario ---- */ + const send = document.querySelector('#send'); + const mustBlock = m.scenario === 'noprinter' || m.scenario === 'wrongmodel' + || (m.mode === 'print' && m.scenario === 'mismatch'); + check('send button reflects the scenario', !!send && send.disabled === mustBlock, + `disabled=${send && send.disabled}, expected ${mustBlock}`); + + /* ---- an edit repaints ---- */ + const picks = document.querySelectorAll('select.head-pick'); + if (picks.length) { + const before = document.body.innerHTML.length; + const sel = picks[0]; + const other = [...sel.options].find((o) => o.value !== sel.value); + sel.value = other.value; + sel.dispatchEvent(new Event('change')); + const moved = m.model.assignment[0] === Number(other.value); + check('an assignment change lands in the model', moved, + `assignment[0] = ${m.model.assignment[0]}, set ${other.value}`); + check('and repaints', document.body.innerHTML.length !== before || moved, + `${before} -> ${document.body.innerHTML.length} chars`); + } + + window.__report = L.join('\n'); +})(); diff --git a/resources/web/shared/tests/drive/print-send-cancel-real.js b/resources/web/shared/tests/drive/print-send-cancel-real.js new file mode 100644 index 00000000000..121ff599387 --- /dev/null +++ b/resources/web/shared/tests/drive/print-send-cancel-real.js @@ -0,0 +1,170 @@ +/* + * The send path, end to end, on a real machine - and then stopped. + * + * python3 resources/web/shared/tests/run_webkit.py --real --sn --allow-print \ + * --size 714x750 --settle 25 --gcode ~/models/plate.gcode \ + * --page web/print_processing/index.html \ + * --drive resources/web/shared/tests/drive/print-send-cancel-real.js + * + * THIS STARTS A PRINT. It is the only script here that does, it needs --allow-print to + * get past the bridge, and it should be run with a person watching the machine. + * + * It is the last unmeasured stretch of this surface: everything up to `sw_GetFileStream` + * has been seen against hardware and nothing past it has. Three things are genuinely + * unknown and this is what answers them: + * + * - does a multipart POST to the printer's /server/files/upload work from this page, + * cross-origin from Orca's own HTTP server + * - what `{type, path}` does THIS firmware want from `server.files.start_local_print` + * - does the job actually stop when told to + * + * The rails: + * + * - it refuses to start if anything is already printing + * - every filament is mapped to a toolhead the picker ACCEPTS, chosen before the send + * - the cancel is sent UNCONDITIONALLY after the start command returns, whatever it + * returned, because an `ok` is not a yes and a start that looked like it failed may + * not have + * - and again from a `finally`, so a throw anywhere in the middle still stops the job + */ +(function () { + const L = []; + const say = (s) => L.push(s); + const check = (n, got, want) => + L.push(`${got === want ? 'PASS' : 'FAIL'} ${n}` + + (got === want ? '' : ` got ${JSON.stringify(got)} want ${JSON.stringify(want)}`)); + const q = (s) => document.querySelector(s); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const stateNow = (pp) => ((pp.state.objects || {})['print_stats'] || {}).state; + + async function cancel(pp, why) { + say(` -> sw_MachinePrintCancel (${why})`); + try { + const r = await pp.bridge.request(pp.CMD.PRINT_CANCEL, {}); + say(` reply ${JSON.stringify(r)}`); + return true; + } catch (e) { + say(` FAILED ${e.message}`); + return false; + } + } + + const run = async () => { + const pp = window.__preprint; + if (!pp) { window.__report = 'FAIL no window.__preprint'; return; } + const m = pp.model; + + check('the machine half came up', m.connected, true); + if (!m.connected) { window.__report = L.join('\n'); return; } + + /* ---- rail 1: nothing may already be running ---- */ + const before = stateNow(pp); + say(`print_stats.state before: ${JSON.stringify(before)}`); + if (before === 'printing' || before === 'paused') { + say('REFUSED something is already printing. Not touching it.'); + window.__report = L.join('\n'); + return; + } + + /* ---- rail 2: a mapping the machine will accept ---- + Every file filament onto ONE head that takes it, so there are no toolchanges in + the few seconds this job is alive. */ + const { matchOf } = pp.session; + // One head for everything if the machine allows it - a job with no toolchange is a + // job with less to interrupt. Otherwise each filament gets the first head that + // accepts it, which is all the picker would have offered anyway. + const single = m.toolheads.find((h) => m.filaments.every((f) => matchOf(f, h).ok)); + const plan = m.filaments.map((f) => ({ + f, head: single || m.toolheads.find((h) => matchOf(f, h).ok) || null, + })); + const homeless = plan.filter((p) => !p.head); + check('every filament on this plate has a toolhead that accepts it', + homeless.length, 0); + if (homeless.length) { + say(` ${homeless.map((p) => p.f.type).join(', ')} - not loaded on this machine`); + window.__report = L.join('\n'); + return; + } + say(''); + say(single + ? `--- mapping all ${m.filaments.length} filaments to toolhead ${single.index + 1} ` + + `(${single.filamentType} @ ${single.nozzleDiameter}) - no toolchange ---` + : `--- mapping each filament to the first head that accepts it ---`); + for (const { f, head } of plan) { + say(` filament ${f.index + 1} (${f.type}) -> toolhead ${head.index + 1}`); + pp.ctx.assign(f, head.index); + await sleep(250); // each write is a SET_PRINT_EXTRUDER_MAP + } + await sleep(1200); + say(` assignment: ${JSON.stringify(m.assignment)}`); + check('every filament now has a home', + m.filaments.every((f) => m.assignment[f.key] != null), true); + check('and Send is offered', q('#send').disabled, false); + + /* ---- the send ---- */ + say(''); + say('--- sending ---'); + let started = false; + try { + const t0 = Date.now(); + q('#send').click(); + // Watch the send bar's state machine rather than the promise, which the page owns. + let last = null; + for (let i = 0; i < 240; i++) { + const st = q('#send').dataset.state; + if (st !== last) { + say(` ${String(Date.now() - t0).padStart(6)}ms ${st}` + + (st === 'uploading' ? ` ${q('#pct').textContent}` : '')); + last = st; + } + if (st === 'starting') started = true; + if (st === 'done' || st === 'failed') break; + await sleep(250); + } + say(` final state: ${q('#send').dataset.state}`); + say(' the wire:'); + (pp.said || []).filter((x) => x.startsWith('send:') || x.startsWith('err:')) + .forEach((x) => say(` ${x}`)); + check('the send reached the start command', started, true); + } catch (e) { + say(` THREW ${e.message}`); + } finally { + /* ---- the cancel, unconditionally ---- */ + say(''); + say('--- stopping ---'); + await cancel(pp, 'unconditional, immediately after the send'); + } + + /* ---- did it actually stop? ---- */ + say(''); + say('--- print_stats after the cancel ---'); + let settled = null; + for (let i = 0; i < 40; i++) { + const st = stateNow(pp); + if (st !== settled) { say(` ${String(i * 500).padStart(6)}ms ${JSON.stringify(st)}`); settled = st; } + if (st === 'cancelled' || st === 'standby' || st === 'complete') break; + await sleep(500); + } + // A second cancel if it is somehow still going - cheap, and the point of the exercise. + if (settled === 'printing' || settled === 'paused') { + await cancel(pp, 'still running after the first'); + await sleep(3000); + settled = stateNow(pp); + say(` after the second: ${JSON.stringify(settled)}`); + } + check('the machine is not printing', settled === 'printing' || settled === 'paused', false); + say(` ended at ${JSON.stringify(settled)}`); + + window.__report = L.join('\n'); + }; + + let n = 0; + const tick = () => { + if ((window.__preprint && window.__preprint.ready && window.__preprint.model.connected) + || n++ > 200) setTimeout(() => { run().catch((e) => { + window.__report = `${L.join('\n')}\nFAIL threw: ${e && e.message}`; + }); }, 1500); + else setTimeout(tick, 200); + }; + tick(); +})(); diff --git a/resources/web/shared/tests/run_webkit.py b/resources/web/shared/tests/run_webkit.py index 91658b7357d..f1b50a28cb6 100644 --- a/resources/web/shared/tests/run_webkit.py +++ b/resources/web/shared/tests/run_webkit.py @@ -30,6 +30,7 @@ there is no pycairo here to receive. """ import argparse +import urllib.parse import functools import http.server import json @@ -466,6 +467,26 @@ class Q(socketserver.TCPServer): return httpd.server_address[1] +def _file_url_factory(port): + """Make a local path fetchable by the page, under the directory already served. + + Orca's `make_wcp_download_url` base64s the path onto its own page server. Here the + harness already has one serving `resources/`, so the honest equivalent is to place + the file under it and hand back that URL - same origin as the page, same `fetch`. + """ + drop = os.path.join(SERVE, "_bridge_files") + os.makedirs(drop, exist_ok=True) + + def to_url(path): + import shutil # noqa: PLC0415 + name = os.path.basename(path) + dest = os.path.join(drop, name) + if os.path.abspath(path) != os.path.abspath(dest): + shutil.copy2(path, dest) + return f"http://127.0.0.1:{port}/_bridge_files/{urllib.parse.quote(name)}" + return to_url + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--shots", help="directory to write screenshots into") @@ -501,6 +522,21 @@ def main(): ap.add_argument("--drive", metavar="FILE", help="run this JavaScript in the page instead of the built-in " "checks; it reports by setting window.__report when done") + ap.add_argument("--gcode", metavar="FILE", + help="with --real: answer the print dialog's Orca-side commands out " + "of this sliced .gcode - the filament list, the thumbnail, the " + "estimate and the packaged file all come from it. Without one " + "those commands refuse and say why.") + ap.add_argument("--allow-print", action="store_true", + help="let sw_StartLocalPrint through to the printer. It STARTS A " + "PRINT. Without it the send path is exercised up to that " + "command and refused there, which is what a suite wants.") + ap.add_argument("--page", metavar="PATH", + help="load this path under resources/ instead of the Device page, " + "e.g. web/print_processing/index.html?mock=1. The built-in " + "checks are the Device page's and are SKIPPED with this - pass " + "--drive with the surface's own script, or --watch to look by " + "hand.") ap.add_argument("--watch", type=float, nargs="?", const=0.0, default=None, metavar="SECONDS", help="leave the window open after the checks so it can be used by " @@ -581,7 +617,17 @@ def to_page(msg, as_string=True): devices = [dict(d, ip=args.device_ip) for d in devices] print(f" pretending the printer is at {args.device_ip}") bridge = Bridge(send=to_page, devices=devices, - trace=100000 if args.trace else 120) + trace=100000 if args.trace else 120, + gcode=args.gcode, allow_print=args.allow_print) + # sw_GetFileStream hands the page a URL rather than the bytes, and the page then + # fetches it - so the zip has to be reachable over HTTP. The harness is already + # serving resources/ ; this copies the packaged file in under a scratch path and + # returns its URL, which is the same shape as Orca's own page server. + bridge.file_url = _file_url_factory(port) + if args.gcode: + print(f" print dialog reads its job from {args.gcode}") + if args.allow_print: + print(" --allow-print: sw_StartLocalPrint WILL reach the printer") if args.original: # Orca reaches the Device tab with a printer host already attached. The # bundle never brings one up itself - it makes its own MQTT clients for @@ -608,6 +654,11 @@ def on_wx(_ucm, result): bundle = f"http://127.0.0.1:{port}/web/flutter_web/index.html?path=" if args.original: url = bundle + (args.prime or "2") + elif args.page: + # Any other surface served out of resources/ - the mockups, chiefly. The bridge + # and the simulator are still installed, so a page that speaks to window.wx gets + # answered; a page that does not simply ignores them. + url = f"http://127.0.0.1:{port}/" + args.page.lstrip("/") else: url = f"http://127.0.0.1:{port}/web/device_page/index.html" if not args.real: @@ -718,6 +769,22 @@ def picked(v, res): state["report"] = "\n".join(lines) state["rc"] = 1 if "FAIL" in state["report"] else 0 return wrap_up() + if args.page: + # The built-in checks are the DEVICE PAGE's - they look for + # window.__devicePage and report "the page did not finish booting" against + # anything else, which reads as a failure of the page under test rather than + # of the check. A surface loaded with --page brings its own; say so instead + # of running the wrong ones. + state["report"] = ( + "INFO --page: the built-in checks are the Device page's and were not " + "run.\n" + "INFO Pass --drive with this surface's own script, e.g.\n" + "INFO resources/web/shared/tests/drive/print-dialog.js " + "(simulator)\n" + "INFO resources/web/shared/tests/drive/print-dialog-real.js " + "(--real, read-only)") + state["rc"] = 0 + return wrap_up() view.evaluate_javascript(REAL_CHECKS if args.real else CHECKS, -1, None, None, None, done) return False diff --git a/resources/web/shared/tests/unit_jsc.py b/resources/web/shared/tests/unit_jsc.py index d605bf2b89e..1f63578625a 100644 --- a/resources/web/shared/tests/unit_jsc.py +++ b/resources/web/shared/tests/unit_jsc.py @@ -858,7 +858,7 @@ def step(objs): # it is worth testing as itself rather than only through one of its customers. It # touches nothing but a Map and a clock, so there is nothing to stub but the clock. print("\n== pending: request against mirror ==") - ctxp = new_ctx(os.path.join(WEB, "device_page", "js", "core", "pending.js")) + ctxp = new_ctx(os.path.join(SHARED, "js", "pending.js")) js(ctxp, """ var NOW = 1000000; var fired = []; @@ -919,7 +919,7 @@ def step(objs): print("\n== the temperature row ==") # The temperature row is the Control panel's DOM, which lives with the rest of that # panel now rather than in one shared ui.js. - ctx4 = new_ctx(os.path.join(WEB, "device_page", "js", "core", "pending.js"), + ctx4 = new_ctx(os.path.join(SHARED, "js", "pending.js"), os.path.join(WEB, "device_page", "js", "views", "device-control", "control", "control-view.js")) # ui.js is written against a DOM there is none of here. The row functions touch four